mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge fa48061df3 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Generated
+145
-17
File diff suppressed because one or more lines are too long
Generated
+214
-147
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user