Merge fa48061df3 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-08 10:36:59 -08:00
committed by GitHub
56 changed files with 2409 additions and 827 deletions
@@ -77,6 +77,12 @@ for arg in "\$@"; do
fi
continue
;;
-Wp,-U_FORTIFY_SOURCE)
# aws-lc-sys emits this GCC preprocessor forwarding form in debug
# builds, but zig cc expects the define flag directly.
args+=("-U_FORTIFY_SOURCE")
continue
;;
esac
args+=("\${arg}")
done
@@ -96,15 +102,23 @@ for arg in "\$@"; do
fi
case "\${arg}" in
--target)
# Drop explicit --target and its value: we always pass zig's -target below.
skip_next=1
continue
;;
--target=*|-target=*|-target)
# Zig expects -target and rejects Rust triples like *-unknown-linux-musl.
if [[ "\${arg}" == "-target" ]]; then
skip_next=1
fi
continue
;;
-Wp,-U_FORTIFY_SOURCE)
# aws-lc-sys emits this GCC forwarding form in debug builds; zig c++
# expects the define flag directly.
args+=("-U_FORTIFY_SOURCE")
continue
;;
esac
args+=("\${arg}")
done
-9
View File
@@ -45,15 +45,6 @@ jobs:
echo "✅ Tag and Cargo.toml agree (${tag_ver})"
echo "::endgroup::"
- name: Verify config schema fixture
shell: bash
working-directory: codex-rs
run: |
set -euo pipefail
echo "If this fails, run: just write-config-schema to overwrite fixture with intentional changes."
cargo run -p codex-core --bin codex-write-config-schema
git diff --exit-code core/config.schema.json
build:
needs: tag-check
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
+14
View File
@@ -3,6 +3,10 @@ bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.3.1")
archive_override(
module_name = "toolchains_llvm_bootstrapped",
integrity = "sha256-4/2h4tYSUSptxFVI9G50yJxWGOwHSeTeOGBlaLQBV8g=",
patch_strip = 1,
patches = [
"//patches:toolchains_llvm_bootstrapped_resource_dir.patch",
],
strip_prefix = "toolchains_llvm_bootstrapped-d20baf67e04d8e2887e3779022890d1dc5e6b948",
urls = ["https://github.com/cerisier/toolchains_llvm_bootstrapped/archive/d20baf67e04d8e2887e3779022890d1dc5e6b948.tar.gz"],
)
@@ -74,6 +78,16 @@ crate.annotation(
strip_prefix = "matcher",
version = "0.3.1",
)
crate.annotation(
build_script_env = {
"AWS_LC_SYS_NO_JITTER_ENTROPY": "1",
},
patch_args = ["-p1"],
patches = [
"//patches:aws-lc-sys_memcmp_check.patch",
],
crate = "aws-lc-sys",
)
bazel_dep(name = "openssl", version = "3.5.4.bcr.0")
+145 -17
View File
File diff suppressed because one or more lines are too long
+214 -147
View File
@@ -458,6 +458,45 @@ dependencies = [
"term",
]
[[package]]
name = "asn1-rs"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -684,6 +723,29 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.8"
@@ -786,24 +848,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.1",
"shlex",
"syn 2.0.114",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -994,15 +1038,6 @@ 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 = [
"nom 7.1.3",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -1087,17 +1122,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.56"
@@ -1197,6 +1221,7 @@ dependencies = [
"pretty_assertions",
"regex-lite",
"reqwest",
"rustls",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -1205,6 +1230,7 @@ dependencies = [
"tokio-tungstenite",
"tokio-util",
"tracing",
"tungstenite",
"url",
"wiremock",
]
@@ -1539,6 +1565,7 @@ dependencies = [
"codex-file-search",
"codex-git",
"codex-keyring-store",
"codex-network-proxy",
"codex-otel",
"codex-protocol",
"codex-rmcp-client",
@@ -1875,9 +1902,8 @@ dependencies = [
"anyhow",
"async-trait",
"clap",
"codex-app-server-protocol",
"codex-core",
"codex-utils-absolute-path",
"codex-utils-home-dir",
"globset",
"pretty_assertions",
"rama-core",
@@ -1886,13 +1912,14 @@ dependencies = [
"rama-net",
"rama-socks5",
"rama-tcp",
"rama-tls-boring",
"rama-tls-rustls",
"rama-unix",
"serde",
"serde_json",
"tempfile",
"time",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-subscriber",
"url",
@@ -2834,6 +2861,20 @@ dependencies = [
"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"
@@ -3414,6 +3455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide",
]
@@ -3517,28 +3559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
]
[[package]]
name = "foreign-types-macros"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"foreign-types-shared",
]
[[package]]
@@ -3547,12 +3568,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -3577,16 +3592,6 @@ dependencies = [
"libc",
]
[[package]]
name = "fslock"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -3786,12 +3791,6 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
@@ -3882,6 +3881,30 @@ 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 = "heck"
version = "0.5.0"
@@ -4829,16 +4852,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -4867,6 +4880,17 @@ dependencies = [
"vcpkg",
]
[[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 = "linux-keyutils"
version = "0.2.4"
@@ -5649,6 +5673,15 @@ dependencies = [
"memchr",
]
[[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"
@@ -5679,7 +5712,7 @@ checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
@@ -5957,6 +5990,16 @@ dependencies = [
"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"
@@ -6458,42 +6501,6 @@ dependencies = [
"nibble_vec",
]
[[package]]
name = "rama-boring"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f7f862c81618f9aef40bd32e73986321109a24272c79e040377c5ac29491e8"
dependencies = [
"bitflags 2.10.0",
"foreign-types 0.5.0",
"libc",
"openssl-macros",
"rama-boring-sys",
]
[[package]]
name = "rama-boring-sys"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5bfe3e86d71e9b91dae7561d5ceeaceb37a7d4fc078ab241afd7aab777f606f"
dependencies = [
"bindgen",
"cmake",
"fs_extra",
"fslock",
]
[[package]]
name = "rama-boring-tokio"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d71fab2ce4408cc40f819865501dbc63272ddab0e77dd3500ff77f1a0f883"
dependencies = [
"rama-boring",
"rama-boring-sys",
"tokio",
]
[[package]]
name = "rama-core"
version = "0.3.0-alpha.4"
@@ -6742,25 +6749,24 @@ dependencies = [
]
[[package]]
name = "rama-tls-boring"
name = "rama-tls-rustls"
version = "0.3.0-alpha.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "def3d5d06d3ca3a2d2e4376cf93de0555cd9c7960f085bf77be9562f5c9ace8f"
checksum = "536d47f6b269fb20dffd45e4c04aa8b340698b3509326e3c36e444b4f33ce0d6"
dependencies = [
"ahash",
"flume 0.12.0",
"itertools 0.14.0",
"moka",
"parking_lot",
"pin-project-lite",
"rama-boring",
"rama-boring-tokio",
"rama-core",
"rama-http-types",
"rama-net",
"rama-utils",
"schannel",
"rcgen",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"webpki-roots 1.0.5",
"x509-parser",
]
[[package]]
@@ -6922,6 +6928,20 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
dependencies = [
"aws-lc-rs",
"pem",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -7088,7 +7108,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -7226,6 +7246,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -7258,6 +7287,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -7295,9 +7325,10 @@ version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
"untrusted 0.9.0",
]
[[package]]
@@ -8959,7 +8990,7 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d"
source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6"
dependencies = [
"futures-util",
"log",
@@ -9353,11 +9384,13 @@ dependencies = [
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e"
version = "0.27.0"
source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f"
dependencies = [
"bytes",
"data-encoding",
"flate2",
"headers",
"http 1.4.0",
"httparse",
"log",
@@ -9519,6 +9552,12 @@ 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"
@@ -10589,6 +10628,25 @@ dependencies = [
"zeroize",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"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"
@@ -10614,6 +10672,15 @@ 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.1"
+7 -3
View File
@@ -93,6 +93,7 @@ codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-process-hardening = { path = "process-hardening" }
@@ -195,6 +196,7 @@ regex = "1.12.2"
regex-lite = "0.1.8"
reqwest = "0.12"
rmcp = { version = "0.12.0", default-features = false }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
schemars = "0.8.22"
seccompiler = "0.5.0"
@@ -227,6 +229,7 @@ tokio = "1"
tokio-stream = "0.1.18"
tokio-test = "0.4"
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
tokio-util = "0.7.18"
toml = "0.9.5"
toml_edit = "0.24.0"
@@ -317,10 +320,11 @@ opt-level = 0
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" }
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" }
# Uncomment to debug local changes.
# rmcp = { path = "../../rust-sdk/crates/rmcp" }
[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"]
tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" }
[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"]
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
@@ -1628,6 +1628,7 @@ impl CodexMessageProcessor {
cwd,
expiration: timeout_ms.into(),
env,
network: self.config.network.clone(),
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
justification: None,
@@ -1878,31 +1879,11 @@ impl CodexMessageProcessor {
..
} = new_conv;
let config_snapshot = thread.config_snapshot().await;
let fallback_provider = self.config.model_provider_id.as_str();
// A bit hacky, but the summary contains a lot of useful information for the thread
// that unfortunately does not get returned from thread_manager.start_thread().
let thread = match session_configured.rollout_path.as_ref() {
Some(rollout_path) => {
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider)
.await
{
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
),
)
.await;
return;
}
}
}
None => build_ephemeral_thread(thread_id, &config_snapshot),
};
let thread = build_thread_from_snapshot(
thread_id,
&config_snapshot,
session_configured.rollout_path.clone(),
);
let response = ThreadStartResponse {
thread: thread.clone(),
@@ -2498,7 +2479,8 @@ impl CodexMessageProcessor {
return;
};
let config_snapshot = thread.config_snapshot().await;
if include_turns {
let loaded_rollout_path = thread.rollout_path();
if include_turns && loaded_rollout_path.is_none() {
self.send_invalid_request_error(
request_id,
"ephemeral threads do not support includeTurns".to_string(),
@@ -2506,7 +2488,10 @@ impl CodexMessageProcessor {
.await;
return;
}
build_ephemeral_thread(thread_uuid, &config_snapshot)
if include_turns {
rollout_path = loaded_rollout_path.clone();
}
build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path)
};
if include_turns && let Some(rollout_path) = rollout_path.as_ref() {
@@ -2514,6 +2499,16 @@ impl CodexMessageProcessor {
Ok(events) => {
thread.turns = build_turns_from_event_msgs(&events);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
self.send_invalid_request_error(
request_id,
format!(
"thread {thread_uuid} is not materialized yet; includeTurns is unavailable before first user message"
),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
@@ -5793,7 +5788,11 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String
updated_at.or_else(|| created_at.map(str::to_string))
}
fn build_ephemeral_thread(thread_id: ThreadId, config_snapshot: &ThreadConfigSnapshot) -> Thread {
fn build_thread_from_snapshot(
thread_id: ThreadId,
config_snapshot: &ThreadConfigSnapshot,
path: Option<PathBuf>,
) -> Thread {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
Thread {
id: thread_id.to_string(),
@@ -5801,7 +5800,7 @@ fn build_ephemeral_thread(thread_id: ThreadId, config_snapshot: &ThreadConfigSna
model_provider: config_snapshot.model_provider_id.clone(),
created_at: now,
updated_at: now,
path: None,
path,
cwd: config_snapshot.cwd.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source: config_snapshot.session_source.clone().into(),
@@ -1,12 +1,19 @@
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::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::ArchiveConversationResponse;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use std::path::Path;
use tempfile::TempDir;
@@ -16,8 +23,9 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -40,9 +48,50 @@ async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<
..
} = to_response::<NewConversationResponse>(new_response)?;
assert!(
!rollout_path.exists(),
"expected rollout path {} to be deferred until first user message",
rollout_path.display()
);
let add_listener_request_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.await?;
let add_listener_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_request_id)),
)
.await??;
let AddConversationSubscriptionResponse { subscription_id: _ } =
to_response::<AddConversationSubscriptionResponse>(add_listener_response)?;
let send_request_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![InputItem::Text {
text: "materialize".to_string(),
text_elements: Vec::new(),
}],
})
.await?;
let send_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_request_id)),
)
.await??;
let _: SendUserMessageResponse = to_response::<SendUserMessageResponse>(send_response)?;
let _: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
assert!(
rollout_path.exists(),
"expected rollout path {} to exist",
"expected rollout path {} to exist after first user message",
rollout_path.display()
);
@@ -81,14 +130,25 @@ async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<
Ok(())
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
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())
std::fs::write(config_toml, config_contents(server_uri))
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
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
"#
)
}
@@ -19,7 +19,9 @@ 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::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -270,6 +272,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
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 {
@@ -387,6 +390,30 @@ async fn start_default_thread(mcp: &mut McpProcess) -> Result<String> {
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")
}
@@ -1,12 +1,17 @@
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::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::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_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::find_thread_path_by_id_str;
use std::path::Path;
@@ -16,9 +21,10 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> {
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())?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -38,17 +44,73 @@ async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> {
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
assert!(!thread.id.is_empty());
// Locate the rollout path recorded for this thread id.
let rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected rollout path for thread id to exist");
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)
.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::<TurnStartResponse>(turn_start_response)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert!(
rollout_path.exists(),
"expected {} to exist",
"expected rollout path {} to exist after first user message",
rollout_path.display()
);
// Archive the thread.
let discovered_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.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(),
@@ -80,14 +142,32 @@ async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> {
Ok(())
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
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())
std::fs::write(config_toml, config_contents(server_uri))
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
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(())
}
@@ -3,6 +3,7 @@ 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::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -10,6 +11,8 @@ 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::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
@@ -117,6 +120,51 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
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::<ThreadStartResponse>(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(())
}
// 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");
@@ -3,12 +3,15 @@ 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::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::SessionSource;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_protocol::user_input::ByteRange;
@@ -134,6 +137,105 @@ async fn thread_read_can_include_turns() -> Result<()> {
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::<ThreadStartResponse>(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::<ThreadReadResponse>(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);
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::<ThreadStartResponse>(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(())
}
// 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");
@@ -35,7 +35,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
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.";
#[tokio::test]
async fn thread_resume_returns_original_thread() -> Result<()> {
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())?;
@@ -57,24 +57,26 @@ async fn thread_resume_returns_original_thread() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
// Resume it via v2 API.
// 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_resp: JSONRPCResponse = timeout(
let resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
let mut expected = thread;
expected.updated_at = resumed.updated_at;
assert_eq!(resumed, expected);
assert!(
resume_err
.error
.message
.contains("no rollout found for thread id"),
"unexpected resume error: {}",
resume_err.error.message
);
Ok(())
}
@@ -322,6 +324,27 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(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 {
@@ -339,9 +362,8 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
let mut expected = thread;
expected.updated_at = resumed.updated_at;
assert_eq!(resumed, expected);
assert_eq!(resumed.id, thread.id);
assert_eq!(resumed.path, thread.path);
Ok(())
}
@@ -412,12 +434,17 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
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 response_mock = responses::mount_sse_once(&server, body).await;
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())?;
@@ -438,9 +465,30 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let materialize_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?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(materialize_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
thread_id: thread.id,
model: Some("gpt-5.2-codex".to_string()),
personality: Some(Personality::Friendly),
..Default::default()
@@ -451,11 +499,11 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let _resume: ThreadResumeResponse = to_response::<ThreadResumeResponse>(resume_resp)?;
let resume: ThreadResumeResponse = to_response::<ThreadResumeResponse>(resume_resp)?;
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
thread_id: resume.thread.id,
input: vec![UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -475,7 +523,10 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
)
.await??;
let request = response_mock.single_request();
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
@@ -59,6 +59,12 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
thread.created_at > 0,
"created_at should be a positive UNIX timestamp"
);
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"
);
// A corresponding thread/started notification should arrive.
let notif: JSONRPCNotification = timeout(
@@ -114,6 +120,37 @@ model_reasoning_effort = "high"
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(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.1".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 ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
thread.path, None,
"ephemeral threads should not expose a path"
);
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;
@@ -1,5 +1,6 @@
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;
@@ -9,6 +10,9 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
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::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use std::fs::FileTimes;
@@ -23,8 +27,9 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
#[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())?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -42,9 +47,34 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
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::<TurnStartResponse>(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)
.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 {
@@ -108,14 +138,32 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
Ok(())
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
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())
std::fs::write(config_toml, config_contents(server_uri))
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
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(())
}
+2
View File
@@ -16,9 +16,11 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true }
tungstenite = { workspace = true }
tracing = { workspace = true }
eventsource-stream = { workspace = true }
regex-lite = { workspace = true }
rustls = { workspace = true }
tokio-util = { workspace = true, features = ["codec"] }
url = { workspace = true }
@@ -30,12 +30,16 @@ use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::trace;
use tungstenite::extensions::ExtensionsConfig;
use tungstenite::extensions::compression::deflate::DeflateConfig;
use tungstenite::protocol::WebSocketConfig;
use url::Url;
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
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";
static RUSTLS_PROVIDER_INSTALLED: OnceLock<()> = OnceLock::new();
pub struct ResponsesWebsocketConnection {
stream: Arc<Mutex<Option<WsStream>>>,
@@ -162,6 +166,7 @@ async fn connect_websocket(
headers: HeaderMap,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<(WsStream, bool, Option<String>), ApiError> {
ensure_rustls_crypto_provider();
info!("connecting to websocket: {url}");
let mut request = url
@@ -170,7 +175,12 @@ async fn connect_websocket(
.map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?;
request.headers_mut().extend(headers);
let response = tokio_tungstenite::connect_async(request).await;
let response = tokio_tungstenite::connect_async_with_config(
request,
Some(websocket_config()),
false, // `false` means "do not disable Nagle", which is tungstenite's recommended default.
)
.await;
let (stream, response) = match response {
Ok((stream, response)) => {
@@ -203,6 +213,21 @@ async fn connect_websocket(
Ok((stream, reasoning_included, models_etag))
}
fn ensure_rustls_crypto_provider() {
let _ = RUSTLS_PROVIDER_INSTALLED.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
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) => {
@@ -328,3 +353,14 @@ async fn run_websocket_response_stream(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn websocket_config_enables_permessage_deflate() {
let config = websocket_config();
assert!(config.extensions.permessage_deflate.is_some());
}
}
+1
View File
@@ -35,6 +35,7 @@ codex-execpolicy = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-keyring-store = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-rmcp-client = { workspace = true }
+117 -258
View File
@@ -16,26 +16,17 @@
//! Preconnect is intentionally handshake-only: it may warm a socket and capture sticky-routing
//! state, but the first `response.create` payload is still sent only when a turn starts.
//!
//! Internally, startup preconnect and warmed-socket adoption share one session-level lifecycle:
//! `Idle` (no task/socket), `InFlight` (startup preconnect task running), and `Ready` (one-shot
//! warmed socket available). On first use in a turn, the session tries to adopt `Ready`; if not
//! ready, it awaits `InFlight` and retries adoption before opening a new websocket. This prevents
//! racing duplicate first-turn handshakes while keeping preconnect best-effort.
//! Internally, startup preconnect stores a single task handle. On first use in a turn, the session
//! awaits that task and adopts the warmed socket if it succeeds; if it fails, the stream attempt
//! fails and the normal retry/fallback loop decides what to do next.
//!
//! ## Retry-Budget Tradeoff
//!
//! `stream_max_retries` applies to retryable turn stream failures, not to background startup
//! preconnect handshakes. In failure cases this can produce two websocket handshakes on the first
//! turn (startup preconnect, then turn-time connect) before HTTP fallback becomes sticky. We keep
//! this split intentionally so opportunistic preconnect cannot consume the user-visible stream
//! retry budget before any turn payload is sent.
//!
//! If this policy needs to change later, preconnect can be modeled as an explicit first connection
//! attempt in the same retry budget as turn streaming. That would require plumbing websocket
//! attempt accounting from connection acquisition into the turn retry loop and updating fallback
//! expectations/tests accordingly.
//! Startup preconnect is treated as the first websocket connection attempt for the first turn. If
//! it fails, the stream attempt fails and the retry/fallback loop decides whether to retry or fall
//! back. This avoids duplicate handshakes but means a failed preconnect can consume one retry
//! budget slot before any turn payload is sent.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
@@ -82,6 +73,7 @@ use codex_protocol::protocol::SessionSource;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
use futures::StreamExt;
use futures::future::BoxFuture;
use http::HeaderMap as ApiHeaderMap;
use http::HeaderValue;
use http::StatusCode as HttpStatusCode;
@@ -94,7 +86,6 @@ use tokio::sync::oneshot::error::TryRecvError;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tracing::debug;
use tracing::warn;
use crate::AuthManager;
@@ -110,8 +101,6 @@ use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::turn_metadata::build_turn_metadata_header;
use crate::turn_metadata::resolve_turn_metadata_header_with_timeout;
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04";
@@ -121,6 +110,12 @@ 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";
struct PreconnectedWebSocket {
connection: ApiWebSocketConnection,
turn_state: Option<String>,
}
type PreconnectTask = JoinHandle<Option<PreconnectedWebSocket>>;
/// Session-scoped state shared by all [`ModelClient`] clones.
///
/// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most
@@ -137,11 +132,8 @@ struct ModelClientState {
include_timing_metrics: bool,
beta_features_header: Option<String>,
disable_websockets: AtomicBool,
/// Session-scoped preconnect lifecycle state.
///
/// This keeps startup preconnect task tracking and warmed-socket adoption in one lock so
/// turn-time websocket setup observes a single, coherent state.
preconnect: Mutex<PreconnectState>,
preconnect: Mutex<Option<PreconnectTask>>,
}
impl std::fmt::Debug for ModelClientState {
@@ -181,28 +173,6 @@ struct CurrentClientSetup {
api_auth: CoreAuthProvider,
}
/// One-shot preconnected websocket slot consumed by the next turn.
///
/// This bundles the socket with optional sticky-routing state captured during
/// handshake so they are taken and cleared atomically.
struct PreconnectedWebSocket {
connection: ApiWebSocketConnection,
turn_state: Option<String>,
}
/// Session-level lifecycle of startup websocket preconnect.
///
/// `InFlight` tracks the startup task so the first turn can await it and reuse the same socket.
/// `Ready` stores a one-shot warmed socket for turn adoption.
enum PreconnectState {
/// No startup preconnect task is active and no warmed socket is available.
Idle,
/// Startup preconnect is currently running; first turn may await this task.
InFlight(JoinHandle<()>),
/// Startup preconnect finished and produced a one-shot warmed socket.
Ready(PreconnectedWebSocket),
}
/// A session-scoped client for model-provider API calls.
///
/// This holds configuration and state that should be shared across turns within a Codex session
@@ -215,8 +185,6 @@ enum PreconnectState {
/// 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.
///
/// This type is cheap to clone.
#[derive(Debug, Clone)]
pub struct ModelClient {
state: Arc<ModelClientState>,
@@ -257,6 +225,11 @@ pub struct ModelClientSession {
turn_state: Arc<OnceLock<String>>,
}
enum WebsocketStreamOutcome {
Stream(ResponseStream),
FallbackToHttp,
}
impl ModelClient {
#[allow(clippy::too_many_arguments)]
/// Creates a new session-scoped `ModelClient`.
@@ -288,7 +261,7 @@ impl ModelClient {
include_timing_metrics,
beta_features_header,
disable_websockets: AtomicBool::new(false),
preconnect: Mutex::new(PreconnectState::Idle),
preconnect: Mutex::new(None),
}),
}
}
@@ -315,53 +288,57 @@ impl ModelClient {
///
/// A timeout when computing turn metadata is treated the same as "no metadata" so startup
/// cannot block indefinitely on optional preconnect context.
pub fn pre_establish_connection(&self, otel_manager: OtelManager, cwd: PathBuf) {
pub fn pre_establish_connection(
&self,
otel_manager: OtelManager,
turn_metadata_header: BoxFuture<'static, Option<String>>,
) {
if !self.responses_websocket_enabled() || self.disable_websockets() {
return;
}
let model_client = self.clone();
let handle = tokio::spawn(async move {
let turn_metadata_header = resolve_turn_metadata_header_with_timeout(
build_turn_metadata_header(cwd.as_path()),
None,
)
.await;
let _ = model_client
let turn_metadata_header = turn_metadata_header.await;
model_client
.preconnect(&otel_manager, turn_metadata_header.as_deref())
.await;
.await
});
self.store_preconnect_task(handle);
self.set_preconnected_task(Some(handle));
}
/// Opportunistically pre-establishes a Responses WebSocket connection for this session.
///
/// This method is best-effort: it returns `false` on any setup/connect failure and the caller
/// should continue normally. A successful preconnect reduces first-turn latency but never sends
/// an initial prompt; the first `response.create` is still sent only when a turn starts.
/// This method is best-effort: it returns an error on setup/connect failure and the caller
/// can decide whether to ignore it. A successful preconnect reduces first-turn latency but
/// never sends an initial prompt; the first `response.create` is still sent only when a turn
/// starts.
///
/// The preconnected slot is single-consumer and single-use: the next `ModelClientSession` may
/// adopt it once, after which later turns either keep using that same turn-local connection or
/// create a new one.
pub async fn preconnect(
async fn preconnect(
&self,
otel_manager: &OtelManager,
turn_metadata_header: Option<&str>,
) -> bool {
) -> Option<PreconnectedWebSocket> {
if !self.responses_websocket_enabled() || self.disable_websockets() {
return false;
return None;
}
let client_setup = match self.current_client_setup().await {
Ok(client_setup) => client_setup,
Err(err) => {
warn!("failed to build websocket preconnect client setup: {err}");
return false;
}
};
let turn_state = Arc::new(OnceLock::new());
let client_setup = self
.current_client_setup()
.await
.map_err(|err| {
ApiError::Stream(format!(
"failed to build websocket preconnect client setup: {err}"
))
})
.ok()?;
match self
let turn_state = Arc::new(OnceLock::new());
let connection = self
.connect_websocket(
otel_manager,
client_setup.api_provider,
@@ -370,16 +347,12 @@ impl ModelClient {
turn_metadata_header,
)
.await
{
Ok(connection) => {
self.store_preconnected_websocket(connection, turn_state.get().cloned());
true
}
Err(err) => {
debug!("websocket preconnect failed: {err}");
false
}
}
.ok()?;
Some(PreconnectedWebSocket {
connection,
turn_state: turn_state.get().cloned(),
})
}
/// Compacts the current conversation history using the Compact endpoint.
@@ -576,132 +549,26 @@ impl ModelClient {
headers
}
/// Consumes the warmed websocket slot.
fn take_preconnected_websocket(&self) -> Option<PreconnectedWebSocket> {
/// Consumes the warmed websocket task slot.
fn take_preconnected_task(&self) -> Option<PreconnectTask> {
let mut state = self
.state
.preconnect
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = std::mem::replace(&mut *state, PreconnectState::Idle);
match previous {
PreconnectState::Ready(preconnected) => Some(preconnected),
other => {
*state = other;
None
}
}
state.take()
}
/// Stores a freshly preconnected websocket and optional captured turn-state token.
///
/// This overwrites any previously warmed socket because only one preconnect candidate is kept.
fn store_preconnected_websocket(
&self,
connection: ApiWebSocketConnection,
turn_state: Option<String>,
) {
fn set_preconnected_task(&self, task: Option<PreconnectTask>) {
let mut state = self
.state
.preconnect
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.disable_websockets() {
debug!("discarding startup websocket preconnect because websocket fallback is active");
*state = PreconnectState::Idle;
return;
}
*state = PreconnectState::Ready(PreconnectedWebSocket {
connection,
turn_state,
});
}
/// Stores the latest startup preconnect task handle.
///
/// If a previous task is still running, it is aborted so only one in-flight startup attempt
/// is tracked.
fn store_preconnect_task(&self, task: JoinHandle<()>) {
let mut task = Some(task);
let previous_in_flight = {
let mut state = self
.state
.preconnect
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match &*state {
// A very fast startup preconnect can complete before this method stores the
// task handle; keep the warmed socket and drop the now-useless handle.
PreconnectState::Ready(_) => None,
_ => match task.take() {
Some(next_task) => {
match std::mem::replace(&mut *state, PreconnectState::InFlight(next_task)) {
PreconnectState::InFlight(previous) => Some(previous),
_ => None,
}
}
None => None,
},
}
};
if let Some(previous) = previous_in_flight {
previous.abort();
}
if let Some(task) = task {
task.abort();
}
}
/// Awaits the startup preconnect task once, if one is currently tracked.
///
/// This lets the first turn treat startup preconnect as the first websocket connection
/// attempt, avoiding a redundant second connect while the preconnect attempt is in flight.
///
/// This await intentionally has no separate timeout wrapper. WebSocket connect handshakes
/// already run without an app-level timeout, so waiting on the in-flight preconnect task does
/// not add a new unbounded wait class; it reuses the same first connection attempt.
async fn await_preconnect_task(&self) {
let task = {
let mut state = self
.state
.preconnect
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = std::mem::replace(&mut *state, PreconnectState::Idle);
match previous {
PreconnectState::InFlight(task) => Some(task),
other => {
*state = other;
None
}
}
};
if let Some(task) = task {
let in_flight = !task.is_finished();
if in_flight {
debug!("awaiting startup websocket preconnect before opening a new websocket");
}
if let Err(err) = task.await {
debug!("startup websocket preconnect task failed: {err}");
}
}
}
/// Clears all startup preconnect state.
///
/// This aborts any in-flight startup preconnect task and drops any warmed socket.
fn clear_preconnect(&self) {
let previous = {
let mut state = self
.state
.preconnect
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::replace(&mut *state, PreconnectState::Idle)
};
if let PreconnectState::InFlight(task) = previous {
task.abort();
if let Some(running_task) = state.take() {
running_task.abort();
}
*state = task;
}
}
@@ -921,15 +788,24 @@ impl ModelClientSession {
options: &ApiResponsesOptions,
) -> std::result::Result<&ApiWebSocketConnection, ApiError> {
// Prefer the session-level preconnect slot before creating a new websocket.
if self.connection.is_none() {
if let Some(preconnected) = self.try_use_preconnected_websocket() {
self.adopt_preconnected_websocket(preconnected);
} else {
self.client.await_preconnect_task().await;
if let Some(preconnected) = self.try_use_preconnected_websocket() {
self.adopt_preconnected_websocket(preconnected);
if self.connection.is_none()
&& let Some(task) = self.client.take_preconnected_task()
{
match task.await {
Ok(Some(preconnected)) => {
let PreconnectedWebSocket {
connection,
turn_state,
} = preconnected;
if let Some(turn_state) = turn_state {
let _ = self.turn_state.set(turn_state);
}
self.connection = Some(connection);
}
}
_ => {
warn!("startup websocket preconnect task failed");
}
};
}
let needs_new = match self.connection.as_ref() {
@@ -938,7 +814,6 @@ impl ModelClientSession {
};
if needs_new {
self.client.clear_preconnect();
self.websocket_last_items.clear();
self.websocket_last_response_id = None;
self.websocket_last_response_id_rx = None;
@@ -964,33 +839,6 @@ impl ModelClientSession {
))
}
/// Adopts the session-level preconnect slot for this turn.
///
/// If a turn-local connection already exists, this intentionally does nothing to avoid
/// replacing an active connection mid-turn.
fn try_use_preconnected_websocket(&mut self) -> Option<PreconnectedWebSocket> {
if self.connection.is_some() {
return None;
}
self.client.take_preconnected_websocket()
}
/// Moves a preconnected socket into the turn-local connection slot.
///
/// If the preconnect handshake captured sticky-routing turn state, this also seeds the
/// turn-local state lock so all later requests in the turn replay the same token.
fn adopt_preconnected_websocket(&mut self, preconnected: PreconnectedWebSocket) {
let PreconnectedWebSocket {
connection,
turn_state,
} = preconnected;
if let Some(turn_state) = turn_state {
let _ = self.turn_state.set(turn_state);
}
self.connection = Some(connection);
}
fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression {
if self.client.state.enable_request_compression
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
@@ -1083,7 +931,7 @@ impl ModelClientSession {
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
turn_metadata_header: Option<&str>,
) -> Result<ResponseStream> {
) -> Result<WebsocketStreamOutcome> {
let auth_manager = self.client.state.auth_manager.clone();
let api_prompt = Self::build_responses_request(prompt)?;
@@ -1114,6 +962,11 @@ impl ModelClientSession {
.await
{
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 => {
@@ -1149,7 +1002,10 @@ impl ModelClientSession {
}
});
return Ok(map_response_stream(stream_result, otel_manager.clone()));
return Ok(WebsocketStreamOutcome::Stream(map_response_stream(
stream_result,
otel_manager.clone(),
)));
}
}
@@ -1193,26 +1049,33 @@ impl ModelClientSession {
self.client.responses_websocket_enabled() && !self.client.disable_websockets();
if websocket_enabled {
self.stream_responses_websocket(
prompt,
model_info,
otel_manager,
effort,
summary,
turn_metadata_header,
)
.await
} else {
self.stream_responses_api(
prompt,
model_info,
otel_manager,
effort,
summary,
turn_metadata_header,
)
.await
match self
.stream_responses_websocket(
prompt,
model_info,
otel_manager,
effort,
summary,
turn_metadata_header,
)
.await?
{
WebsocketStreamOutcome::Stream(stream) => return Ok(stream),
WebsocketStreamOutcome::FallbackToHttp => {
self.try_switch_fallback_transport(otel_manager);
}
}
}
self.stream_responses_api(
prompt,
model_info,
otel_manager,
effort,
summary,
turn_metadata_header,
)
.await
}
}
}
@@ -1223,10 +1086,6 @@ impl ModelClientSession {
/// the HTTP transport. It also clears any warmed websocket preconnect state so future turns
/// cannot accidentally adopt a stale socket after fallback has been activated.
///
/// Startup preconnect handshakes are intentionally not counted against `stream_max_retries`.
/// See [`crate::client`] module docs ("Retry-Budget Tradeoff") for rationale and future
/// alternatives.
///
/// Returns `true` if this call activated fallback, or `false` if fallback was already active.
pub(crate) fn try_switch_fallback_transport(&mut self, otel_manager: &OtelManager) -> bool {
let websocket_enabled = self.client.responses_websocket_enabled();
@@ -1239,9 +1098,9 @@ impl ModelClientSession {
&[("from_wire_api", "responses_websocket")],
);
self.client.set_preconnected_task(None);
self.connection = None;
self.websocket_last_items.clear();
self.client.clear_preconnect();
}
activated
}
+29 -6
View File
@@ -314,7 +314,7 @@ impl Codex {
// Resolve base instructions for the session. Priority order:
// 1. config.base_instructions override
// 2. conversation history => session_meta.base_instructions
// 3. base_intructions for current model
// 3. base_instructions for current model
let model_info = models_manager.get_model_info(model.as_str(), &config).await;
let base_instructions = config
.base_instructions
@@ -559,7 +559,7 @@ impl TurnContext {
async fn build_turn_metadata_header(&self) -> Option<String> {
self.turn_metadata_header
.get_or_init(|| async { build_turn_metadata_header(self.cwd.as_path()).await })
.get_or_init(|| async { build_turn_metadata_header(self.cwd.clone()).await })
.await
.clone()
}
@@ -1097,10 +1097,14 @@ impl Session {
// Warm a websocket in the background so the first turn can reuse it.
// This performs only connection setup; user input is still sent later via response.create
// when submit_turn() runs.
sess.services.model_client.pre_establish_connection(
sess.services.otel_manager.clone(),
session_configuration.cwd.clone(),
);
let turn_metadata_header = resolve_turn_metadata_header_with_timeout(
build_turn_metadata_header(session_configuration.cwd.clone()),
None,
)
.boxed();
sess.services
.model_client
.pre_establish_connection(sess.services.otel_manager.clone(), turn_metadata_header);
// Dispatch the SessionConfiguredEvent first and then report any errors.
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
@@ -1207,6 +1211,18 @@ impl Session {
}
}
pub(crate) async fn ensure_rollout_materialized(&self) {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
};
if let Some(rec) = recorder
&& let Err(e) = rec.persist().await
{
warn!("failed to materialize rollout recorder: {e}");
}
}
fn next_internal_sub_id(&self) -> String {
let id = self
.next_internal_sub_id
@@ -1322,6 +1338,10 @@ impl Session {
let mut state = self.state.lock().await;
state.initial_context_seeded = true;
}
// Forked threads should remain file-backed immediately after startup.
self.ensure_rollout_materialized().await;
// Flush after seeding history and any persisted rollout copy.
self.flush_rollout().await;
}
@@ -2343,6 +2363,7 @@ impl Session {
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
self.emit_turn_item_started(turn_context, &turn_item).await;
self.emit_turn_item_completed(turn_context, turn_item).await;
self.ensure_rollout_materialized().await;
}
pub(crate) async fn notify_background_event(
@@ -6637,6 +6658,7 @@ mod tests {
cwd: turn_context.cwd.clone(),
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: Some("test".to_string()),
@@ -6649,6 +6671,7 @@ mod tests {
cwd: params.cwd.clone(),
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
arg0: None,
+9
View File
@@ -47,6 +47,7 @@ use crate::protocol::SandboxPolicy;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModeKind;
@@ -152,6 +153,9 @@ pub struct Config {
/// using backend-specific headers or URLs to enforce this.
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxy>,
/// True if the user passed in an override or set a value in config.toml
/// for either of approval_policy or sandbox_mode.
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
@@ -1690,6 +1694,7 @@ impl Config {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
enforce_residency: enforce_residency.value,
network: None,
did_user_set_custom_approval_policy_or_sandbox_mode,
forced_auto_mode_downgraded_on_windows,
shell_environment_policy,
@@ -4003,6 +4008,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
enforce_residency: Constrained::allow_any(None),
network: None,
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -4091,6 +4097,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
enforce_residency: Constrained::allow_any(None),
network: None,
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -4194,6 +4201,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
enforce_residency: Constrained::allow_any(None),
network: None,
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
@@ -4283,6 +4291,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
enforce_residency: Constrained::allow_any(None),
network: None,
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
shell_environment_policy: ShellEnvironmentPolicy::default(),
+20 -3
View File
@@ -32,6 +32,7 @@ use crate::sandboxing::SandboxPermissions;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use codex_network_proxy::NetworkProxy;
use codex_utils_pty::process_group::kill_child_process_group;
pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000;
@@ -63,6 +64,7 @@ pub struct ExecParams {
pub cwd: PathBuf,
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub justification: Option<String>,
@@ -170,12 +172,16 @@ pub async fn process_exec_tool_call(
command,
cwd,
expiration,
env,
mut env,
network,
sandbox_permissions,
windows_sandbox_level,
justification,
arg0: _,
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -233,6 +239,7 @@ pub(crate) async fn execute_exec_env(
cwd,
expiration,
env,
network: None,
sandbox_permissions,
windows_sandbox_level,
justification,
@@ -324,11 +331,15 @@ async fn exec_windows_sandbox(
let ExecParams {
command,
cwd,
env,
mut env,
network,
expiration,
windows_sandbox_level,
..
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
}
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
// variants of ExecExpiration, not just timeout.
let timeout_ms = expiration.timeout_ms();
@@ -677,12 +688,16 @@ async fn exec(
let ExecParams {
command,
cwd,
env,
mut env,
network,
arg0,
expiration,
windows_sandbox_level: _,
..
} = params;
if let Some(network) = network.as_ref() {
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -1061,6 +1076,7 @@ mod tests {
cwd: std::env::current_dir()?,
expiration: 500.into(),
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -1107,6 +1123,7 @@ mod tests {
cwd: cwd.clone(),
expiration: ExecExpiration::Cancellation(cancel_token),
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -430,8 +430,20 @@ mod tests {
}
}
fn can_bind_loopback_port() -> bool {
match std::net::TcpListener::bind(("127.0.0.1", 0)) {
Ok(_listener) => true,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => false,
Err(err) => panic!("unexpected error probing loopback listen permissions: {err}"),
}
}
#[tokio::test]
async fn refresh_available_models_sorts_by_priority() {
if !can_bind_loopback_port() {
return;
}
let server = MockServer::start().await;
let remote_models = vec![
remote_model("priority-low", "Low", 1),
@@ -489,6 +501,10 @@ mod tests {
#[tokio::test]
async fn refresh_available_models_uses_cache_when_fresh() {
if !can_bind_loopback_port() {
return;
}
let server = MockServer::start().await;
let remote_models = vec![remote_model("cached", "Cached", 5)];
let models_mock = mount_models_once(
@@ -536,6 +552,10 @@ mod tests {
#[tokio::test]
async fn refresh_available_models_refetches_when_cache_stale() {
if !can_bind_loopback_port() {
return;
}
let server = MockServer::start().await;
let initial_models = vec![remote_model("stale", "Stale", 1)];
let initial_mock = mount_models_once(
@@ -605,6 +625,10 @@ mod tests {
#[tokio::test]
async fn refresh_available_models_refetches_when_version_mismatch() {
if !can_bind_loopback_port() {
return;
}
let server = MockServer::start().await;
let initial_models = vec![remote_model("old", "Old", 1)];
let initial_mock = mount_models_once(
@@ -674,6 +698,10 @@ mod tests {
#[tokio::test]
async fn refresh_available_models_drops_removed_remote_models() {
if !can_bind_loopback_port() {
return;
}
let server = MockServer::start().await;
let initial_models = vec![remote_model("remote-old", "Remote Old", 1)];
let initial_mock = mount_models_once(
+312 -81
View File
@@ -47,6 +47,7 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
@@ -81,6 +82,9 @@ pub enum RolloutRecorderParams {
enum RolloutCmd {
AddItems(Vec<RolloutItem>),
Persist {
ack: oneshot::Sender<()>,
},
/// Ensure all prior writes are processed; respond when flushed.
Flush {
ack: oneshot::Sender<()>,
@@ -279,16 +283,19 @@ impl RolloutRecorder {
}
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
/// cannot be created or the rollout file cannot be opened we return the
/// error so the caller can decide whether to disable persistence.
/// Attempt to create a new [`RolloutRecorder`].
///
/// For newly created sessions, this precomputes path/metadata and defers
/// file creation/open until an explicit `persist()` call.
///
/// For resumed sessions, this immediately opens the existing rollout file.
pub async fn new(
config: &Config,
params: RolloutRecorderParams,
state_db_ctx: Option<StateDbHandle>,
state_builder: Option<ThreadMetadataBuilder>,
) -> std::io::Result<Self> {
let (file, rollout_path, meta) = match params {
let (file, deferred_log_file_info, rollout_path, meta) = match params {
RolloutRecorderParams::Create {
conversation_id,
forked_from_id,
@@ -296,47 +303,46 @@ impl RolloutRecorder {
base_instructions,
dynamic_tools,
} => {
let LogFileInfo {
file,
path,
conversation_id: session_id,
timestamp,
} = create_log_file(config, conversation_id)?;
let log_file_info = precompute_log_file_info(config, conversation_id)?;
let path = log_file_info.path.clone();
let session_id = log_file_info.conversation_id;
let started_at = log_file_info.timestamp;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = timestamp
let timestamp = started_at
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
(
tokio::fs::File::from_std(file),
path,
Some(SessionMeta {
id: session_id,
forked_from_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
dynamic_tools: if dynamic_tools.is_empty() {
None
} else {
Some(dynamic_tools)
},
}),
)
let session_meta = SessionMeta {
id: session_id,
forked_from_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
dynamic_tools: if dynamic_tools.is_empty() {
None
} else {
Some(dynamic_tools)
},
};
(None, Some(log_file_info), path, Some(session_meta))
}
RolloutRecorderParams::Resume { path } => (
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
Some(
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
),
None,
path,
None,
),
@@ -355,6 +361,7 @@ impl RolloutRecorder {
// driver instead of blocking the runtime.
tokio::task::spawn(rollout_writer(
file,
deferred_log_file_info,
rx,
meta,
cwd,
@@ -398,6 +405,19 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
}
/// Materialize the rollout file and persist all buffered items.
///
/// This is idempotent; after first materialization, repeated calls are no-ops.
pub async fn persist(&self) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(RolloutCmd::Persist { ack: tx })
.await
.map_err(|e| IoError::other(format!("failed to queue rollout persist: {e}")))?;
rx.await
.map_err(|e| IoError::other(format!("failed waiting for rollout persist: {e}")))
}
/// Flush all queued writes and wait until they are committed by the writer task.
pub async fn flush(&self) -> std::io::Result<()> {
let (tx, rx) = oneshot::channel();
@@ -508,9 +528,6 @@ impl RolloutRecorder {
}
struct LogFileInfo {
/// Opened file handle to the rollout file.
file: File,
/// Full path to the rollout file.
path: PathBuf,
@@ -521,8 +538,11 @@ struct LogFileInfo {
timestamp: OffsetDateTime,
}
fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Result<LogFileInfo> {
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
fn precompute_log_file_info(
config: &Config,
conversation_id: ThreadId,
) -> std::io::Result<LogFileInfo> {
// Resolve ~/.codex/sessions/YYYY/MM/DD path.
let timestamp = OffsetDateTime::now_local()
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
let mut dir = config.codex_home.clone();
@@ -530,7 +550,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul
dir.push(timestamp.year().to_string());
dir.push(format!("{:02}", u8::from(timestamp.month())));
dir.push(format!("{:02}", timestamp.day()));
fs::create_dir_all(&dir)?;
// Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for
// compatibility with filesystems that do not allow colons in filenames.
@@ -543,22 +562,32 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul
let filename = format!("rollout-{date_str}-{conversation_id}.jsonl");
let path = dir.join(filename);
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)?;
Ok(LogFileInfo {
file,
path,
conversation_id,
timestamp,
})
}
fn open_log_file(path: &Path) -> std::io::Result<File> {
let Some(parent) = path.parent() else {
return Err(IoError::other(format!(
"rollout path has no parent: {}",
path.display()
)));
};
fs::create_dir_all(parent)?;
std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)
}
#[allow(clippy::too_many_arguments)]
async fn rollout_writer(
file: tokio::fs::File,
file: Option<tokio::fs::File>,
mut deferred_log_file_info: Option<LogFileInfo>,
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
@@ -567,35 +596,27 @@ async fn rollout_writer(
mut state_builder: Option<ThreadMetadataBuilder>,
default_provider: String,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
let mut writer = file.map(|file| JsonlWriter { file });
let mut buffered_items = Vec::<RolloutItem>::new();
if let Some(builder) = state_builder.as_mut() {
builder.rollout_path = rollout_path.clone();
}
// If we have a meta, collect git info asynchronously and write meta first
if let Some(session_meta) = meta.take() {
let git_info = collect_git_info(&cwd).await;
let session_meta_line = SessionMetaLine {
meta: session_meta,
git: git_info,
};
if state_db_ctx.is_some() {
state_builder =
metadata::builder_from_session_meta(&session_meta_line, rollout_path.as_path());
}
// Write the SessionMeta as the first item in the file, wrapped in a rollout line
let rollout_item = RolloutItem::SessionMeta(session_meta_line);
writer.write_rollout_item(&rollout_item).await?;
state_db::reconcile_rollout(
// Resumed sessions already have a file handle open, so session metadata can
// be written immediately if present.
if writer.is_some()
&& let Some(session_meta) = meta.take()
{
write_session_meta(
writer.as_mut(),
session_meta,
&cwd,
&rollout_path,
state_db_ctx.as_deref(),
rollout_path.as_path(),
&mut state_builder,
default_provider.as_str(),
state_builder.as_ref(),
std::slice::from_ref(&rollout_item),
None,
)
.await;
.await?;
}
// Process rollout commands
@@ -605,29 +626,83 @@ async fn rollout_writer(
let mut persisted_items = Vec::new();
for item in items {
if is_persisted_response_item(&item) {
writer.write_rollout_item(&item).await?;
persisted_items.push(item);
}
}
if persisted_items.is_empty() {
continue;
}
if let Some(builder) = state_builder.as_mut() {
builder.rollout_path = rollout_path.clone();
if writer.is_none() {
buffered_items.extend(persisted_items);
continue;
}
state_db::apply_rollout_items(
state_db_ctx.as_deref(),
rollout_path.as_path(),
default_provider.as_str(),
state_builder.as_ref(),
write_and_reconcile_items(
writer.as_mut(),
persisted_items.as_slice(),
"rollout_writer",
&rollout_path,
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
)
.await;
.await?;
}
RolloutCmd::Persist { ack } => {
if writer.is_none() {
let result = async {
let Some(log_file_info) = deferred_log_file_info.take() else {
return Err(IoError::other(
"deferred rollout recorder missing log file metadata",
));
};
let file = open_log_file(log_file_info.path.as_path())?;
writer = Some(JsonlWriter {
file: tokio::fs::File::from_std(file),
});
if let Some(session_meta) = meta.take() {
write_session_meta(
writer.as_mut(),
session_meta,
&cwd,
&rollout_path,
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
)
.await?;
}
if !buffered_items.is_empty() {
write_and_reconcile_items(
writer.as_mut(),
buffered_items.as_slice(),
&rollout_path,
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
)
.await?;
buffered_items.clear();
}
Ok(())
}
.await;
if let Err(err) = result {
let _ = ack.send(());
return Err(err);
}
}
let _ = ack.send(());
}
RolloutCmd::Flush { ack } => {
// Ensure underlying file is flushed and then ack.
if let Err(e) = writer.file.flush().await {
// Deferred fresh threads may not have an initialized file yet.
if let Some(writer) = writer.as_mut()
&& let Err(e) = writer.file.flush().await
{
let _ = ack.send(());
return Err(e);
}
@@ -642,6 +717,68 @@ async fn rollout_writer(
Ok(())
}
async fn write_session_meta(
mut writer: Option<&mut JsonlWriter>,
session_meta: SessionMeta,
cwd: &Path,
rollout_path: &Path,
state_db_ctx: Option<&StateRuntime>,
state_builder: &mut Option<ThreadMetadataBuilder>,
default_provider: &str,
) -> std::io::Result<()> {
let git_info = collect_git_info(cwd).await;
let session_meta_line = SessionMetaLine {
meta: session_meta,
git: git_info,
};
if state_db_ctx.is_some() {
*state_builder = metadata::builder_from_session_meta(&session_meta_line, rollout_path);
}
let rollout_item = RolloutItem::SessionMeta(session_meta_line);
if let Some(writer) = writer.as_mut() {
writer.write_rollout_item(&rollout_item).await?;
}
state_db::reconcile_rollout(
state_db_ctx,
rollout_path,
default_provider,
state_builder.as_ref(),
std::slice::from_ref(&rollout_item),
None,
)
.await;
Ok(())
}
async fn write_and_reconcile_items(
mut writer: Option<&mut JsonlWriter>,
items: &[RolloutItem],
rollout_path: &Path,
state_db_ctx: Option<&StateRuntime>,
state_builder: &mut Option<ThreadMetadataBuilder>,
default_provider: &str,
) -> std::io::Result<()> {
if let Some(writer) = writer.as_mut() {
for item in items {
writer.write_rollout_item(item).await?;
}
}
if let Some(builder) = state_builder.as_mut() {
builder.rollout_path = rollout_path.to_path_buf();
}
state_db::apply_rollout_items(
state_db_ctx,
rollout_path,
default_provider,
state_builder.as_ref(),
items,
"rollout_writer",
)
.await;
Ok(())
}
struct JsonlWriter {
file: tokio::fs::File,
}
@@ -751,3 +888,97 @@ fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool {
}
session_cwd == cwd
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::UserMessageEvent;
use tempfile::TempDir;
#[tokio::test]
async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.build()
.await?;
let thread_id = ThreadId::new();
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(
thread_id,
None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
),
None,
None,
)
.await?;
let rollout_path = recorder.rollout_path().to_path_buf();
assert!(
!rollout_path.exists(),
"rollout file should not exist before first user message"
);
recorder
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
message: "buffered-event".to_string(),
},
))])
.await?;
recorder.flush().await?;
assert!(
!rollout_path.exists(),
"rollout file should remain deferred before first user message"
);
recorder
.record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
UserMessageEvent {
message: "first-user-message".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
},
))])
.await?;
recorder.flush().await?;
assert!(
!rollout_path.exists(),
"user-message-like items should not materialize without explicit persist"
);
recorder.persist().await?;
// Second call verifies `persist()` is idempotent after materialization.
recorder.persist().await?;
assert!(rollout_path.exists(), "rollout file should be materialized");
let text = std::fs::read_to_string(&rollout_path)?;
assert!(
text.contains("\"type\":\"session_meta\""),
"expected session metadata in rollout"
);
let buffered_idx = text
.find("buffered-event")
.expect("buffered event in rollout");
let user_idx = text
.find("first-user-message")
.expect("first user message in rollout");
assert!(
buffered_idx < user_idx,
"buffered items should preserve ordering"
);
let text_after_second_persist = std::fs::read_to_string(&rollout_path)?;
assert_eq!(text_after_second_persist, text);
recorder.shutdown().await?;
Ok(())
}
}
+6
View File
@@ -240,6 +240,7 @@ pub(crate) async fn exit_review_mode(
}],
)
.await;
session
.send_event(
ctx.as_ref(),
@@ -260,4 +261,9 @@ pub(crate) async fn exit_review_mode(
},
)
.await;
// Review turns can run before any regular user turn, so explicitly
// materialize rollout persistence. Do this after emitting review output so
// file creation + git metadata collection cannot delay client-facing items.
session.ensure_rollout_materialized().await;
}
+21 -1
View File
@@ -797,6 +797,7 @@ fn build_agent_shared_config(
#[cfg(test)]
mod tests {
use super::*;
use crate::AuthManager;
use crate::CodexAuth;
use crate::ThreadManager;
use crate::agent::MAX_THREAD_SPAWN_DEPTH;
@@ -811,6 +812,10 @@ mod tests {
use crate::protocol::SubAgentSource;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde_json::json;
@@ -1142,7 +1147,22 @@ mod tests {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager.start_thread(config).await.expect("start thread");
let thread = manager
.resume_thread_with_history(
config,
InitialHistory::Forked(vec![RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "materialized".to_string(),
}],
end_turn: None,
phase: None,
})]),
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
)
.await
.expect("start thread");
let agent_id = thread.thread_id;
let _ = manager
.agent_control()
@@ -53,6 +53,7 @@ impl ShellHandler {
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.config.network.clone(),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -81,6 +82,7 @@ impl ShellCommandHandler {
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.config.network.clone(),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -311,6 +313,7 @@ impl ShellHandler {
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),
env: exec_params.env.clone(),
network: exec_params.network.clone(),
sandbox_permissions: exec_params.sandbox_permissions,
justification: exec_params.justification.clone(),
exec_approval_requirement,
@@ -441,6 +444,7 @@ mod tests {
assert_eq!(exec_params.command, expected_command);
assert_eq!(exec_params.cwd, expected_cwd);
assert_eq!(exec_params.env, expected_env);
assert_eq!(exec_params.network, turn_context.config.network);
assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms);
assert_eq!(exec_params.sandbox_permissions, sandbox_permissions);
assert_eq!(exec_params.justification, justification);
+7 -1
View File
@@ -23,6 +23,7 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_network_proxy::NetworkProxy;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::path::PathBuf;
@@ -33,6 +34,7 @@ pub struct ShellRequest {
pub cwd: PathBuf,
pub timeout_ms: Option<u64>,
pub env: std::collections::HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
@@ -155,10 +157,14 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
command
};
let mut env = req.env.clone();
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
let spec = build_command_spec(
&command,
&req.cwd,
&req.env,
&env,
req.timeout_ms.into(),
req.sandbox_permissions,
req.justification.clone(),
@@ -27,6 +27,7 @@ use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_network_proxy::NetworkProxy;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::collections::HashMap;
@@ -37,6 +38,7 @@ pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
@@ -60,6 +62,7 @@ impl UnifiedExecRequest {
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
network: Option<NetworkProxy>,
tty: bool,
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
@@ -69,6 +72,7 @@ impl UnifiedExecRequest {
command,
cwd,
env,
network,
tty,
sandbox_permissions,
justification,
@@ -181,10 +185,14 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
command
};
let mut env = req.env.clone();
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
let spec = build_command_spec(
&command,
&req.cwd,
&req.env,
&env,
ExecExpiration::DefaultTimeout,
req.sandbox_permissions,
req.justification.clone(),
+3 -3
View File
@@ -6,7 +6,7 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use serde::Serialize;
@@ -16,7 +16,6 @@ use crate::git_info::get_git_remote_urls_assume_git_repo;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_head_commit_hash;
/// Timeout used when resolving the optional turn-metadata header.
pub(crate) const TURN_METADATA_HEADER_TIMEOUT: Duration = Duration::from_millis(250);
/// Resolves turn metadata with a shared timeout policy.
@@ -57,7 +56,8 @@ struct TurnMetadata {
workspaces: BTreeMap<String, TurnMetadataWorkspace>,
}
pub async fn build_turn_metadata_header(cwd: &Path) -> Option<String> {
pub async fn build_turn_metadata_header(cwd: PathBuf) -> Option<String> {
let cwd = cwd.as_path();
let repo_root = get_git_repo_root(cwd)?;
let (latest_git_commit_hash, associated_remote_urls) = tokio::join!(
@@ -505,6 +505,7 @@ impl UnifiedExecProcessManager {
request.command.clone(),
cwd,
env,
context.turn.config.network.clone(),
request.tty,
request.sandbox_permissions,
request.justification.clone(),
+20 -1
View File
@@ -11,9 +11,13 @@ use futures::StreamExt;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::accept_hdr_async_with_config;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::extensions::ExtensionsConfig;
use tokio_tungstenite::tungstenite::extensions::compression::deflate::DeflateConfig;
use tokio_tungstenite::tungstenite::handshake::server::Request;
use tokio_tungstenite::tungstenite::handshake::server::Response;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use wiremock::BodyPrintLimit;
use wiremock::Match;
use wiremock::Mock;
@@ -964,7 +968,13 @@ pub async fn start_websocket_server_with_headers(
Ok(response)
};
let mut ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
let mut ws_stream = match accept_hdr_async_with_config(
stream,
callback,
Some(websocket_accept_config()),
)
.await
{
Ok(ws) => ws,
Err(_) => continue,
};
@@ -1020,6 +1030,15 @@ fn parse_ws_request_body(message: Message) -> Option<Value> {
}
}
fn websocket_accept_config() -> WebSocketConfig {
let mut extensions = ExtensionsConfig::default();
extensions.permessage_deflate = Some(DeflateConfig::default());
let mut config = WebSocketConfig::default();
config.extensions = extensions;
config
}
#[derive(Clone)]
pub struct FunctionCallResponseMocks {
pub function_call: ResponseMock,
@@ -30,6 +30,7 @@ use core_test_support::responses::ev_response_created;
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 futures::FutureExt;
use futures::StreamExt;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
use pretty_assertions::assert_eq;
@@ -97,7 +98,9 @@ async fn responses_websocket_preconnect_reuses_connection() {
.await;
let harness = websocket_harness(&server).await;
assert!(harness.client.preconnect(&harness.otel_manager, None).await);
harness
.client
.pre_establish_connection(harness.otel_manager.clone(), async { None }.boxed());
let mut client_session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
@@ -120,7 +123,9 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() {
.await;
let harness = websocket_harness(&server).await;
assert!(harness.client.preconnect(&harness.otel_manager, None).await);
harness
.client
.pre_establish_connection(harness.otel_manager.clone(), async { None }.boxed());
let mut client_session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
+1
View File
@@ -36,6 +36,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
cwd: tmp.path().to_path_buf(),
expiration: 1000.into(),
env: HashMap::new(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
+17
View File
@@ -2,7 +2,12 @@
use anyhow::Result;
use codex_core::features::Feature;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_function_call_agent_response;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
@@ -26,6 +31,18 @@ async fn get_memory_tool_returns_persisted_thread_memory() -> Result<()> {
let thread_id = test.session_configured.session_id;
let thread_id_string = thread_id.to_string();
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-init"),
ev_assistant_message("msg-init", "Materialized"),
ev_completed("resp-init"),
]),
)
.await;
test.submit_turn("materialize thread before memory write")
.await?;
let mut thread_exists = false;
// Wait for DB creation.
for _ in 0..100 {
@@ -176,6 +176,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()>
None,
)
.await?;
recorder.persist().await?;
recorder.flush().await?;
let index_path = home.path().join("session_index.jsonl");
+16
View File
@@ -43,6 +43,18 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> {
}
let db = test.codex.state_db().expect("state db enabled");
assert!(
!rollout_path.exists(),
"fresh thread rollout should not be materialized before first user message"
);
let initial_metadata = db.get_thread(thread_id).await?;
assert!(
initial_metadata.is_none(),
"fresh thread should not be recorded in state db before first user message"
);
test.submit_turn("materialize rollout").await?;
let mut metadata = None;
for _ in 0..100 {
@@ -56,6 +68,10 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> {
let metadata = metadata.expect("thread should exist in state db");
assert_eq!(metadata.id, thread_id);
assert_eq!(metadata.rollout_path, rollout_path);
assert!(
rollout_path.exists(),
"rollout should be materialized after first user message"
);
Ok(())
}
+17 -5
View File
@@ -23,6 +23,7 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_event_with_timeout;
use regex_lite::escape;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -99,11 +100,11 @@ async fn user_shell_cmd_can_be_interrupted() {
// Set up isolated config and conversation.
let server = start_mock_server().await;
let mut builder = test_codex();
let codex = builder
let fixture = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
.expect("create new conversation");
let codex = &fixture.codex;
// Start a long-running command and then interrupt it.
let sleep_cmd = "sleep 5".to_string();
@@ -113,11 +114,22 @@ async fn user_shell_cmd_can_be_interrupted() {
.unwrap();
// Wait until it has started (ExecCommandBegin), then interrupt.
let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await;
let _begin = wait_for_event_match(codex, |ev| match ev {
EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::UserShell => {
Some(event.clone())
}
_ => None,
})
.await;
codex.submit(Op::Interrupt).await.unwrap();
// Expect a TurnAborted(Interrupted) notification.
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
let msg = wait_for_event_with_timeout(
codex,
|ev| matches!(ev, EventMsg::TurnAborted(_)),
Duration::from_secs(60),
)
.await;
let EventMsg::TurnAborted(ev) = msg else {
unreachable!()
};
@@ -9,13 +9,23 @@ use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::http::Method;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result<()> {
async fn websocket_fallback_switches_to_http_on_upgrade_required_connect() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
Mock::given(method("GET"))
.and(path_regex(".*/responses$"))
.respond_with(ResponseTemplate::new(426))
.mount(&server)
.await;
let response_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
@@ -28,7 +38,9 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result
config.model_provider.base_url = Some(base_url);
config.model_provider.wire_api = codex_core::WireApi::Responses;
config.features.enable(Feature::ResponsesWebsockets);
config.model_provider.stream_max_retries = Some(0);
// If we don't treat 426 specially, the sampling loop would retry the WebSocket
// handshake before switching to the HTTP transport.
config.model_provider.stream_max_retries = Some(2);
config.model_provider.request_max_retries = Some(0);
}
});
@@ -56,6 +68,51 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut builder = test_codex().with_config({
let base_url = format!("{}/v1", server.uri());
move |config| {
config.model_provider.base_url = Some(base_url);
config.model_provider.wire_api = codex_core::WireApi::Responses;
config.features.enable(Feature::ResponsesWebsockets);
config.model_provider.stream_max_retries = Some(2);
config.model_provider.request_max_retries = Some(0);
}
});
let test = builder.build(&server).await?;
test.submit_turn("hello").await?;
let requests = server.received_requests().await.unwrap_or_default();
let websocket_attempts = requests
.iter()
.filter(|req| req.method == Method::GET && req.url.path().ends_with("/responses"))
.count();
let http_attempts = requests
.iter()
.filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses"))
.count();
// One websocket attempt comes from startup preconnect.
// The first turn then makes 3 websocket stream attempts (initial try + 2 retries),
// after which fallback activates and the request is replayed over HTTP.
assert_eq!(websocket_attempts, 4);
assert_eq!(http_attempts, 1);
assert_eq!(response_mock.requests().len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_fallback_is_sticky_across_turns() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -76,7 +133,7 @@ async fn websocket_fallback_is_sticky_across_turns() -> Result<()> {
config.model_provider.base_url = Some(base_url);
config.model_provider.wire_api = codex_core::WireApi::Responses;
config.features.enable(Feature::ResponsesWebsockets);
config.model_provider.stream_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(2);
config.model_provider.request_max_retries = Some(0);
}
});
@@ -95,10 +152,10 @@ async fn websocket_fallback_is_sticky_across_turns() -> Result<()> {
.filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses"))
.count();
// The first turn issues exactly two websocket attempts (startup preconnect + first stream
// attempt). After fallback becomes sticky, subsequent turns stay on HTTP. This mirrors the
// retry-budget tradeoff documented in [`codex_core::client`] module docs.
assert_eq!(websocket_attempts, 2);
// WebSocket attempts all happen on the first turn:
// 1 startup preconnect + 3 stream attempts (initial try + 2 retries) before fallback.
// Fallback is sticky, so the second turn stays on HTTP and adds no websocket attempts.
assert_eq!(websocket_attempts, 4);
assert_eq!(http_attempts, 2);
assert_eq!(response_mock.requests().len(), 2);
+3
View File
@@ -120,6 +120,9 @@ allow = [
# MPL-2.0 - https://www.mozilla.org/MPL/2.0/
# Used by: nucleo-matcher
"MPL-2.0",
# OpenSSL - https://spdx.org/licenses/OpenSSL.html
# Used by: aws-lc-sys
"OpenSSL",
# Unicode-3.0 - https://opensource.org/license/unicode
# Used by: icu_decimal, icu_locale_core, icu_provider
"Unicode-3.0",
@@ -87,6 +87,7 @@ impl EscalateServer {
cwd: PathBuf::from(&workdir),
expiration: ExecExpiration::Cancellation(cancel_rx),
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -75,6 +75,7 @@ async fn run_cmd_result_with_writable_roots(
cwd,
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
@@ -231,6 +232,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
// do not stall the suite.
expiration: NETWORK_TIMEOUT_MS.into(),
env: create_env_from_core_vars(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
+3 -3
View File
@@ -19,14 +19,14 @@ workspace = true
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
globset = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["full"] }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["fmt"] }
url = { workspace = true }
@@ -36,7 +36,7 @@ rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] }
rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] }
rama-socks5 = { version = "=0.3.0-alpha.4" }
rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-tls-boring = { version = "=0.3.0-alpha.4", features = ["http"] }
rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+2 -2
View File
@@ -147,8 +147,8 @@ curl -sS -X POST http://127.0.0.1:8080/reload
- Unix socket proxying via the `x-unix-socket` header is **macOS-only**; other platforms will
reject unix socket requests.
- HTTPS tunneling uses BoringSSL via Rama's `rama-tls-boring`; building the proxy requires a
native toolchain and CMake on macOS/Linux/Windows.
- HTTPS tunneling uses rustls via Rama's `rama-tls-rustls`; this avoids BoringSSL/OpenSSL symbol
collisions in mixed TLS dependency graphs.
## Security notes (important)
+5 -3
View File
@@ -59,8 +59,8 @@ use rama_net::stream::SocketInfo;
use rama_tcp::client::Request as TcpRequest;
use rama_tcp::client::service::TcpConnector;
use rama_tcp::server::TcpListener;
use rama_tls_boring::client::TlsConnectorDataBuilder;
use rama_tls_boring::client::TlsConnectorLayer;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
use rama_tls_rustls::client::TlsConnectorLayer;
use serde::Serialize;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -301,7 +301,9 @@ async fn forward_connect_tunnel(
let req = TcpRequest::new_with_extensions(authority.clone(), extensions)
.with_protocol(Protocol::HTTPS);
let proxy_connector = HttpProxyConnector::optional(TcpConnector::new());
let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder();
let tls_config = TlsConnectorDataBuilder::new()
.with_alpn_protocols_http_auto()
.build();
let connector = TlsConnectorLayer::tunnel(None)
.with_connector_data(tls_config)
.into_layer(proxy_connector);
+32
View File
@@ -8,6 +8,7 @@ use crate::state::NetworkProxyState;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::task::JoinHandle;
@@ -88,11 +89,42 @@ pub struct NetworkProxy {
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
}
impl std::fmt::Debug for NetworkProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
// and may contain sensitive paths.
f.debug_struct("NetworkProxy")
.field("http_addr", &self.http_addr)
.field("socks_addr", &self.socks_addr)
.field("admin_addr", &self.admin_addr)
.finish_non_exhaustive()
}
}
impl PartialEq for NetworkProxy {
fn eq(&self, other: &Self) -> bool {
self.http_addr == other.http_addr
&& self.socks_addr == other.socks_addr
&& self.admin_addr == other.admin_addr
}
}
impl Eq for NetworkProxy {}
impl NetworkProxy {
pub fn builder() -> NetworkProxyBuilder {
NetworkProxyBuilder::default()
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
// Enforce proxying for all child processes when configured. We always override to ensure
// the proxy is actually used even if the caller passed conflicting environment variables.
let proxy_url = format!("http://{}", self.http_addr);
for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] {
env.insert(key.to_string(), proxy_url.clone());
}
}
pub async fn run(&self) -> Result<NetworkProxyHandle> {
let current_cfg = self.state.current_cfg().await?;
if !current_cfg.network.enabled {
+133 -121
View File
@@ -6,18 +6,17 @@ use crate::runtime::ConfigState;
use crate::runtime::LayerMtime;
use anyhow::Context;
use anyhow::Result;
use codex_app_server_protocol::ConfigLayerSource;
use codex_core::config::CONFIG_TOML_FILE;
use codex_core::config::ConstraintError;
use codex_core::config::find_codex_home;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::RequirementSource;
use codex_core::config_loader::load_config_layers_state;
use codex_utils_home_dir::find_codex_home;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use tokio::fs;
use toml::Value as TomlValue;
const CONFIG_TOML_FILE: &str = "config.toml";
#[cfg(unix)]
const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
@@ -26,35 +25,37 @@ pub use crate::runtime::NetworkProxyState;
pub(crate) use crate::runtime::network_proxy_state_for_policy;
pub(crate) async fn build_config_state() -> Result<ConfigState> {
// Load config through `codex-core` so we inherit the same layer ordering and semantics as the
// rest of Codex (system/managed layers, user layers, session flags, etc.).
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let cli_overrides = Vec::new();
let overrides = LoaderOverrides::default();
let config_layer_stack = load_config_layers_state(
&codex_home,
None,
&cli_overrides,
overrides,
CloudRequirementsLoader::default(),
)
.await
.context("failed to load Codex config")?;
let cfg_path = codex_home.join(CONFIG_TOML_FILE);
// Deserialize from the merged effective config, rather than parsing config.toml ourselves.
// This avoids a second parser/merger implementation (and the drift that comes with it).
let merged_toml = config_layer_stack.effective_config();
let system_cfg_path = system_config_path();
let system_config = read_toml_file_best_effort(&system_cfg_path)
.await
.context("failed to read system config")?;
let user_config = read_toml_file_best_effort(&cfg_path)
.await
.context("failed to read user config")?;
let mut merged_toml = TomlValue::Table(toml::map::Map::new());
if let Some(system_config) = system_config.clone() {
merge_toml_values(&mut merged_toml, system_config);
}
if let Some(user_config) = user_config {
merge_toml_values(&mut merged_toml, user_config);
}
let config: NetworkProxyConfig = merged_toml
.try_into()
.context("failed to deserialize network proxy config")?;
// Security boundary: user-controlled layers must not be able to widen restrictions set by
// trusted/managed layers (e.g., MDM). Enforce this before building runtime state.
let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?;
let constraints = enforce_trusted_constraints(system_config.as_ref(), &config)?;
let layer_mtimes = collect_layer_mtimes(&config_layer_stack);
let layer_mtimes = vec![
LayerMtime::new(system_cfg_path),
LayerMtime::new(cfg_path.clone()),
];
let deny_set = compile_globset(&config.network.denied_domains)?;
let allow_set = compile_globset(&config.network.allowed_domains)?;
Ok(ConfigState {
@@ -68,26 +69,57 @@ pub(crate) async fn build_config_state() -> Result<ConfigState> {
})
}
fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec<LayerMtime> {
stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
.iter()
.filter_map(|layer| {
let path = match &layer.name {
ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()),
ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()),
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder
.join(CONFIG_TOML_FILE)
.ok()
.map(|p| p.as_path().to_path_buf()),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
Some(file.as_path().to_path_buf())
fn system_config_path() -> std::path::PathBuf {
#[cfg(unix)]
{
Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX).to_path_buf()
}
#[cfg(not(unix))]
{
// Use a dummy path on non-Unix platforms. This keeps the reload logic stable without
// needing per-platform config stack logic yet.
std::path::PathBuf::from("__no_system_config.toml")
}
}
async fn read_toml_file_best_effort(path: &Path) -> Result<Option<TomlValue>> {
let contents = match fs::read_to_string(path).await {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::warn!(
path = %path.display(),
error = %err,
"permission denied reading config; ignoring"
);
return Ok(None);
}
Err(err) => return Err(err).context(format!("read {}", path.display())),
};
let parsed: TomlValue = contents
.parse()
.with_context(|| format!("parse {}", path.display()))?;
Ok(Some(parsed))
}
fn merge_toml_values(base: &mut TomlValue, overlay: TomlValue) {
match (base, overlay) {
(TomlValue::Table(base_table), TomlValue::Table(overlay_table)) => {
for (key, value) in overlay_table {
match base_table.get_mut(&key) {
Some(existing) => merge_toml_values(existing, value),
None => {
base_table.insert(key, value);
}
}
_ => None,
};
path.map(LayerMtime::new)
})
.collect()
}
}
(base_slot, overlay_value) => {
*base_slot = overlay_value;
}
}
}
#[derive(Debug, Default, Deserialize)]
@@ -127,103 +159,83 @@ pub(crate) struct NetworkProxyConstraints {
}
fn enforce_trusted_constraints(
layers: &codex_core::config_loader::ConfigLayerStack,
system_config: Option<&TomlValue>,
config: &NetworkProxyConfig,
) -> Result<NetworkProxyConstraints> {
let constraints = network_constraints_from_trusted_layers(layers)?;
let constraints = network_constraints_from_system_config(system_config)?;
validate_policy_against_constraints(config, &constraints)
.context("network proxy constraints")?;
Ok(constraints)
}
fn network_constraints_from_trusted_layers(
layers: &codex_core::config_loader::ConfigLayerStack,
fn network_constraints_from_system_config(
system_config: Option<&TomlValue>,
) -> Result<NetworkProxyConstraints> {
let mut constraints = NetworkProxyConstraints::default();
for layer in layers.get_layers(
codex_core::config_loader::ConfigLayerStackOrdering::LowestPrecedenceFirst,
false,
) {
// Only trusted layers contribute constraints. User-controlled layers can narrow policy but
// must never widen beyond what managed config allows.
if is_user_controlled_layer(&layer.name) {
continue;
}
let Some(system_config) = system_config else {
return Ok(constraints);
};
let partial: PartialConfig = layer
.config
.clone()
.try_into()
.context("failed to deserialize trusted config layer")?;
let partial: PartialConfig = system_config
.clone()
.try_into()
.context("failed to deserialize system config constraints")?;
if let Some(enabled) = partial.network.enabled {
constraints.enabled = Some(enabled);
}
if let Some(mode) = partial.network.mode {
constraints.mode = Some(mode);
}
if let Some(allow_upstream_proxy) = partial.network.allow_upstream_proxy {
constraints.allow_upstream_proxy = Some(allow_upstream_proxy);
}
if let Some(dangerously_allow_non_loopback_proxy) =
partial.network.dangerously_allow_non_loopback_proxy
{
constraints.dangerously_allow_non_loopback_proxy =
Some(dangerously_allow_non_loopback_proxy);
}
if let Some(dangerously_allow_non_loopback_admin) =
partial.network.dangerously_allow_non_loopback_admin
{
constraints.dangerously_allow_non_loopback_admin =
Some(dangerously_allow_non_loopback_admin);
}
if let Some(allowed_domains) = partial.network.allowed_domains {
constraints.allowed_domains = Some(allowed_domains);
}
if let Some(denied_domains) = partial.network.denied_domains {
constraints.denied_domains = Some(denied_domains);
}
if let Some(allow_unix_sockets) = partial.network.allow_unix_sockets {
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = partial.network.allow_local_binding {
constraints.allow_local_binding = Some(allow_local_binding);
}
if let Some(enabled) = partial.network.enabled {
constraints.enabled = Some(enabled);
}
if let Some(mode) = partial.network.mode {
constraints.mode = Some(mode);
}
if let Some(allow_upstream_proxy) = partial.network.allow_upstream_proxy {
constraints.allow_upstream_proxy = Some(allow_upstream_proxy);
}
if let Some(dangerously_allow_non_loopback_proxy) =
partial.network.dangerously_allow_non_loopback_proxy
{
constraints.dangerously_allow_non_loopback_proxy =
Some(dangerously_allow_non_loopback_proxy);
}
if let Some(dangerously_allow_non_loopback_admin) =
partial.network.dangerously_allow_non_loopback_admin
{
constraints.dangerously_allow_non_loopback_admin =
Some(dangerously_allow_non_loopback_admin);
}
Ok(constraints)
}
fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
matches!(
layer,
ConfigLayerSource::User { .. }
| ConfigLayerSource::Project { .. }
| ConfigLayerSource::SessionFlags
)
if let Some(allowed_domains) = partial.network.allowed_domains {
constraints.allowed_domains = Some(allowed_domains);
}
if let Some(denied_domains) = partial.network.denied_domains {
constraints.denied_domains = Some(denied_domains);
}
if let Some(allow_unix_sockets) = partial.network.allow_unix_sockets {
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = partial.network.allow_local_binding {
constraints.allow_local_binding = Some(allow_local_binding);
}
Ok(constraints)
}
pub(crate) fn validate_policy_against_constraints(
config: &NetworkProxyConfig,
constraints: &NetworkProxyConstraints,
) -> std::result::Result<(), ConstraintError> {
) -> Result<()> {
fn invalid_value(
field_name: &'static str,
candidate: impl Into<String>,
allowed: impl Into<String>,
) -> ConstraintError {
ConstraintError::InvalidValue {
field_name,
candidate: candidate.into(),
allowed: allowed.into(),
requirement_source: RequirementSource::Unknown,
}
) -> anyhow::Error {
anyhow::anyhow!(
"invalid value for {field_name}: candidate={} allowed={}",
candidate.into(),
allowed.into()
)
}
fn validate<T>(
candidate: T,
validator: impl FnOnce(&T) -> std::result::Result<(), ConstraintError>,
) -> std::result::Result<(), ConstraintError> {
fn validate<T>(candidate: T, validator: impl FnOnce(&T) -> Result<()>) -> Result<()> {
validator(&candidate)
}
+5 -3
View File
@@ -17,8 +17,8 @@ use rama_net::address::ProxyAddress;
use rama_net::client::EstablishedClientConnection;
use rama_net::http::RequestContext;
use rama_tcp::client::service::TcpConnector;
use rama_tls_boring::client::TlsConnectorDataBuilder;
use rama_tls_boring::client::TlsConnectorLayer;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
use rama_tls_rustls::client::TlsConnectorLayer;
use tracing::warn;
#[cfg(target_os = "macos")]
@@ -165,7 +165,9 @@ fn build_http_connector() -> BoxService<
> {
let transport = TcpConnector::default();
let proxy = HttpProxyConnectorLayer::optional().into_layer(transport);
let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder();
let tls_config = TlsConnectorDataBuilder::new()
.with_alpn_protocols_http_auto()
.build();
let tls = TlsConnectorLayer::auto()
.with_connector_data(tls_config)
.into_layer(proxy);
+46 -35
View File
@@ -1445,46 +1445,57 @@ impl App {
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(path) = self.chat_widget.rollout_path() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
// Fresh threads expose a precomputed path, but the file is
// materialized lazily on first user message.
if path.exists() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
self.chat_widget.add_plain_history_lines(lines);
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
} else {
self.chat_widget
.add_error_message("Current session is not ready to fork yet.".to_string());
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
tui.frame_requester().schedule_frame();
+161 -13
View File
@@ -540,12 +540,7 @@ impl ChatComposer {
};
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
self.apply_history_entry(entry);
true
}
@@ -837,6 +832,7 @@ impl ChatComposer {
.iter()
.map(|img| img.path.clone())
.collect();
let pending_pastes = std::mem::take(&mut self.pending_pastes);
let mention_bindings = self.snapshot_mention_bindings();
self.set_text_content(String::new(), Vec::new(), Vec::new());
self.history.reset_navigation();
@@ -845,6 +841,7 @@ impl ChatComposer {
text_elements,
local_image_paths,
mention_bindings,
pending_pastes,
});
Some(previous)
}
@@ -854,6 +851,23 @@ impl ChatComposer {
self.textarea.text().to_string()
}
fn apply_history_entry(&mut self, entry: HistoryEntry) {
let HistoryEntry {
text,
text_elements,
local_image_paths,
mention_bindings,
pending_pastes,
} = entry;
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_bindings,
);
self.set_pending_pastes(pending_pastes);
}
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
self.textarea.text_elements()
}
@@ -2065,6 +2079,7 @@ impl ChatComposer {
text_elements: text_elements.clone(),
local_image_paths,
mention_bindings: original_mention_bindings,
pending_pastes: Vec::new(),
});
}
self.pending_pastes.clear();
@@ -2352,12 +2367,7 @@ impl ChatComposer {
_ => unreachable!(),
};
if let Some(entry) = replace_entry {
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
self.apply_history_entry(entry);
return (InputResult::None, true);
}
}
@@ -4057,10 +4067,120 @@ mod tests {
assert_eq!(
composer.history.navigate_up(&composer.app_event_tx),
Some(HistoryEntry::from_text("draft text".to_string()))
Some(HistoryEntry::new("draft text".to_string()))
);
}
#[test]
fn clear_for_ctrl_c_preserves_pending_paste_history_entry() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
composer.handle_paste(large.clone());
let char_count = large.chars().count();
let placeholder = format!("[Pasted Content {char_count} chars]");
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(
composer.pending_pastes,
vec![(placeholder.clone(), large.clone())]
);
composer.clear_for_ctrl_c();
assert!(composer.is_empty());
let history_entry = composer
.history
.navigate_up(&composer.app_event_tx)
.expect("expected history entry");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.clone()),
)];
assert_eq!(
history_entry,
HistoryEntry::with_pending(
placeholder.clone(),
text_elements,
Vec::new(),
vec![(placeholder.clone(), large.clone())]
)
);
composer.apply_history_entry(history_entry);
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.pending_pastes, vec![(placeholder.clone(), large)]);
assert_eq!(composer.textarea.element_payloads(), vec![placeholder]);
composer.set_steer_enabled(true);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5));
assert!(text_elements.is_empty());
}
_ => panic!("expected Submitted"),
}
}
#[test]
fn clear_for_ctrl_c_preserves_image_draft_state() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let path = PathBuf::from("example.png");
composer.attach_image(path.clone());
let placeholder = local_image_label_text(1);
composer.clear_for_ctrl_c();
assert!(composer.is_empty());
let history_entry = composer
.history
.navigate_up(&composer.app_event_tx)
.expect("expected history entry");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.clone()),
)];
assert_eq!(
history_entry,
HistoryEntry::with_pending(
placeholder.clone(),
text_elements,
vec![path.clone()],
Vec::new()
)
);
composer.apply_history_entry(history_entry);
assert_eq!(composer.textarea.text(), placeholder);
assert_eq!(composer.local_image_paths(), vec![path]);
assert_eq!(composer.textarea.element_payloads(), vec![placeholder]);
}
/// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After
/// any typing has occurred, `?` should be inserted as a literal character.
#[test]
@@ -7481,6 +7601,34 @@ mod tests {
assert!(composer.attached_images.is_empty());
}
#[test]
fn apply_external_edit_renumbers_image_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let first_path = PathBuf::from("img1.png");
let second_path = PathBuf::from("img2.png");
composer.attach_image(first_path);
composer.attach_image(second_path.clone());
let placeholder2 = local_image_label_text(2);
composer.apply_external_edit(format!("Keep {placeholder2}"));
let placeholder1 = local_image_label_text(1);
assert_eq!(composer.current_text(), format!("Keep {placeholder1}"));
assert_eq!(composer.attached_images.len(), 1);
assert_eq!(composer.attached_images[0].placeholder, placeholder1);
assert_eq!(composer.local_image_paths(), vec![second_path]);
assert_eq!(composer.textarea.element_payloads(), vec![placeholder1]);
}
#[test]
fn current_text_with_pending_expands_placeholders() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -8,25 +8,23 @@ use crate::mention_codec::decode_history_mentions;
use codex_core::protocol::Op;
use codex_protocol::user_input::TextElement;
/// A composer history entry that can rehydrate draft state.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HistoryEntry {
/// Raw text stored in history (may include placeholder strings).
pub(crate) text: String,
/// Text element ranges for placeholders inside `text`.
pub(crate) text_elements: Vec<TextElement>,
/// Local image paths captured alongside `text_elements`.
pub(crate) local_image_paths: Vec<PathBuf>,
/// Mention bindings for tool/app/skill references inside `text`.
pub(crate) mention_bindings: Vec<MentionBinding>,
/// Placeholder-to-payload pairs used to restore large paste content.
pub(crate) pending_pastes: Vec<(String, String)>,
}
impl HistoryEntry {
fn empty() -> Self {
Self {
text: String::new(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
mention_bindings: Vec::new(),
}
}
pub(crate) fn from_text(text: String) -> Self {
pub(crate) fn new(text: String) -> Self {
let decoded = decode_history_mentions(&text);
Self {
text: decoded.text,
@@ -40,6 +38,23 @@ impl HistoryEntry {
path: mention.path,
})
.collect(),
pending_pastes: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn with_pending(
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
pending_pastes: Vec<(String, String)>,
) -> Self {
Self {
text,
text_elements,
local_image_paths,
mention_bindings: Vec::new(),
pending_pastes,
}
}
}
@@ -55,9 +70,10 @@ pub(crate) struct ChatComposerHistory {
history_entry_count: usize,
/// Messages submitted by the user *during this UI session* (newest at END).
/// Local entries retain full draft state (text elements, image paths, pending pastes).
local_history: Vec<HistoryEntry>,
/// Cache of persistent history entries fetched on-demand.
/// Cache of persistent history entries fetched on-demand (text-only).
fetched_history: HashMap<usize, HistoryEntry>,
/// Current cursor within the combined (persistent + local) history. `None`
@@ -95,10 +111,14 @@ impl ChatComposerHistory {
/// Record a message submitted by the user in the current session so it can
/// be recalled later.
pub fn record_local_submission(&mut self, entry: HistoryEntry) {
if entry.text.is_empty() && entry.local_image_paths.is_empty() {
if entry.text.is_empty()
&& entry.text_elements.is_empty()
&& entry.local_image_paths.is_empty()
&& entry.mention_bindings.is_empty()
&& entry.pending_pastes.is_empty()
{
return;
}
self.history_cursor = None;
self.last_history_text = None;
@@ -177,7 +197,7 @@ impl ChatComposerHistory {
// Past newest – clear and exit browsing mode.
self.history_cursor = None;
self.last_history_text = None;
Some(HistoryEntry::empty())
Some(HistoryEntry::new(String::new()))
}
}
}
@@ -192,8 +212,7 @@ impl ChatComposerHistory {
if self.history_log_id != Some(log_id) {
return None;
}
let text = entry?;
let entry = HistoryEntry::from_text(text);
let entry = HistoryEntry::new(entry?);
self.fetched_history.insert(offset, entry.clone());
if self.history_cursor == Some(offset as isize) {
@@ -241,6 +260,7 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::Op;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
#[test]
@@ -248,27 +268,27 @@ mod tests {
let mut history = ChatComposerHistory::new();
// Empty submissions are ignored.
history.record_local_submission(HistoryEntry::from_text(String::new()));
history.record_local_submission(HistoryEntry::new(String::new()));
assert_eq!(history.local_history.len(), 0);
// First entry is recorded.
history.record_local_submission(HistoryEntry::from_text("hello".to_string()));
history.record_local_submission(HistoryEntry::new("hello".to_string()));
assert_eq!(history.local_history.len(), 1);
assert_eq!(
history.local_history.last().unwrap(),
&HistoryEntry::from_text("hello".to_string())
&HistoryEntry::new("hello".to_string())
);
// Identical consecutive entry is skipped.
history.record_local_submission(HistoryEntry::from_text("hello".to_string()));
history.record_local_submission(HistoryEntry::new("hello".to_string()));
assert_eq!(history.local_history.len(), 1);
// Different entry is recorded.
history.record_local_submission(HistoryEntry::from_text("world".to_string()));
history.record_local_submission(HistoryEntry::new("world".to_string()));
assert_eq!(history.local_history.len(), 2);
assert_eq!(
history.local_history.last().unwrap(),
&HistoryEntry::from_text("world".to_string())
&HistoryEntry::new("world".to_string())
);
}
@@ -300,7 +320,7 @@ mod tests {
// Inject the async response.
assert_eq!(
Some(HistoryEntry::from_text("latest".to_string())),
Some(HistoryEntry::new("latest".to_string())),
history.on_entry_response(1, 2, Some("latest".into()))
);
@@ -321,7 +341,7 @@ mod tests {
);
assert_eq!(
Some(HistoryEntry::from_text("older".to_string())),
Some(HistoryEntry::new("older".to_string())),
history.on_entry_response(1, 1, Some("older".into()))
);
}
@@ -335,17 +355,17 @@ mod tests {
history.set_metadata(1, 3);
history
.fetched_history
.insert(1, HistoryEntry::from_text("command2".to_string()));
.insert(1, HistoryEntry::new("command2".to_string()));
history
.fetched_history
.insert(2, HistoryEntry::from_text("command3".to_string()));
.insert(2, HistoryEntry::new("command3".to_string()));
assert_eq!(
Some(HistoryEntry::from_text("command3".to_string())),
Some(HistoryEntry::new("command3".to_string())),
history.navigate_up(&tx)
);
assert_eq!(
Some(HistoryEntry::from_text("command2".to_string())),
Some(HistoryEntry::new("command2".to_string())),
history.navigate_up(&tx)
);
@@ -354,7 +374,7 @@ mod tests {
assert!(history.last_history_text.is_none());
assert_eq!(
Some(HistoryEntry::from_text("command3".to_string())),
Some(HistoryEntry::new("command3".to_string())),
history.navigate_up(&tx)
);
}
+6
View File
@@ -6845,6 +6845,12 @@ impl ChatWidget {
pub(crate) fn thread_name(&self) -> Option<String> {
self.thread_name.clone()
}
/// Returns the current thread's precomputed rollout path.
///
/// For fresh non-ephemeral threads this path may exist before the file is
/// materialized; rollout persistence is deferred until the first user
/// message is recorded.
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
self.current_rollout_path.clone()
}
+78
View File
@@ -3,6 +3,7 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_core::config::Config;
use codex_core::config_loader::ConfigLayerStack;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::NetworkConstraints;
use codex_core::config_loader::RequirementSource;
use codex_core::config_loader::ResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement;
@@ -115,6 +116,14 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
));
}
if let Some(network) = requirements.network.as_ref() {
requirement_lines.push(requirement_line(
"experimental_network",
format_network_constraints(&network.value),
Some(&network.source),
));
}
if requirement_lines.is_empty() {
lines.push(" <none>".dim().into());
} else {
@@ -199,6 +208,63 @@ fn format_residency_requirement(requirement: ResidencyRequirement) -> String {
}
}
fn format_network_constraints(network: &NetworkConstraints) -> String {
let mut parts = Vec::new();
let NetworkConstraints {
enabled,
http_port,
socks_port,
allow_upstream_proxy,
dangerously_allow_non_loopback_proxy,
dangerously_allow_non_loopback_admin,
allowed_domains,
denied_domains,
allow_unix_sockets,
allow_local_binding,
} = network;
if let Some(enabled) = enabled {
parts.push(format!("enabled={enabled}"));
}
if let Some(http_port) = http_port {
parts.push(format!("http_port={http_port}"));
}
if let Some(socks_port) = socks_port {
parts.push(format!("socks_port={socks_port}"));
}
if let Some(allow_upstream_proxy) = allow_upstream_proxy {
parts.push(format!("allow_upstream_proxy={allow_upstream_proxy}"));
}
if let Some(dangerously_allow_non_loopback_proxy) = dangerously_allow_non_loopback_proxy {
parts.push(format!(
"dangerously_allow_non_loopback_proxy={dangerously_allow_non_loopback_proxy}"
));
}
if let Some(dangerously_allow_non_loopback_admin) = dangerously_allow_non_loopback_admin {
parts.push(format!(
"dangerously_allow_non_loopback_admin={dangerously_allow_non_loopback_admin}"
));
}
if let Some(allowed_domains) = allowed_domains {
parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", ")));
}
if let Some(denied_domains) = denied_domains {
parts.push(format!("denied_domains=[{}]", denied_domains.join(", ")));
}
if let Some(allow_unix_sockets) = allow_unix_sockets {
parts.push(format!(
"allow_unix_sockets=[{}]",
allow_unix_sockets.join(", ")
));
}
if let Some(allow_local_binding) = allow_local_binding {
parts.push(format!("allow_local_binding={allow_local_binding}"));
}
join_or_empty(parts)
}
#[cfg(test)]
mod tests {
use super::render_debug_config_lines;
@@ -211,6 +277,7 @@ mod tests {
use codex_core::config_loader::ConstrainedWithSource;
use codex_core::config_loader::McpServerIdentity;
use codex_core::config_loader::McpServerRequirement;
use codex_core::config_loader::NetworkConstraints;
use codex_core::config_loader::RequirementSource;
use codex_core::config_loader::ResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement;
@@ -323,6 +390,14 @@ mod tests {
Constrained::allow_any(WebSearchMode::Cached),
Some(RequirementSource::CloudRequirements),
);
requirements.network = Some(Sourced::new(
NetworkConstraints {
enabled: Some(true),
allowed_domains: Some(vec!["example.com".to_string()]),
..Default::default()
},
RequirementSource::CloudRequirements,
));
let requirements_toml = ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
@@ -376,6 +451,9 @@ mod tests {
);
assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))"));
assert!(rendered.contains("enforce_residency: us (source: cloud requirements)"));
assert!(rendered.contains(
"experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)"
));
assert!(!rendered.contains(" - rules:"));
}
+45
View File
@@ -123,6 +123,51 @@ positional args, Enter auto-submits without calling `prepare_submission_text`. T
- Prunes attachments based on expanded placeholders.
- Clears pending pastes after a successful auto-submit.
## History navigation (Up/Down) and backtrack prefill
`ChatComposerHistory` merges two kinds of history:
- **Persistent history** (cross-session, fetched from core on demand): text-only.
- **Local history** (this UI session): full draft state.
Local history entries capture:
- raw text (including placeholders),
- `TextElement` ranges for placeholders,
- local image paths,
- pending large-paste payloads (for drafts).
Persistent history entries only restore text. They intentionally do **not** rehydrate attachments
or pending paste payloads.
### Draft recovery (Ctrl+C)
Ctrl+C clears the composer but stashes the full draft state (text elements, image paths, and
pending paste payloads) into local history. Pressing Up immediately restores that draft, including
image placeholders and large-paste placeholders with their payloads.
### Submitted message recall
After a successful submission, the local history entry stores the submitted text and any element
ranges and local image paths. Pending paste payloads are cleared during submission, so large-paste
placeholders are expanded into their full text before being recorded. This means:
- Up/Down recall of a submitted message restores image placeholders and their local paths.
- Large-paste placeholders are not expected in recalled submitted history; the text is the
expanded paste content.
### Backtrack prefill
Backtrack selections read `UserHistoryCell` data from the transcript. The composer prefill now
reuses the selected message’s text elements and local image paths, so image placeholders and
attachments rehydrate when rolling back to a prior user message.
### External editor edits
When the composer content is replaced from an external editor, the composer rebuilds text elements
and keeps only attachments whose placeholders still appear in the new text. Image placeholders are
then normalized to `[Image #1]..[Image #N]` to keep attachment mapping consistent after edits.
## Paste burst: concepts and assumptions
The burst detector is intentionally conservative: it only processes “plain” character input
+86
View File
@@ -0,0 +1,86 @@
diff --git a/builder/cc_builder.rs b/builder/cc_builder.rs
--- a/builder/cc_builder.rs
+++ b/builder/cc_builder.rs
@@ -26,7 +26,7 @@
};
use std::cell::Cell;
use std::collections::HashMap;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
#[non_exhaustive]
#[derive(PartialEq, Eq)]
@@ -661,6 +661,16 @@
}
let mut memcmp_compile_args = Vec::from(memcmp_compiler.args());
+ // Keep the probe self-contained and avoid invoking external debug tools
+ // (for example `dsymutil`) that may be missing in hermetic sandboxes.
+ memcmp_compile_args.retain(|arg| {
+ let Some(arg_str) = arg.to_str() else {
+ return true;
+ };
+ !arg_str.starts_with("-g")
+ });
+ memcmp_compile_args.push("-g0".into());
+
// This check invokes the compiled executable and hence needs to link
// it. CMake handles this via LDFLAGS but `cc` doesn't. In setups with
// custom linker setups this could lead to a mismatch between the
@@ -672,6 +682,15 @@
}
}
+ if let Some(execroot) = Self::bazel_execroot(self.manifest_dir.as_path()) {
+ // In Bazel build-script sandboxes, `cc` can pass `bazel-out/...` args
+ // relative to the execroot while the process runs from elsewhere.
+ // Normalize those args to absolute paths so this check can still link.
+ for arg in &mut memcmp_compile_args {
+ Self::rewrite_bazel_execroot_arg(execroot.as_path(), arg);
+ }
+ }
+
memcmp_compile_args.push(
self.manifest_dir
.join("aws-lc")
@@ -725,6 +744,40 @@
}
let _ = fs::remove_file(exec_path);
}
+
+ fn rewrite_bazel_execroot_arg(execroot: &Path, arg: &mut std::ffi::OsString) {
+ let Some(arg_str) = arg.to_str() else {
+ return;
+ };
+
+ if arg_str.starts_with("bazel-out/") {
+ *arg = execroot.join(arg_str).into_os_string();
+ return;
+ }
+
+ for flag_prefix in ["-B", "-L"] {
+ if let Some(path) = arg_str.strip_prefix(flag_prefix) {
+ if path.starts_with("bazel-out/") {
+ *arg = format!("{flag_prefix}{}", execroot.join(path).display()).into();
+ return;
+ }
+ }
+ }
+ }
+
+ fn bazel_execroot(path: &Path) -> Option<PathBuf> {
+ let mut prefix = PathBuf::new();
+ for component in path.components() {
+ if component.as_os_str() == "bazel-out" {
+ return Some(prefix);
+ }
+
+ prefix.push(component.as_os_str());
+ }
+
+ None
+ }
+
fn run_compiler_checks(&self, cc_build: &mut cc::Build) {
if self.compiler_check("stdalign_check", Vec::<&'static str>::new()) {
cc_build.define("AWS_LC_STDALIGN_AVAILABLE", Some("1"));
@@ -0,0 +1,85 @@
diff --git a/toolchain/args/BUILD.bazel b/toolchain/args/BUILD.bazel
index e6dc56c..b323589 100644
--- a/toolchain/args/BUILD.bazel
+++ b/toolchain/args/BUILD.bazel
@@ -17,8 +17,8 @@ package(default_visibility = ["//visibility:public"])
cc_args(
name = "resource_dir",
actions = [
+ "@rules_cc//cc/toolchains/actions:compile_actions",
"@rules_cc//cc/toolchains/actions:link_actions",
- # We may need it for other actions too?
],
args = [
"-resource-dir",
@@ -32,6 +32,46 @@ cc_args(
],
)
+alias(
+ name = "clang_builtin_headers_include_search_directory",
+ actual = select({
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64_gnu": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64_gnu": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64_musl": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64_musl": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:macos_x86_64": "@llvm-toolchain-minimal-21.1.8-darwin-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:macos_aarch64": "@llvm-toolchain-minimal-21.1.8-darwin-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:windows_x86_64": "@llvm-toolchain-minimal-21.1.8-windows-amd64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:windows_aarch64": "@llvm-toolchain-minimal-21.1.8-windows-arm64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:none_wasm32": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers",
+ "@toolchains_llvm_bootstrapped//platforms/config:none_wasm64": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers",
+ }),
+ visibility = ["//toolchain:__subpackages__"],
+)
+
+cc_args(
+ name = "clang_builtin_headers_include_search_paths",
+ actions = [
+ "@rules_cc//cc/toolchains/actions:compile_actions",
+ "@rules_cc//cc/toolchains/actions:link_actions",
+ ],
+ args = [
+ "-isystem",
+ "{clang_builtin_headers_include_search_path}",
+ ],
+ format = {
+ "clang_builtin_headers_include_search_path": ":clang_builtin_headers_include_search_directory",
+ },
+ data = [
+ ":clang_builtin_headers_include_search_directory",
+ ],
+ allowlist_include_directories = [
+ ":clang_builtin_headers_include_search_directory",
+ ],
+)
+
cc_args(
name = "llvm_target_for_platform",
actions = [
diff --git a/toolchain/BUILD.bazel b/toolchain/BUILD.bazel
index 6ffc9f7..e02089a 100644
--- a/toolchain/BUILD.bazel
+++ b/toolchain/BUILD.bazel
@@ -100,6 +100,7 @@ cc_args_list(
"@platforms//os:macos": [],
"//conditions:default": [
"//toolchain/args:resource_dir",
+ "//toolchain/args:clang_builtin_headers_include_search_paths",
],
}),
)
diff --git a/toolchain/llvm/llvm.bzl b/toolchain/llvm/llvm.bzl
index d068085..c152552 100644
--- a/toolchain/llvm/llvm.bzl
+++ b/toolchain/llvm/llvm.bzl
@@ -7,6 +7,7 @@ def declare_llvm_targets(*, suffix = ""):
name = "builtin_headers",
# Grab whichever version-specific dir is there.
path = native.glob(["lib/clang/*"], exclude_directories = 0)[0] + "/include",
+ visibility = ["//visibility:public"],
)
# Convenient exports