Merge db95926c15 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-03 09:09:34 -08:00
committed by GitHub
129 changed files with 13120 additions and 3297 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ body:
id: version
attributes:
label: What version of Codex is running?
description: Copy the output of `codex --version`
description: (App) look in "About Codex" dialog; (CLI) use `codex --version`; (IDE Extension) look in extensions panel.
validations:
required: true
- type: input
@@ -12,6 +12,13 @@ body:
1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one.
2. The Codex team will try to balance the varying needs of the community when prioritizing or rejecting new features. Not all features will be accepted. See [Contributing](https://github.com/openai/codex#contributing) for more details.
- type: input
id: variant
attributes:
label: What variant of Codex are you using?
description: (e.g., CLI, App, IDE Extension, Web)
validations:
required: true
- type: textarea
id: feature
attributes:
+518 -28
View File
@@ -175,6 +175,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -186,6 +196,49 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "age"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b"
dependencies = [
"age-core",
"base64 0.21.7",
"bech32",
"chacha20poly1305",
"cookie-factory",
"hmac",
"i18n-embed",
"i18n-embed-fl",
"lazy_static",
"nom 7.1.3",
"pin-project",
"rand 0.8.5",
"rust-embed",
"scrypt",
"sha2",
"subtle",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "age-core"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99"
dependencies = [
"base64 0.21.7",
"chacha20poly1305",
"cookie-factory",
"hkdf",
"io_tee",
"nom 7.1.3",
"rand 0.8.5",
"secrecy",
"sha2",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -330,7 +383,7 @@ name = "app_test_support"
version = "0.0.0"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"chrono",
"codex-app-server-protocol",
"codex-core",
@@ -685,6 +738,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -697,6 +756,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bech32"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
[[package]]
name = "beef"
version = "0.5.2"
@@ -716,7 +790,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"rustc-hash 2.1.1",
"shlex",
"syn 2.0.114",
]
@@ -919,6 +993,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chardetng"
version = "0.1.17"
@@ -958,6 +1056,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -1090,7 +1189,7 @@ dependencies = [
"app_test_support",
"async-trait",
"axum",
"base64",
"base64 0.22.1",
"chrono",
"codex-app-server-protocol",
"codex-arg0",
@@ -1309,7 +1408,7 @@ name = "codex-cloud-requirements"
version = "0.0.0"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"codex-backend-client",
"codex-core",
"codex-otel",
@@ -1328,7 +1427,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"base64 0.22.1",
"chrono",
"clap",
"codex-cloud-tasks-client",
@@ -1391,7 +1490,7 @@ dependencies = [
"assert_matches",
"async-channel",
"async-trait",
"base64",
"base64 0.22.1",
"chardetng",
"chrono",
"clap",
@@ -1656,15 +1755,19 @@ dependencies = [
name = "codex-linux-sandbox"
version = "0.0.0"
dependencies = [
"cc",
"clap",
"codex-core",
"codex-utils-absolute-path",
"landlock",
"libc",
"pkg-config",
"pretty_assertions",
"seccompiler",
"serde_json",
"tempfile",
"tokio",
"which",
]
[[package]]
@@ -1685,7 +1788,7 @@ name = "codex-login"
version = "0.0.0"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"chrono",
"codex-app-server-protocol",
"codex-core",
@@ -1888,6 +1991,25 @@ dependencies = [
"which",
]
[[package]]
name = "codex-secrets"
version = "0.0.0"
dependencies = [
"age",
"anyhow",
"base64 0.22.1",
"codex-keyring-store",
"keyring",
"pretty_assertions",
"rand 0.9.2",
"schemars 0.8.22",
"serde",
"serde_json",
"sha2",
"tempfile",
"tracing",
]
[[package]]
name = "codex-state"
version = "0.0.0"
@@ -1929,7 +2051,7 @@ dependencies = [
"anyhow",
"arboard",
"assert_matches",
"base64",
"base64 0.22.1",
"chrono",
"clap",
"codex-ansi-escape",
@@ -2045,7 +2167,7 @@ dependencies = [
name = "codex-utils-image"
version = "0.0.0"
dependencies = [
"base64",
"base64 0.22.1",
"codex-utils-cache",
"image",
"tempfile",
@@ -2098,7 +2220,7 @@ name = "codex-windows-sandbox"
version = "0.0.0"
dependencies = [
"anyhow",
"base64",
"base64 0.22.1",
"chrono",
"codex-protocol",
"codex-utils-absolute-path",
@@ -2249,6 +2371,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
dependencies = [
"futures",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -2281,7 +2412,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"base64",
"base64 0.22.1",
"codex-core",
"codex-protocol",
"codex-utils-absolute-path",
@@ -2471,6 +2602,32 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling"
version = "0.21.3"
@@ -3120,6 +3277,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -3131,6 +3294,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "find-crate"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -3191,6 +3363,50 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fluent"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a"
dependencies = [
"fluent-bundle",
"unic-langid",
]
[[package]]
name = "fluent-bundle"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"intl-memoizer",
"intl_pluralrules",
"rustc-hash 1.1.0",
"self_cell 0.10.3",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-langneg"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
dependencies = [
"unic-langid",
]
[[package]]
name = "fluent-syntax"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -3843,7 +4059,7 @@ version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
@@ -3863,6 +4079,72 @@ dependencies = [
"windows-registry",
]
[[package]]
name = "i18n-config"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef"
dependencies = [
"basic-toml",
"log",
"serde",
"serde_derive",
"thiserror 1.0.69",
"unic-langid",
]
[[package]]
name = "i18n-embed"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4"
dependencies = [
"arc-swap",
"fluent",
"fluent-langneg",
"fluent-syntax",
"i18n-embed-impl",
"intl-memoizer",
"log",
"parking_lot",
"rust-embed",
"thiserror 1.0.69",
"unic-langid",
"walkdir",
]
[[package]]
name = "i18n-embed-fl"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d"
dependencies = [
"find-crate",
"fluent",
"fluent-syntax",
"i18n-config",
"i18n-embed",
"proc-macro-error2",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.114",
"unic-langid",
]
[[package]]
name = "i18n-embed-impl"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2"
dependencies = [
"find-crate",
"i18n-config",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -4190,6 +4472,25 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
dependencies = [
"type-map",
"unic-langid",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "inventory"
version = "0.3.21"
@@ -4199,6 +4500,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "io_tee"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304"
[[package]]
name = "ipconfig"
version = "0.3.2"
@@ -5064,7 +5371,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -5274,6 +5581,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
@@ -5399,7 +5712,7 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"base64",
"base64 0.22.1",
"const-hex",
"opentelemetry",
"opentelemetry_sdk",
@@ -5550,6 +5863,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -5692,6 +6015,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -5809,6 +6143,28 @@ dependencies = [
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -5939,7 +6295,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustc-hash 2.1.1",
"rustls",
"socket2 0.6.2",
"thiserror 2.0.18",
@@ -5959,7 +6315,7 @@ dependencies = [
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustc-hash 2.1.1",
"rustls",
"rustls-pki-types",
"slab",
@@ -6105,7 +6461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd"
dependencies = [
"ahash",
"base64",
"base64 0.22.1",
"bitflags 2.10.0",
"chrono",
"const_format",
@@ -6185,7 +6541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285"
dependencies = [
"ahash",
"base64",
"base64 0.22.1",
"chrono",
"const_format",
"httpdate",
@@ -6589,7 +6945,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
@@ -6659,7 +7015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"futures",
@@ -6725,12 +7081,52 @@ name = "runfiles"
version = "0.1.0"
source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81"
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.114",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -6854,6 +7250,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -6997,6 +7402,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sdd"
version = "3.0.10"
@@ -7012,6 +7428,15 @@ dependencies = [
"libc",
]
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"zeroize",
]
[[package]]
name = "secret-service"
version = "4.0.0"
@@ -7067,6 +7492,21 @@ dependencies = [
"libc",
]
[[package]]
name = "self_cell"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d"
dependencies = [
"self_cell 1.2.2",
]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]]
name = "semver"
version = "1.0.27"
@@ -7311,7 +7751,7 @@ version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
dependencies = [
"base64",
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -7599,7 +8039,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"crc",
@@ -7677,7 +8117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags 2.10.0",
"byteorder",
"bytes",
@@ -7722,7 +8162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags 2.10.0",
"byteorder",
"chrono",
@@ -8542,7 +8982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"bytes",
"http 1.4.0",
"http-body",
@@ -8845,6 +9285,15 @@ dependencies = [
"utf-8",
]
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash 2.1.1",
]
[[package]]
name = "typenum"
version = "1.19.0"
@@ -8877,6 +9326,25 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"serde",
"tinystr",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -8951,6 +9419,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -8969,7 +9447,7 @@ version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64",
"base64 0.22.1",
"der",
"log",
"native-tls",
@@ -8986,7 +9464,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64",
"base64 0.22.1",
"http 1.4.0",
"httparse",
"log",
@@ -9952,7 +10430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"base64 0.22.1",
"deadpool",
"futures",
"http 1.4.0",
@@ -10015,6 +10493,18 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
+4 -1
View File
@@ -17,6 +17,7 @@ members = [
"cli",
"common",
"core",
"secrets",
"exec",
"exec-server",
"execpolicy",
@@ -79,6 +80,7 @@ codex-cli = { path = "cli"}
codex-client = { path = "codex-client" }
codex-common = { path = "common" }
codex-core = { path = "core" }
codex-secrets = { path = "secrets" }
codex-exec = { path = "exec" }
codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -114,6 +116,7 @@ exec_server_test_support = { path = "exec-server/tests/common" }
mcp_test_support = { path = "mcp-server/tests/common" }
# External
age = "0.11.1"
allocative = "0.3.3"
ansi-to-tui = "7.0.0"
anyhow = "1"
@@ -291,7 +294,7 @@ unwrap_used = "deny"
# cargo-shear cannot see the platform-specific openssl-sys usage, so we
# silence the false positive here instead of deleting a real dependency.
[workspace.metadata.cargo-shear]
ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness"]
ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness", "codex-secrets"]
[profile.release]
lto = "fat"
@@ -526,7 +526,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -1410,7 +1410,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -2857,7 +2857,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -3689,7 +3689,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -3477,7 +3477,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -4729,7 +4729,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -4965,7 +4965,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -6629,7 +6629,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -11151,7 +11151,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -12588,7 +12588,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -2857,7 +2857,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -3689,7 +3689,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -144,7 +144,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -530,7 +530,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -2857,7 +2857,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -3689,7 +3689,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -2857,7 +2857,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -3689,7 +3689,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -111,7 +111,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -417,7 +417,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -120,7 +120,7 @@
]
},
"FunctionCallOutputPayload": {
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.",
"description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.",
"properties": {
"content": {
"type": "string"
@@ -433,7 +433,7 @@
]
},
"id": {
"description": "Set when using the chat completions API.",
"description": "Legacy id field retained for compatibility with older payloads.",
"type": [
"string",
"null"
@@ -9,7 +9,6 @@ import type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentI
* `content` preserves the historical plain-string payload so downstream
* integrations (tests, logging, etc.) can keep treating tool output as
* `String`. When an MCP server returns richer data we additionally populate
* `content_items` with the structured form that the Responses/Chat
* Completions APIs understand.
* `content_items` with the structured form that the Responses API understands.
*/
export type FunctionCallOutputPayload = { content: string, content_items: Array<FunctionCallOutputContentItem> | null, success: boolean | null, };
+2 -2
View File
@@ -2,7 +2,7 @@
Typed clients for Codex/OpenAI APIs built on top of the generic transport in `codex-client`.
- Hosts the request/response models and prompt helpers for Responses, Chat Completions, and Compact APIs.
- Hosts the request/response models and prompt helpers for Responses and Compact APIs.
- Owns provider configuration (base URLs, headers, query params), auth header injection, retry tuning, and stream idle settings.
- Parses SSE streams into `ResponseEvent`/`ResponseStream`, including rate-limit snapshots and API-specific error mapping.
- Serves as the wire-level layer consumed by `codex-core`; higher layers handle auth refresh and business logic.
@@ -11,7 +11,7 @@ Typed clients for Codex/OpenAI APIs built on top of the generic transport in `co
The public interface of this crate is intentionally small and uniform:
- **Prompted endpoints (Chat + Responses)**
- **Prompted endpoints (Responses)**
- Input: a single `Prompt` plus endpoint-specific options.
- `Prompt` (re-exported as `codex_api::Prompt`) carries:
- `instructions: String` – the fully-resolved system prompt for this turn.
+11 -5
View File
@@ -1,4 +1,6 @@
use codex_client::Request;
use http::HeaderMap;
use http::HeaderValue;
/// Provides bearer and account identity information for API requests.
///
@@ -12,16 +14,20 @@ pub trait AuthProvider: Send + Sync {
}
}
pub(crate) fn add_auth_headers<A: AuthProvider>(auth: &A, mut req: Request) -> Request {
pub(crate) fn add_auth_headers_to_header_map<A: AuthProvider>(auth: &A, headers: &mut HeaderMap) {
if let Some(token) = auth.bearer_token()
&& let Ok(header) = format!("Bearer {token}").parse()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = req.headers.insert(http::header::AUTHORIZATION, header);
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = auth.account_id()
&& let Ok(header) = account_id.parse()
&& let Ok(header) = HeaderValue::from_str(&account_id)
{
let _ = req.headers.insert("ChatGPT-Account-ID", header);
let _ = headers.insert("ChatGPT-Account-ID", header);
}
}
pub(crate) fn add_auth_headers<A: AuthProvider>(auth: &A, mut req: Request) -> Request {
add_auth_headers_to_header_map(auth, &mut req.headers);
req
}
+1 -1
View File
@@ -13,7 +13,7 @@ use std::task::Context;
use std::task::Poll;
use tokio::sync::mpsc;
/// Canonical prompt input for Chat and Responses endpoints.
/// Canonical prompt input for Responses endpoints.
#[derive(Debug, Clone)]
pub struct Prompt {
/// Fully-resolved system instructions for this turn.
@@ -1,111 +1,21 @@
use crate::ChatRequest;
use crate::auth::AuthProvider;
use crate::common::Prompt as ApiPrompt;
use crate::common::ResponseEvent;
use crate::common::ResponseStream;
use crate::endpoint::streaming::StreamingClient;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::provider::WireApi;
use crate::sse::chat::spawn_chat_stream;
use crate::telemetry::SseTelemetry;
use codex_client::HttpTransport;
use codex_client::RequestCompression;
use codex_client::RequestTelemetry;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
use futures::Stream;
use http::HeaderMap;
use serde_json::Value;
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context;
use std::task::Poll;
pub struct ChatClient<T: HttpTransport, A: AuthProvider> {
streaming: StreamingClient<T, A>,
}
impl<T: HttpTransport, A: AuthProvider> ChatClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
streaming: StreamingClient::new(transport, provider, auth),
}
}
pub fn with_telemetry(
self,
request: Option<Arc<dyn RequestTelemetry>>,
sse: Option<Arc<dyn SseTelemetry>>,
) -> Self {
Self {
streaming: self.streaming.with_telemetry(request, sse),
}
}
pub async fn stream_request(&self, request: ChatRequest) -> Result<ResponseStream, ApiError> {
self.stream(request.body, request.headers).await
}
pub async fn stream_prompt(
&self,
model: &str,
prompt: &ApiPrompt,
conversation_id: Option<String>,
session_source: Option<SessionSource>,
) -> Result<ResponseStream, ApiError> {
use crate::requests::ChatRequestBuilder;
let request =
ChatRequestBuilder::new(model, &prompt.instructions, &prompt.input, &prompt.tools)
.conversation_id(conversation_id)
.session_source(session_source)
.build(self.streaming.provider())?;
self.stream_request(request).await
}
fn path(&self) -> &'static str {
match self.streaming.provider().wire {
WireApi::Chat => "chat/completions",
_ => "responses",
}
}
pub async fn stream(
&self,
body: Value,
extra_headers: HeaderMap,
) -> Result<ResponseStream, ApiError> {
self.streaming
.stream(
self.path(),
body,
extra_headers,
RequestCompression::None,
spawn_chat_stream,
None,
)
.await
}
}
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum AggregateMode {
AggregatedOnly,
Streaming,
}
/// Stream adapter that merges token deltas into a single assistant message per turn.
pub struct AggregatedStream {
inner: ResponseStream,
cumulative: String,
cumulative_reasoning: String,
pending: VecDeque<ResponseEvent>,
mode: AggregateMode,
}
impl Stream for AggregatedStream {
@@ -122,7 +32,7 @@ impl Stream for AggregatedStream {
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(None) => return Poll::Ready(None),
Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))),
Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))) => {
let is_assistant_message = matches!(
&item,
@@ -130,29 +40,16 @@ impl Stream for AggregatedStream {
);
if is_assistant_message {
match this.mode {
AggregateMode::AggregatedOnly => {
if this.cumulative.is_empty()
&& let ResponseItem::Message { content, .. } = &item
&& let Some(text) = content.iter().find_map(|c| match c {
ContentItem::OutputText { text } => Some(text),
_ => None,
})
{
this.cumulative.push_str(text);
}
continue;
}
AggregateMode::Streaming => {
if this.cumulative.is_empty() {
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(
item,
))));
} else {
continue;
}
}
if this.cumulative.is_empty()
&& let ResponseItem::Message { content, .. } = &item
&& let Some(text) = content.iter().find_map(|c| match c {
ContentItem::OutputText { text } => Some(text),
_ => None,
})
{
this.cumulative.push_str(text);
}
continue;
}
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item))));
@@ -216,35 +113,20 @@ impl Stream for AggregatedStream {
token_usage,
})));
}
Poll::Ready(Some(Ok(ResponseEvent::Created))) => {
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::Created))) => continue,
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => {
this.cumulative.push_str(&delta);
if matches!(this.mode, AggregateMode::Streaming) {
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta))));
} else {
continue;
}
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta {
delta,
content_index,
content_index: _,
}))) => {
this.cumulative_reasoning.push_str(&delta);
if matches!(this.mode, AggregateMode::Streaming) {
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta {
delta,
content_index,
})));
} else {
continue;
}
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => continue,
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded { .. }))) => {
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => continue,
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded { .. }))) => continue,
Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item))));
}
@@ -255,28 +137,21 @@ impl Stream for AggregatedStream {
pub trait AggregateStreamExt {
fn aggregate(self) -> AggregatedStream;
fn streaming_mode(self) -> ResponseStream;
}
impl AggregateStreamExt for ResponseStream {
fn aggregate(self) -> AggregatedStream {
AggregatedStream::new(self, AggregateMode::AggregatedOnly)
}
fn streaming_mode(self) -> ResponseStream {
self
AggregatedStream::new(self)
}
}
impl AggregatedStream {
fn new(inner: ResponseStream, mode: AggregateMode) -> Self {
fn new(inner: ResponseStream) -> Self {
AggregatedStream {
inner,
cumulative: String::new(),
cumulative_reasoning: String::new(),
pending: VecDeque::new(),
mode,
}
}
}
+19 -76
View File
@@ -1,10 +1,8 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers;
use crate::common::CompactionInput;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::provider::WireApi;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestTelemetry;
use codex_protocol::models::ResponseItem;
@@ -15,34 +13,24 @@ use serde_json::to_value;
use std::sync::Arc;
pub struct CompactClient<T: HttpTransport, A: AuthProvider> {
transport: T,
provider: Provider,
auth: A,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
session: EndpointSession<T, A>,
}
impl<T: HttpTransport, A: AuthProvider> CompactClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
transport,
provider,
auth,
request_telemetry: None,
session: EndpointSession::new(transport, provider, auth),
}
}
pub fn with_telemetry(mut self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
self.request_telemetry = request;
self
pub fn with_telemetry(self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
Self {
session: self.session.with_request_telemetry(request),
}
}
fn path(&self) -> Result<&'static str, ApiError> {
match self.provider.wire {
WireApi::Compact | WireApi::Responses => Ok("responses/compact"),
WireApi::Chat => Err(ApiError::Stream(
"compact endpoint requires responses wire api".to_string(),
)),
}
fn path() -> &'static str {
"responses/compact"
}
pub async fn compact(
@@ -50,21 +38,10 @@ impl<T: HttpTransport, A: AuthProvider> CompactClient<T, A> {
body: serde_json::Value,
extra_headers: HeaderMap,
) -> Result<Vec<ResponseItem>, ApiError> {
let path = self.path()?;
let builder = || {
let mut req = self.provider.build_request(Method::POST, path);
req.headers.extend(extra_headers.clone());
req.body = Some(body.clone());
add_auth_headers(&self.auth, req)
};
let resp = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
builder,
|req| self.transport.execute(req),
)
.await?;
let resp = self
.session
.execute(Method::POST, Self::path(), extra_headers, Some(body))
.await?;
let parsed: CompactHistoryResponse =
serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?;
Ok(parsed.output)
@@ -89,14 +66,11 @@ struct CompactHistoryResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use async_trait::async_trait;
use codex_client::Request;
use codex_client::Response;
use codex_client::StreamResponse;
use codex_client::TransportError;
use http::HeaderMap;
use std::time::Duration;
#[derive(Clone, Default)]
struct DummyTransport;
@@ -121,42 +95,11 @@ mod tests {
}
}
fn provider(wire: WireApi) -> Provider {
Provider {
name: "test".to_string(),
base_url: "https://example.com/v1".to_string(),
query_params: None,
wire,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: Duration::from_millis(1),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: Duration::from_secs(1),
}
}
#[tokio::test]
async fn errors_when_wire_is_chat() {
let client = CompactClient::new(DummyTransport, provider(WireApi::Chat), DummyAuth);
let input = CompactionInput {
model: "gpt-test",
input: &[],
instructions: "inst",
};
let err = client
.compact_input(&input, HeaderMap::new())
.await
.expect_err("expected wire mismatch to fail");
match err {
ApiError::Stream(msg) => {
assert_eq!(msg, "compact endpoint requires responses wire api");
}
other => panic!("unexpected error: {other:?}"),
}
#[test]
fn path_is_responses_compact() {
assert_eq!(
CompactClient::<DummyTransport, DummyAuth>::path(),
"responses/compact"
);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
pub mod chat;
pub mod aggregate;
pub mod compact;
pub mod models;
pub mod responses;
pub mod responses_websocket;
mod streaming;
mod session;
+19 -33
View File
@@ -1,8 +1,7 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestTelemetry;
use codex_protocol::openai_models::ModelInfo;
@@ -13,53 +12,42 @@ use http::header::ETAG;
use std::sync::Arc;
pub struct ModelsClient<T: HttpTransport, A: AuthProvider> {
transport: T,
provider: Provider,
auth: A,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
session: EndpointSession<T, A>,
}
impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
transport,
provider,
auth,
request_telemetry: None,
session: EndpointSession::new(transport, provider, auth),
}
}
pub fn with_telemetry(mut self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
self.request_telemetry = request;
self
pub fn with_telemetry(self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
Self {
session: self.session.with_request_telemetry(request),
}
}
fn path(&self) -> &'static str {
fn path() -> &'static str {
"models"
}
fn append_client_version_query(req: &mut codex_client::Request, client_version: &str) {
let separator = if req.url.contains('?') { '&' } else { '?' };
req.url = format!("{}{}client_version={client_version}", req.url, separator);
}
pub async fn list_models(
&self,
client_version: &str,
extra_headers: HeaderMap,
) -> Result<(Vec<ModelInfo>, Option<String>), ApiError> {
let builder = || {
let mut req = self.provider.build_request(Method::GET, self.path());
req.headers.extend(extra_headers.clone());
let separator = if req.url.contains('?') { '&' } else { '?' };
req.url = format!("{}{}client_version={client_version}", req.url, separator);
add_auth_headers(&self.auth, req)
};
let resp = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
builder,
|req| self.transport.execute(req),
)
.await?;
let resp = self
.session
.execute_with(Method::GET, Self::path(), extra_headers, None, |req| {
Self::append_client_version_query(req, client_version);
})
.await?;
let header_etag = resp
.headers
@@ -83,7 +71,6 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
use async_trait::async_trait;
use codex_client::Request;
use codex_client::Response;
@@ -149,7 +136,6 @@ mod tests {
name: "test".to_string(),
base_url: base_url.to_string(),
query_params: None,
wire: WireApi::Responses,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
+34 -20
View File
@@ -3,10 +3,9 @@ use crate::common::Prompt as ApiPrompt;
use crate::common::Reasoning;
use crate::common::ResponseStream;
use crate::common::TextControls;
use crate::endpoint::streaming::StreamingClient;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::provider::WireApi;
use crate::requests::ResponsesRequest;
use crate::requests::ResponsesRequestBuilder;
use crate::requests::responses::Compression;
@@ -17,13 +16,16 @@ use codex_client::RequestCompression;
use codex_client::RequestTelemetry;
use codex_protocol::protocol::SessionSource;
use http::HeaderMap;
use http::HeaderValue;
use http::Method;
use serde_json::Value;
use std::sync::Arc;
use std::sync::OnceLock;
use tracing::instrument;
pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> {
streaming: StreamingClient<T, A>,
session: EndpointSession<T, A>,
sse_telemetry: Option<Arc<dyn SseTelemetry>>,
}
#[derive(Default)]
@@ -43,7 +45,8 @@ pub struct ResponsesOptions {
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
streaming: StreamingClient::new(transport, provider, auth),
session: EndpointSession::new(transport, provider, auth),
sse_telemetry: None,
}
}
@@ -53,7 +56,8 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
sse: Option<Arc<dyn SseTelemetry>>,
) -> Self {
Self {
streaming: self.streaming.with_telemetry(request, sse),
session: self.session.with_request_telemetry(request),
sse_telemetry: sse,
}
}
@@ -103,16 +107,13 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
.store_override(store_override)
.extra_headers(extra_headers)
.compression(compression)
.build(self.streaming.provider())?;
.build(self.session.provider())?;
self.stream_request(request, turn_state).await
}
fn path(&self) -> &'static str {
match self.streaming.provider().wire {
WireApi::Responses | WireApi::Compact => "responses",
WireApi::Chat => "chat/completions",
}
fn path() -> &'static str {
"responses"
}
pub async fn stream(
@@ -122,20 +123,33 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
compression: Compression,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponseStream, ApiError> {
let compression = match compression {
let request_compression = match compression {
Compression::None => RequestCompression::None,
Compression::Zstd => RequestCompression::Zstd,
};
self.streaming
.stream(
self.path(),
body,
let stream_response = self
.session
.stream_with(
Method::POST,
Self::path(),
extra_headers,
compression,
spawn_response_stream,
turn_state,
Some(body),
|req| {
req.headers.insert(
http::header::ACCEPT,
HeaderValue::from_static("text/event-stream"),
);
req.compression = request_compression;
},
)
.await
.await?;
Ok(spawn_response_stream(
stream_response,
self.session.provider().stream_idle_timeout,
self.sse_telemetry.clone(),
turn_state,
))
}
}
@@ -1,4 +1,5 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers_to_header_map;
use crate::common::ResponseEvent;
use crate::common::ResponseStream;
use crate::common::ResponsesWsRequest;
@@ -11,7 +12,6 @@ use codex_client::TransportError;
use futures::SinkExt;
use futures::StreamExt;
use http::HeaderMap;
use http::HeaderValue;
use serde_json::Value;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -134,7 +134,7 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
let mut headers = self.provider.headers.clone();
headers.extend(extra_headers);
apply_auth_headers(&mut headers, &self.auth);
add_auth_headers_to_header_map(&self.auth, &mut headers);
let (stream, server_reasoning_included) =
connect_websocket(ws_url, headers, turn_state).await?;
@@ -147,20 +147,6 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
}
}
// TODO (pakrym): share with /auth
fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) {
if let Some(token) = auth.bearer_token()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = auth.account_id()
&& let Ok(header) = HeaderValue::from_str(&account_id)
{
let _ = headers.insert("ChatGPT-Account-ID", header);
}
}
async fn connect_websocket(
url: Url,
headers: HeaderMap,
+126
View File
@@ -0,0 +1,126 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::Request;
use codex_client::RequestTelemetry;
use codex_client::Response;
use codex_client::StreamResponse;
use http::HeaderMap;
use http::Method;
use serde_json::Value;
use std::sync::Arc;
pub(crate) struct EndpointSession<T: HttpTransport, A: AuthProvider> {
transport: T,
provider: Provider,
auth: A,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
}
impl<T: HttpTransport, A: AuthProvider> EndpointSession<T, A> {
pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
transport,
provider,
auth,
request_telemetry: None,
}
}
pub(crate) fn with_request_telemetry(
mut self,
request: Option<Arc<dyn RequestTelemetry>>,
) -> Self {
self.request_telemetry = request;
self
}
pub(crate) fn provider(&self) -> &Provider {
&self.provider
}
fn make_request(
&self,
method: &Method,
path: &str,
extra_headers: &HeaderMap,
body: Option<&Value>,
) -> Request {
let mut req = self.provider.build_request(method.clone(), path);
req.headers.extend(extra_headers.clone());
if let Some(body) = body {
req.body = Some(body.clone());
}
add_auth_headers(&self.auth, req)
}
pub(crate) async fn execute(
&self,
method: Method,
path: &str,
extra_headers: HeaderMap,
body: Option<Value>,
) -> Result<Response, ApiError> {
self.execute_with(method, path, extra_headers, body, |_| {})
.await
}
pub(crate) async fn execute_with<C>(
&self,
method: Method,
path: &str,
extra_headers: HeaderMap,
body: Option<Value>,
configure: C,
) -> Result<Response, ApiError>
where
C: Fn(&mut Request),
{
let make_request = || {
let mut req = self.make_request(&method, path, &extra_headers, body.as_ref());
configure(&mut req);
req
};
let response = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
make_request,
|req| self.transport.execute(req),
)
.await?;
Ok(response)
}
pub(crate) async fn stream_with<C>(
&self,
method: Method,
path: &str,
extra_headers: HeaderMap,
body: Option<Value>,
configure: C,
) -> Result<StreamResponse, ApiError>
where
C: Fn(&mut Request),
{
let make_request = || {
let mut req = self.make_request(&method, path, &extra_headers, body.as_ref());
configure(&mut req);
req
};
let stream = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
make_request,
|req| self.transport.stream(req),
)
.await?;
Ok(stream)
}
}
@@ -1,95 +0,0 @@
use crate::auth::AuthProvider;
use crate::auth::add_auth_headers;
use crate::common::ResponseStream;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::telemetry::SseTelemetry;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestCompression;
use codex_client::RequestTelemetry;
use codex_client::StreamResponse;
use http::HeaderMap;
use http::Method;
use serde_json::Value;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
pub(crate) struct StreamingClient<T: HttpTransport, A: AuthProvider> {
transport: T,
provider: Provider,
auth: A,
request_telemetry: Option<Arc<dyn RequestTelemetry>>,
sse_telemetry: Option<Arc<dyn SseTelemetry>>,
}
type StreamSpawner = fn(
StreamResponse,
Duration,
Option<Arc<dyn SseTelemetry>>,
Option<Arc<OnceLock<String>>>,
) -> ResponseStream;
impl<T: HttpTransport, A: AuthProvider> StreamingClient<T, A> {
pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self {
Self {
transport,
provider,
auth,
request_telemetry: None,
sse_telemetry: None,
}
}
pub(crate) fn with_telemetry(
mut self,
request: Option<Arc<dyn RequestTelemetry>>,
sse: Option<Arc<dyn SseTelemetry>>,
) -> Self {
self.request_telemetry = request;
self.sse_telemetry = sse;
self
}
pub(crate) fn provider(&self) -> &Provider {
&self.provider
}
pub(crate) async fn stream(
&self,
path: &str,
body: Value,
extra_headers: HeaderMap,
compression: RequestCompression,
spawner: StreamSpawner,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponseStream, ApiError> {
let builder = || {
let mut req = self.provider.build_request(Method::POST, path);
req.headers.extend(extra_headers.clone());
req.headers.insert(
http::header::ACCEPT,
http::HeaderValue::from_static("text/event-stream"),
);
req.body = Some(body.clone());
req.compression = compression;
add_auth_headers(&self.auth, req)
};
let stream_response = run_with_request_telemetry(
self.provider.retry.to_policy(),
self.request_telemetry.clone(),
builder,
|req| self.transport.stream(req),
)
.await?;
Ok(spawner(
stream_response,
self.provider.stream_idle_timeout,
self.sse_telemetry.clone(),
turn_state,
))
}
}
+1 -5
View File
@@ -22,8 +22,7 @@ pub use crate::common::ResponseEvent;
pub use crate::common::ResponseStream;
pub use crate::common::ResponsesApiRequest;
pub use crate::common::create_text_param_for_request;
pub use crate::endpoint::chat::AggregateStreamExt;
pub use crate::endpoint::chat::ChatClient;
pub use crate::endpoint::aggregate::AggregateStreamExt;
pub use crate::endpoint::compact::CompactClient;
pub use crate::endpoint::models::ModelsClient;
pub use crate::endpoint::responses::ResponsesClient;
@@ -32,10 +31,7 @@ pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient;
pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection;
pub use crate::error::ApiError;
pub use crate::provider::Provider;
pub use crate::provider::WireApi;
pub use crate::provider::is_azure_responses_wire_base_url;
pub use crate::requests::ChatRequest;
pub use crate::requests::ChatRequestBuilder;
pub use crate::requests::ResponsesRequest;
pub use crate::requests::ResponsesRequestBuilder;
pub use crate::sse::stream_from_fixture;
+4 -24
View File
@@ -8,14 +8,6 @@ use std::collections::HashMap;
use std::time::Duration;
use url::Url;
/// Wire-level APIs supported by a `Provider`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WireApi {
Responses,
Chat,
Compact,
}
/// High-level retry configuration for a provider.
///
/// This is converted into a `RetryPolicy` used by `codex-client` to drive
@@ -52,7 +44,6 @@ pub struct Provider {
pub name: String,
pub base_url: String,
pub query_params: Option<HashMap<String, String>>,
pub wire: WireApi,
pub headers: HeaderMap,
pub retry: RetryConfig,
pub stream_idle_timeout: Duration,
@@ -95,7 +86,7 @@ impl Provider {
}
pub fn is_azure_responses_endpoint(&self) -> bool {
is_azure_responses_wire_base_url(self.wire.clone(), &self.name, Some(&self.base_url))
is_azure_responses_wire_base_url(&self.name, Some(&self.base_url))
}
pub fn websocket_url_for_path(&self, path: &str) -> Result<Url, url::ParseError> {
@@ -112,11 +103,7 @@ impl Provider {
}
}
pub fn is_azure_responses_wire_base_url(wire: WireApi, name: &str, base_url: Option<&str>) -> bool {
if wire != WireApi::Responses {
return false;
}
pub fn is_azure_responses_wire_base_url(name: &str, base_url: Option<&str>) -> bool {
if name.eq_ignore_ascii_case("azure") {
return true;
}
@@ -157,13 +144,12 @@ mod tests {
for base_url in positive_cases {
assert!(
is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)),
is_azure_responses_wire_base_url("test", Some(base_url)),
"expected {base_url} to be detected as Azure"
);
}
assert!(is_azure_responses_wire_base_url(
WireApi::Responses,
"Azure",
Some("https://example.com")
));
@@ -176,15 +162,9 @@ mod tests {
for base_url in negative_cases {
assert!(
!is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)),
!is_azure_responses_wire_base_url("test", Some(base_url)),
"expected {base_url} not to be detected as Azure"
);
}
assert!(!is_azure_responses_wire_base_url(
WireApi::Chat,
"Azure",
Some("https://foo.openai.azure.com/openai")
));
}
}
-494
View File
@@ -1,494 +0,0 @@
use crate::error::ApiError;
use crate::provider::Provider;
use crate::requests::headers::build_conversation_headers;
use crate::requests::headers::insert_header;
use crate::requests::headers::subagent_header;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
use http::HeaderMap;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
/// Assembled request body plus headers for Chat Completions streaming calls.
pub struct ChatRequest {
pub body: Value,
pub headers: HeaderMap,
}
pub struct ChatRequestBuilder<'a> {
model: &'a str,
instructions: &'a str,
input: &'a [ResponseItem],
tools: &'a [Value],
conversation_id: Option<String>,
session_source: Option<SessionSource>,
}
impl<'a> ChatRequestBuilder<'a> {
pub fn new(
model: &'a str,
instructions: &'a str,
input: &'a [ResponseItem],
tools: &'a [Value],
) -> Self {
Self {
model,
instructions,
input,
tools,
conversation_id: None,
session_source: None,
}
}
pub fn conversation_id(mut self, id: Option<String>) -> Self {
self.conversation_id = id;
self
}
pub fn session_source(mut self, source: Option<SessionSource>) -> Self {
self.session_source = source;
self
}
pub fn build(self, _provider: &Provider) -> Result<ChatRequest, ApiError> {
let mut messages = Vec::<Value>::new();
messages.push(json!({"role": "system", "content": self.instructions}));
let input = self.input;
let mut reasoning_by_anchor_index: HashMap<usize, String> = HashMap::new();
let mut last_emitted_role: Option<&str> = None;
for item in input {
match item {
ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()),
ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => {
last_emitted_role = Some("assistant")
}
ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"),
ResponseItem::Reasoning { .. } | ResponseItem::Other => {}
ResponseItem::CustomToolCall { .. } => {}
ResponseItem::CustomToolCallOutput { .. } => {}
ResponseItem::WebSearchCall { .. } => {}
ResponseItem::GhostSnapshot { .. } => {}
ResponseItem::Compaction { .. } => {}
}
}
let mut last_user_index: Option<usize> = None;
for (idx, item) in input.iter().enumerate() {
if let ResponseItem::Message { role, .. } = item
&& role == "user"
{
last_user_index = Some(idx);
}
}
if !matches!(last_emitted_role, Some("user")) {
for (idx, item) in input.iter().enumerate() {
if let Some(u_idx) = last_user_index
&& idx <= u_idx
{
continue;
}
if let ResponseItem::Reasoning {
content: Some(items),
..
} = item
{
let mut text = String::new();
for entry in items {
match entry {
ReasoningItemContent::ReasoningText { text: segment }
| ReasoningItemContent::Text { text: segment } => {
text.push_str(segment)
}
}
}
if text.trim().is_empty() {
continue;
}
let mut attached = false;
if idx > 0
&& let ResponseItem::Message { role, .. } = &input[idx - 1]
&& role == "assistant"
{
reasoning_by_anchor_index
.entry(idx - 1)
.and_modify(|v| v.push_str(&text))
.or_insert(text.clone());
attached = true;
}
if !attached && idx + 1 < input.len() {
match &input[idx + 1] {
ResponseItem::FunctionCall { .. }
| ResponseItem::LocalShellCall { .. } => {
reasoning_by_anchor_index
.entry(idx + 1)
.and_modify(|v| v.push_str(&text))
.or_insert(text.clone());
}
ResponseItem::Message { role, .. } if role == "assistant" => {
reasoning_by_anchor_index
.entry(idx + 1)
.and_modify(|v| v.push_str(&text))
.or_insert(text.clone());
}
_ => {}
}
}
}
}
}
let mut last_assistant_text: Option<String> = None;
for (idx, item) in input.iter().enumerate() {
match item {
ResponseItem::Message { role, content, .. } => {
let mut text = String::new();
let mut items: Vec<Value> = Vec::new();
let mut saw_image = false;
for c in content {
match c {
ContentItem::InputText { text: t }
| ContentItem::OutputText { text: t } => {
text.push_str(t);
items.push(json!({"type":"text","text": t}));
}
ContentItem::InputImage { image_url } => {
saw_image = true;
items.push(
json!({"type":"image_url","image_url": {"url": image_url}}),
);
}
}
}
if role == "assistant" {
if let Some(prev) = &last_assistant_text
&& prev == &text
{
continue;
}
last_assistant_text = Some(text.clone());
}
let content_value = if role == "assistant" {
json!(text)
} else if saw_image {
json!(items)
} else {
json!(text)
};
let mut msg = json!({"role": role, "content": content_value});
if role == "assistant"
&& let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
}
messages.push(msg);
}
ResponseItem::FunctionCall {
name,
arguments,
call_id,
..
} => {
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
let tool_call = json!({
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments,
}
});
push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::LocalShellCall {
id,
call_id: _,
status,
action,
} => {
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
let tool_call = json!({
"id": id.clone().unwrap_or_default(),
"type": "local_shell_call",
"status": status,
"action": action,
});
push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::FunctionCallOutput { call_id, output } => {
let content_value = if let Some(items) = &output.content_items {
let mapped: Vec<Value> = items
.iter()
.map(|it| match it {
FunctionCallOutputContentItem::InputText { text } => {
json!({"type":"text","text": text})
}
FunctionCallOutputContentItem::InputImage { image_url } => {
json!({"type":"image_url","image_url": {"url": image_url}})
}
})
.collect();
json!(mapped)
} else {
json!(output.content)
};
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": content_value,
}));
}
ResponseItem::CustomToolCall {
id,
call_id: _,
name,
input,
status: _,
} => {
let tool_call = json!({
"id": id,
"type": "custom",
"custom": {
"name": name,
"input": input,
}
});
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
push_tool_call_message(&mut messages, tool_call, reasoning);
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": output,
}));
}
ResponseItem::GhostSnapshot { .. } => {
continue;
}
ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::Other
| ResponseItem::Compaction { .. } => {
continue;
}
}
}
let payload = json!({
"model": self.model,
"messages": messages,
"stream": true,
"tools": self.tools,
});
let mut headers = build_conversation_headers(self.conversation_id);
if let Some(subagent) = subagent_header(&self.session_source) {
insert_header(&mut headers, "x-openai-subagent", &subagent);
}
Ok(ChatRequest {
body: payload,
headers,
})
}
}
fn push_tool_call_message(messages: &mut Vec<Value>, tool_call: Value, reasoning: Option<&str>) {
// Chat Completions requires that tool calls are grouped into a single assistant message
// (with `tool_calls: [...]`) followed by tool role responses.
if let Some(Value::Object(obj)) = messages.last_mut()
&& obj.get("role").and_then(Value::as_str) == Some("assistant")
&& obj.get("content").is_some_and(Value::is_null)
&& let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut)
{
tool_calls.push(tool_call);
if let Some(reasoning) = reasoning {
if let Some(Value::String(existing)) = obj.get_mut("reasoning") {
if !existing.is_empty() {
existing.push('\n');
}
existing.push_str(reasoning);
} else {
obj.insert(
"reasoning".to_string(),
Value::String(reasoning.to_string()),
);
}
}
return;
}
let mut msg = json!({
"role": "assistant",
"content": null,
"tool_calls": [tool_call],
});
if let Some(reasoning) = reasoning
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
}
messages.push(msg);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use std::time::Duration;
fn provider() -> Provider {
Provider {
name: "openai".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
query_params: None,
wire: WireApi::Chat,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: Duration::from_millis(10),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: Duration::from_secs(1),
}
}
#[test]
fn attaches_conversation_and_subagent_headers() {
let prompt_input = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
phase: None,
}];
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
.conversation_id(Some("conv-1".into()))
.session_source(Some(SessionSource::SubAgent(SubAgentSource::Review)))
.build(&provider())
.expect("request");
assert_eq!(
req.headers.get("session_id"),
Some(&HeaderValue::from_static("conv-1"))
);
assert_eq!(
req.headers.get("x-openai-subagent"),
Some(&HeaderValue::from_static("review"))
);
}
#[test]
fn groups_consecutive_tool_calls_into_a_single_assistant_message() {
let prompt_input = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "read these".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"a.txt"}"#.to_string(),
call_id: "call-a".to_string(),
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"b.txt"}"#.to_string(),
call_id: "call-b".to_string(),
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"c.txt"}"#.to_string(),
call_id: "call-c".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-a".to_string(),
output: FunctionCallOutputPayload {
content: "A".to_string(),
..Default::default()
},
},
ResponseItem::FunctionCallOutput {
call_id: "call-b".to_string(),
output: FunctionCallOutputPayload {
content: "B".to_string(),
..Default::default()
},
},
ResponseItem::FunctionCallOutput {
call_id: "call-c".to_string(),
output: FunctionCallOutputPayload {
content: "C".to_string(),
..Default::default()
},
},
];
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
.build(&provider())
.expect("request");
let messages = req
.body
.get("messages")
.and_then(|v| v.as_array())
.expect("messages array");
// system + user + assistant(tool_calls=[...]) + 3 tool outputs
assert_eq!(messages.len(), 6);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[1]["role"], "user");
let tool_calls_msg = &messages[2];
assert_eq!(tool_calls_msg["role"], "assistant");
assert_eq!(tool_calls_msg["content"], serde_json::Value::Null);
let tool_calls = tool_calls_msg["tool_calls"]
.as_array()
.expect("tool_calls array");
assert_eq!(tool_calls.len(), 3);
assert_eq!(tool_calls[0]["id"], "call-a");
assert_eq!(tool_calls[1]["id"], "call-b");
assert_eq!(tool_calls[2]["id"], "call-c");
assert_eq!(messages[3]["role"], "tool");
assert_eq!(messages[3]["tool_call_id"], "call-a");
assert_eq!(messages[4]["role"], "tool");
assert_eq!(messages[4]["tool_call_id"], "call-b");
assert_eq!(messages[5]["role"], "tool");
assert_eq!(messages[5]["tool_call_id"], "call-c");
}
}
-3
View File
@@ -1,8 +1,5 @@
pub mod chat;
pub(crate) mod headers;
pub mod responses;
pub use chat::ChatRequest;
pub use chat::ChatRequestBuilder;
pub use responses::ResponsesRequest;
pub use responses::ResponsesRequestBuilder;
@@ -191,7 +191,6 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderValue;
use pretty_assertions::assert_eq;
@@ -202,7 +201,6 @@ mod tests {
name: name.to_string(),
base_url: base_url.to_string(),
query_params: None,
wire: WireApi::Responses,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
-717
View File
@@ -1,717 +0,0 @@
use crate::common::ResponseEvent;
use crate::common::ResponseStream;
use crate::error::ApiError;
use crate::telemetry::SseTelemetry;
use codex_client::StreamResponse;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::Stream;
use futures::StreamExt;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio::time::timeout;
use tracing::debug;
use tracing::trace;
pub(crate) fn spawn_chat_stream(
stream_response: StreamResponse,
idle_timeout: Duration,
telemetry: Option<Arc<dyn SseTelemetry>>,
_turn_state: Option<Arc<OnceLock<String>>>,
) -> ResponseStream {
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
tokio::spawn(async move {
process_chat_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await;
});
ResponseStream { rx_event }
}
/// Processes Server-Sent Events from the legacy Chat Completions streaming API.
///
/// The upstream protocol terminates a streaming response with a final sentinel event
/// (`data: [DONE]`). Historically, some of our test stubs have emitted `data: DONE`
/// (without brackets) instead.
///
/// `eventsource_stream` delivers these sentinels as regular events rather than signaling
/// end-of-stream. If we try to parse them as JSON, we log and skip them, then keep
/// polling for more events.
///
/// On servers that keep the HTTP connection open after emitting the sentinel (notably
/// wiremock on Windows), skipping the sentinel means we never emit `ResponseEvent::Completed`.
/// Higher-level workflows/tests that wait for completion before issuing subsequent model
/// calls will then stall, which shows up as "expected N requests, got 1" verification
/// failures in the mock server.
pub async fn process_chat_sse<S>(
stream: S,
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
idle_timeout: Duration,
telemetry: Option<std::sync::Arc<dyn SseTelemetry>>,
) where
S: Stream<Item = Result<bytes::Bytes, codex_client::TransportError>> + Unpin,
{
let mut stream = stream.eventsource();
#[derive(Default, Debug)]
struct ToolCallState {
id: Option<String>,
name: Option<String>,
arguments: String,
}
let mut tool_calls: HashMap<usize, ToolCallState> = HashMap::new();
let mut tool_call_order: Vec<usize> = Vec::new();
let mut tool_call_order_seen: HashSet<usize> = HashSet::new();
let mut tool_call_index_by_id: HashMap<String, usize> = HashMap::new();
let mut next_tool_call_index = 0usize;
let mut last_tool_call_index: Option<usize> = None;
let mut assistant_item: Option<ResponseItem> = None;
let mut reasoning_item: Option<ResponseItem> = None;
let mut completed_sent = false;
async fn flush_and_complete(
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
reasoning_item: &mut Option<ResponseItem>,
assistant_item: &mut Option<ResponseItem>,
) {
if let Some(reasoning) = reasoning_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
.await;
}
if let Some(assistant) = assistant_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
.await;
}
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
token_usage: None,
}))
.await;
}
loop {
let start = Instant::now();
let response = timeout(idle_timeout, stream.next()).await;
if let Some(t) = telemetry.as_ref() {
t.on_sse_poll(&response, start.elapsed());
}
let sse = match response {
Ok(Some(Ok(sse))) => sse,
Ok(Some(Err(e))) => {
let _ = tx_event.send(Err(ApiError::Stream(e.to_string()))).await;
return;
}
Ok(None) => {
if !completed_sent {
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
}
return;
}
Err(_) => {
let _ = tx_event
.send(Err(ApiError::Stream("idle timeout waiting for SSE".into())))
.await;
return;
}
};
trace!("SSE event: {}", sse.data);
let data = sse.data.trim();
if data.is_empty() {
continue;
}
if data == "[DONE]" || data == "DONE" {
if !completed_sent {
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
}
return;
}
let value: serde_json::Value = match serde_json::from_str(data) {
Ok(val) => val,
Err(err) => {
debug!(
"Failed to parse ChatCompletions SSE event: {err}, data: {}",
data
);
continue;
}
};
let Some(choices) = value.get("choices").and_then(|c| c.as_array()) else {
continue;
};
for choice in choices {
if let Some(delta) = choice.get("delta") {
if let Some(reasoning) = delta.get("reasoning") {
if let Some(text) = reasoning.as_str() {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string())
.await;
} else if let Some(text) = reasoning.get("text").and_then(|v| v.as_str()) {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string())
.await;
} else if let Some(text) = reasoning.get("content").and_then(|v| v.as_str()) {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string())
.await;
}
}
if let Some(content) = delta.get("content") {
if content.is_array() {
for item in content.as_array().unwrap_or(&vec![]) {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
append_assistant_text(
&tx_event,
&mut assistant_item,
text.to_string(),
)
.await;
}
}
} else if let Some(text) = content.as_str() {
append_assistant_text(&tx_event, &mut assistant_item, text.to_string())
.await;
}
}
if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) {
for tool_call in tool_call_values {
let mut index = tool_call
.get("index")
.and_then(serde_json::Value::as_u64)
.map(|i| i as usize);
let mut call_id_for_lookup = None;
if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) {
call_id_for_lookup = Some(call_id.to_string());
if let Some(existing) = tool_call_index_by_id.get(call_id) {
index = Some(*existing);
}
}
if index.is_none() && call_id_for_lookup.is_none() {
index = last_tool_call_index;
}
let index = index.unwrap_or_else(|| {
while tool_calls.contains_key(&next_tool_call_index) {
next_tool_call_index += 1;
}
let idx = next_tool_call_index;
next_tool_call_index += 1;
idx
});
let call_state = tool_calls.entry(index).or_default();
if tool_call_order_seen.insert(index) {
tool_call_order.push(index);
}
if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) {
call_state.id.get_or_insert_with(|| id.to_string());
tool_call_index_by_id.entry(id.to_string()).or_insert(index);
}
if let Some(func) = tool_call.get("function") {
if let Some(fname) = func.get("name").and_then(|n| n.as_str())
&& !fname.is_empty()
{
call_state.name.get_or_insert_with(|| fname.to_string());
}
if let Some(arguments) = func.get("arguments").and_then(|a| a.as_str())
{
call_state.arguments.push_str(arguments);
}
}
last_tool_call_index = Some(index);
}
}
}
if let Some(message) = choice.get("message")
&& let Some(reasoning) = message.get("reasoning")
{
if let Some(text) = reasoning.as_str() {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await;
} else if let Some(text) = reasoning.get("text").and_then(|v| v.as_str()) {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await;
} else if let Some(text) = reasoning.get("content").and_then(|v| v.as_str()) {
append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await;
}
}
let finish_reason = choice.get("finish_reason").and_then(|r| r.as_str());
if finish_reason == Some("stop") {
if let Some(reasoning) = reasoning_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
.await;
}
if let Some(assistant) = assistant_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
.await;
}
if !completed_sent {
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
token_usage: None,
}))
.await;
completed_sent = true;
}
continue;
}
if finish_reason == Some("length") {
let _ = tx_event.send(Err(ApiError::ContextWindowExceeded)).await;
return;
}
if finish_reason == Some("tool_calls") {
if let Some(reasoning) = reasoning_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
.await;
}
for index in tool_call_order.drain(..) {
let Some(state) = tool_calls.remove(&index) else {
continue;
};
tool_call_order_seen.remove(&index);
let ToolCallState {
id,
name,
arguments,
} = state;
let Some(name) = name else {
debug!("Skipping tool call at index {index} because name is missing");
continue;
};
let item = ResponseItem::FunctionCall {
id: None,
name,
arguments,
call_id: id.unwrap_or_else(|| format!("tool-call-{index}")),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
}
}
}
}
}
async fn append_assistant_text(
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
assistant_item: &mut Option<ResponseItem>,
text: String,
) {
if assistant_item.is_none() {
let item = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![],
end_turn: None,
phase: None,
};
*assistant_item = Some(item.clone());
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemAdded(item)))
.await;
}
if let Some(ResponseItem::Message { content, .. }) = assistant_item {
content.push(ContentItem::OutputText { text: text.clone() });
let _ = tx_event
.send(Ok(ResponseEvent::OutputTextDelta(text.clone())))
.await;
}
}
async fn append_reasoning_text(
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
reasoning_item: &mut Option<ResponseItem>,
text: String,
) {
if reasoning_item.is_none() {
let item = ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: Some(vec![]),
encrypted_content: None,
};
*reasoning_item = Some(item.clone());
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemAdded(item)))
.await;
}
if let Some(ResponseItem::Reasoning {
content: Some(content),
..
}) = reasoning_item
{
let content_index = content.len() as i64;
content.push(ReasoningItemContent::ReasoningText { text: text.clone() });
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningContentDelta {
delta: text.clone(),
content_index,
}))
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use codex_protocol::models::ResponseItem;
use futures::TryStreamExt;
use serde_json::json;
use tokio::sync::mpsc;
use tokio_util::io::ReaderStream;
fn build_body(events: &[serde_json::Value]) -> String {
let mut body = String::new();
for e in events {
body.push_str(&format!("event: message\ndata: {e}\n\n"));
}
body
}
/// Regression test: the stream should complete when we see a `[DONE]` sentinel.
///
/// This is important for tests/mocks that don't immediately close the underlying
/// connection after emitting the sentinel.
#[tokio::test]
async fn completes_on_done_sentinel_without_json() {
let events = collect_events("event: message\ndata: [DONE]\n\n").await;
assert_matches!(&events[..], [ResponseEvent::Completed { .. }]);
}
async fn collect_events(body: &str) -> Vec<ResponseEvent> {
let reader = ReaderStream::new(std::io::Cursor::new(body.to_string()))
.map_err(|err| codex_client::TransportError::Network(err.to_string()));
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent, ApiError>>(16);
tokio::spawn(process_chat_sse(
reader,
tx,
Duration::from_millis(1000),
None,
));
let mut out = Vec::new();
while let Some(ev) = rx.recv().await {
out.push(ev.expect("stream error"));
}
out
}
#[tokio::test]
async fn concatenates_tool_call_arguments_across_deltas() {
let delta_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"index": 0,
"function": { "name": "do_a" }
}]
}
}]
});
let delta_args_1 = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "{ \"foo\":" }
}]
}
}]
});
let delta_args_2 = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "1}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }),
ResponseEvent::Completed { .. }
] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}"
);
}
#[tokio::test]
async fn emits_multiple_tool_calls() {
let delta_a = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "do_a", "arguments": "{\"foo\":1}" }
}]
}
}]
});
let delta_b = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_b",
"function": { "name": "do_b", "arguments": "{\"bar\":2}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_a, delta_b, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
ResponseEvent::Completed { .. }
] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}"
);
}
#[tokio::test]
async fn emits_tool_calls_for_multiple_choices() {
let payload = json!({
"choices": [
{
"delta": {
"tool_calls": [{
"id": "call_a",
"index": 0,
"function": { "name": "do_a", "arguments": "{}" }
}]
},
"finish_reason": "tool_calls"
},
{
"delta": {
"tool_calls": [{
"id": "call_b",
"index": 0,
"function": { "name": "do_b", "arguments": "{}" }
}]
},
"finish_reason": "tool_calls"
}
]
});
let body = build_body(&[payload]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }),
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }),
ResponseEvent::Completed { .. }
] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}"
);
}
#[tokio::test]
async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() {
let delta_with_id = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_a",
"function": { "name": "do_a", "arguments": "{ \"foo\":" }
}]
}
}]
});
let delta_without_id = json!({
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "1}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_with_id, delta_without_id, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }),
ResponseEvent::Completed { .. }
] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}"
);
}
#[tokio::test]
async fn preserves_tool_call_name_when_empty_deltas_arrive() {
let delta_with_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "do_a" }
}]
}
}]
});
let delta_with_empty_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "", "arguments": "{}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_with_name, delta_with_empty_name, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, arguments, .. }),
ResponseEvent::Completed { .. }
] if name == "do_a" && arguments == "{}"
);
}
#[tokio::test]
async fn emits_tool_calls_even_when_content_and_reasoning_present() {
let delta_content_and_tools = json!({
"choices": [{
"delta": {
"content": [{"text": "hi"}],
"reasoning": "because",
"tool_calls": [{
"id": "call_a",
"function": { "name": "do_a", "arguments": "{}" }
}]
}
}]
});
let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});
let body = build_body(&[delta_content_and_tools, finish]);
let events = collect_events(&body).await;
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }),
ResponseEvent::ReasoningContentDelta { .. },
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }),
ResponseEvent::OutputTextDelta(delta),
ResponseEvent::OutputItemDone(ResponseItem::Reasoning { .. }),
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, .. }),
ResponseEvent::OutputItemDone(ResponseItem::Message { .. }),
ResponseEvent::Completed { .. }
] if delta == "hi" && call_id == "call_a" && name == "do_a"
);
}
#[tokio::test]
async fn drops_partial_tool_calls_on_stop_finish_reason() {
let delta_tool = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "do_a", "arguments": "{}" }
}]
}
}]
});
let finish_stop = json!({
"choices": [{
"finish_reason": "stop"
}]
});
let body = build_body(&[delta_tool, finish_stop]);
let events = collect_events(&body).await;
assert!(!events.iter().any(|ev| {
matches!(
ev,
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { .. })
)
}));
assert_matches!(events.last(), Some(ResponseEvent::Completed { .. }));
}
}
-1
View File
@@ -1,4 +1,3 @@
pub mod chat;
pub mod responses;
pub use responses::process_sse;
+5 -52
View File
@@ -6,11 +6,9 @@ use anyhow::Result;
use async_trait::async_trait;
use bytes::Bytes;
use codex_api::AuthProvider;
use codex_api::ChatClient;
use codex_api::Provider;
use codex_api::ResponsesClient;
use codex_api::ResponsesOptions;
use codex_api::WireApi;
use codex_api::requests::responses::Compression;
use codex_client::HttpTransport;
use codex_client::Request;
@@ -119,12 +117,11 @@ impl AuthProvider for StaticAuth {
}
}
fn provider(name: &str, wire: WireApi) -> Provider {
fn provider(name: &str) -> Provider {
Provider {
name: name.to_string(),
base_url: "https://example.com/v1".to_string(),
query_params: None,
wire,
headers: HeaderMap::new(),
retry: codex_api::provider::RetryConfig {
max_attempts: 1,
@@ -196,38 +193,10 @@ data: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{
}
#[tokio::test]
async fn chat_client_uses_chat_completions_path_for_chat_wire() -> Result<()> {
async fn responses_client_uses_responses_path() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ChatClient::new(transport, provider("openai", WireApi::Chat), NoAuth);
let body = serde_json::json!({ "echo": true });
let _stream = client.stream(body, HeaderMap::new()).await?;
let requests = state.take_stream_requests();
assert_path_ends_with(&requests, "/chat/completions");
Ok(())
}
#[tokio::test]
async fn chat_client_uses_responses_path_for_responses_wire() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ChatClient::new(transport, provider("openai", WireApi::Responses), NoAuth);
let body = serde_json::json!({ "echo": true });
let _stream = client.stream(body, HeaderMap::new()).await?;
let requests = state.take_stream_requests();
assert_path_ends_with(&requests, "/responses");
Ok(())
}
#[tokio::test]
async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth);
let client = ResponsesClient::new(transport, provider("openai"), NoAuth);
let body = serde_json::json!({ "echo": true });
let _stream = client
@@ -239,28 +208,12 @@ async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()>
Ok(())
}
#[tokio::test]
async fn responses_client_uses_chat_path_for_chat_wire() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("openai", WireApi::Chat), NoAuth);
let body = serde_json::json!({ "echo": true });
let _stream = client
.stream(body, HeaderMap::new(), Compression::None, None)
.await?;
let requests = state.take_stream_requests();
assert_path_ends_with(&requests, "/chat/completions");
Ok(())
}
#[tokio::test]
async fn streaming_client_adds_auth_headers() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let auth = StaticAuth::new("secret-token", "acct-1");
let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), auth);
let client = ResponsesClient::new(transport, provider("openai"), auth);
let body = serde_json::json!({ "model": "gpt-test" });
let _stream = client
@@ -295,7 +248,7 @@ async fn streaming_client_adds_auth_headers() -> Result<()> {
async fn streaming_client_retries_on_transport_error() -> Result<()> {
let transport = FlakyTransport::new();
let mut provider = provider("openai", WireApi::Responses);
let mut provider = provider("openai");
provider.retry.max_attempts = 2;
let client = ResponsesClient::new(transport.clone(), provider, NoAuth);
@@ -2,7 +2,6 @@ use codex_api::AuthProvider;
use codex_api::ModelsClient;
use codex_api::provider::Provider;
use codex_api::provider::RetryConfig;
use codex_api::provider::WireApi;
use codex_client::ReqwestTransport;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
@@ -34,7 +33,6 @@ fn provider(base_url: &str) -> Provider {
name: "test".to_string(),
base_url: base_url.to_string(),
query_params: None,
wire: WireApi::Responses,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
+3 -5
View File
@@ -8,7 +8,6 @@ use codex_api::AuthProvider;
use codex_api::Provider;
use codex_api::ResponseEvent;
use codex_api::ResponsesClient;
use codex_api::WireApi;
use codex_api::requests::responses::Compression;
use codex_client::HttpTransport;
use codex_client::Request;
@@ -61,12 +60,11 @@ impl AuthProvider for NoAuth {
}
}
fn provider(name: &str, wire: WireApi) -> Provider {
fn provider(name: &str) -> Provider {
Provider {
name: name.to_string(),
base_url: "https://example.com/v1".to_string(),
query_params: None,
wire,
headers: HeaderMap::new(),
retry: codex_api::provider::RetryConfig {
max_attempts: 1,
@@ -122,7 +120,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()>
let body = build_responses_body(vec![item1, item2, completed]);
let transport = FixtureSseTransport::new(body);
let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth);
let client = ResponsesClient::new(transport, provider("openai"), NoAuth);
let mut stream = client
.stream(
@@ -192,7 +190,7 @@ async fn responses_stream_aggregates_output_text_deltas() -> Result<()> {
let body = build_responses_body(vec![delta1, delta2, completed]);
let transport = FixtureSseTransport::new(body);
let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth);
let client = ResponsesClient::new(transport, provider("openai"), NoAuth);
let stream = client
.stream(
+3 -36
View File
@@ -1,52 +1,18 @@
//! OSS provider utilities shared between TUI and exec.
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
use codex_core::OLLAMA_CHAT_PROVIDER_ID;
use codex_core::OLLAMA_OSS_PROVIDER_ID;
use codex_core::WireApi;
use codex_core::config::Config;
use codex_core::protocol::DeprecationNoticeEvent;
use std::io;
/// Returns the default model for a given OSS provider.
pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> {
match provider_id {
LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL),
OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
_ => None,
}
}
/// Returns a deprecation notice if Ollama doesn't support the responses wire API.
pub async fn ollama_chat_deprecation_notice(
config: &Config,
) -> io::Result<Option<DeprecationNoticeEvent>> {
if config.model_provider_id != OLLAMA_OSS_PROVIDER_ID
|| config.model_provider.wire_api != WireApi::Responses
{
return Ok(None);
}
if let Some(detection) = codex_ollama::detect_wire_api(&config.model_provider).await?
&& detection.wire_api == WireApi::Chat
{
let version_suffix = detection
.version
.as_ref()
.map(|version| format!(" (version {version})"))
.unwrap_or_default();
let summary = format!(
"Your Ollama server{version_suffix} doesn't support the Responses API. Either update Ollama or set `oss_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"` (or `model_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"`) in your config.toml to use the \"chat\" wire API. Support for the \"chat\" wire API is deprecated and will soon be removed."
);
return Ok(Some(DeprecationNoticeEvent {
summary,
details: None,
}));
}
Ok(None)
}
/// Ensures the specified OSS provider is ready (models downloaded, service reachable).
pub async fn ensure_oss_provider_ready(
provider_id: &str,
@@ -58,7 +24,8 @@ pub async fn ensure_oss_provider_ready(
.await
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
}
OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => {
OLLAMA_OSS_PROVIDER_ID => {
codex_ollama::ensure_responses_supported(&config.model_provider).await?;
codex_ollama::ensure_oss_ready(config)
.await
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
+3 -10
View File
@@ -464,7 +464,7 @@
"$ref": "#/definitions/WireApi"
}
],
"default": "chat",
"default": "responses",
"description": "Which wire protocol this provider expects."
}
},
@@ -1087,7 +1087,7 @@
"type": "string"
},
"WireApi": {
"description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.",
"description": "Wire protocol that the provider speaks.",
"oneOf": [
{
"description": "The Responses API exposed by OpenAI at `/v1/responses`.",
@@ -1095,13 +1095,6 @@
"responses"
],
"type": "string"
},
{
"description": "Regular Chat Completions compatible with `/v1/chat/completions`.",
"enum": [
"chat"
],
"type": "string"
}
]
}
@@ -1423,7 +1416,7 @@
"type": "array"
},
"oss_provider": {
"description": "Preferred OSS provider for local models, e.g. \"lmstudio\", \"ollama\", or \"ollama-chat\".",
"description": "Preferred OSS provider for local models, e.g. \"lmstudio\" or \"ollama\".",
"type": "string"
},
"otel": {
+18 -8
View File
@@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
status,
body: message,
url: None,
cf_ray: None,
request_id: None,
}),
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
@@ -89,13 +90,14 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: extract_request_id(headers.as_ref()),
request_id: extract_request_tracking_id(headers.as_ref()),
})
} else {
CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body: body_text,
url,
cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER),
request_id: extract_request_id(headers.as_ref()),
})
}
@@ -115,6 +117,9 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model";
const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds";
const REQUEST_ID_HEADER: &str = "x-request-id";
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
const CF_RAY_HEADER: &str = "cf-ray";
#[cfg(test)]
mod tests {
@@ -149,15 +154,20 @@ mod tests {
}
}
fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
}
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
extract_header(headers, REQUEST_ID_HEADER)
.or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER))
}
fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option<String> {
headers.and_then(|map| {
["cf-ray", "x-request-id", "x-oai-request-id"]
.iter()
.find_map(|name| {
map.get(*name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
})
map.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
})
}
+12 -102
View File
@@ -8,8 +8,6 @@ use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::map_api_error;
use crate::auth::UnauthorizedRecovery;
use crate::turn_metadata::build_turn_metadata_header;
use codex_api::AggregateStreamExt;
use codex_api::ChatClient as ApiChatClient;
use codex_api::CompactClient as ApiCompactClient;
use codex_api::CompactionInput as ApiCompactionInput;
use codex_api::Prompt as ApiPrompt;
@@ -17,7 +15,6 @@ use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::ResponseAppendWsRequest;
use codex_api::ResponseCreateWsRequest;
use codex_api::ResponseStream as ApiResponseStream;
use codex_api::ResponsesClient as ApiResponsesClient;
use codex_api::ResponsesOptions as ApiResponsesOptions;
use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
@@ -69,7 +66,6 @@ use crate::features::Feature;
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_chat_completions_api;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::transport_manager::TransportManager;
@@ -310,11 +306,7 @@ impl ModelClientSession {
.and_then(|cache| cache.header.clone())
}
/// Streams a single model turn using either the Responses or Chat
/// Completions wire API, depending on the configured provider.
///
/// For Chat providers, the underlying stream is optionally aggregated
/// based on the `show_raw_agent_reasoning` flag in the config.
/// Streams a single model turn using the configured Responses transport.
pub async fn stream(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
let wire_api = self.state.provider.wire_api;
match wire_api {
@@ -328,21 +320,6 @@ impl ModelClientSession {
self.stream_responses_api(prompt).await
}
}
WireApi::Chat => {
let api_stream = self.stream_chat_completions(prompt).await?;
if self.state.config.show_raw_agent_reasoning {
Ok(map_response_stream(
api_stream.streaming_mode(),
self.state.otel_manager.clone(),
))
} else {
Ok(map_response_stream(
api_stream.aggregate(),
self.state.otel_manager.clone(),
))
}
}
}
}
@@ -544,64 +521,6 @@ impl ModelClientSession {
}
}
/// Streams a turn via the OpenAI Chat Completions API.
///
/// This path is only used when the provider is configured with
/// `WireApi::Chat`; it does not support `output_schema` today.
async fn stream_chat_completions(&self, prompt: &Prompt) -> Result<ApiResponseStream> {
if prompt.output_schema.is_some() {
return Err(CodexErr::UnsupportedOperation(
"output_schema is not supported for Chat Completions API".to_string(),
));
}
let auth_manager = self.state.auth_manager.clone();
let instructions = prompt.base_instructions.text.clone();
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
let api_prompt = build_api_prompt(prompt, instructions, tools_json);
let conversation_id = self.state.conversation_id.to_string();
let session_source = self.state.session_source.clone();
let mut auth_recovery = auth_manager
.as_ref()
.map(super::auth::AuthManager::unauthorized_recovery);
loop {
let auth = match auth_manager.as_ref() {
Some(manager) => manager.auth().await,
None => None,
};
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
let client = ApiChatClient::new(transport, api_provider, api_auth)
.with_telemetry(Some(request_telemetry), Some(sse_telemetry));
let stream_result = client
.stream_prompt(
&self.state.model_info.slug,
&api_prompt,
Some(conversation_id.clone()),
Some(session_source.clone()),
)
.await;
match stream_result {
Ok(stream) => return Ok(stream),
Err(ApiError::Transport(TransportError::Http { status, .. }))
if status == StatusCode::UNAUTHORIZED =>
{
handle_unauthorized(status, &mut auth_recovery).await?;
continue;
}
Err(err) => return Err(map_api_error(err)),
}
}
}
/// Streams a turn via the OpenAI Responses API.
///
/// Handles SSE fixtures, reasoning summaries, verbosity, and the
@@ -648,10 +567,10 @@ impl ModelClientSession {
Ok(stream) => {
return Ok(map_response_stream(stream, self.state.otel_manager.clone()));
}
Err(ApiError::Transport(TransportError::Http { status, .. }))
if status == StatusCode::UNAUTHORIZED =>
{
handle_unauthorized(status, &mut auth_recovery).await?;
Err(ApiError::Transport(
unauthorized_transport @ TransportError::Http { status, .. },
)) if status == StatusCode::UNAUTHORIZED => {
handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?;
continue;
}
Err(err) => return Err(map_api_error(err)),
@@ -687,10 +606,10 @@ impl ModelClientSession {
.await
{
Ok(connection) => connection,
Err(ApiError::Transport(TransportError::Http { status, .. }))
if status == StatusCode::UNAUTHORIZED =>
{
handle_unauthorized(status, &mut auth_recovery).await?;
Err(ApiError::Transport(
unauthorized_transport @ TransportError::Http { status, .. },
)) if status == StatusCode::UNAUTHORIZED => {
handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?;
continue;
}
Err(err) => return Err(map_api_error(err)),
@@ -709,7 +628,7 @@ impl ModelClientSession {
}
}
/// Builds request and SSE telemetry for streaming API calls (Chat/Responses).
/// Builds request and SSE telemetry for streaming API calls.
fn build_streaming_telemetry(&self) -> (Arc<dyn RequestTelemetry>, Arc<dyn SseTelemetry>) {
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry.clone();
@@ -861,7 +780,7 @@ where
/// When refresh succeeds, the caller should retry the API call; otherwise
/// the mapped `CodexErr` is returned to the caller.
async fn handle_unauthorized(
status: StatusCode,
transport: TransportError,
auth_recovery: &mut Option<UnauthorizedRecovery>,
) -> Result<()> {
if let Some(recovery) = auth_recovery
@@ -874,16 +793,7 @@ async fn handle_unauthorized(
};
}
Err(map_unauthorized_status(status))
}
fn map_unauthorized_status(status: StatusCode) -> CodexErr {
map_api_error(ApiError::Transport(TransportError::Http {
status,
url: None,
headers: None,
body: None,
}))
Err(map_api_error(ApiError::Transport(transport)))
}
struct ApiTelemetry {
-30
View File
@@ -3,9 +3,7 @@ use std::collections::HashSet;
use std::fmt::Debug;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use crate::AuthManager;
use crate::CodexAuth;
@@ -96,7 +94,6 @@ use tracing::trace_span;
use tracing::warn;
use crate::ModelProviderInfo;
use crate::WireApi;
use crate::client::ModelClient;
use crate::client::ModelClientSession;
use crate::client_common::Prompt;
@@ -130,7 +127,6 @@ use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_paths;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
use crate::project_doc::get_user_instructions;
use crate::proposed_plan_parser::ProposedPlanParser;
use crate::proposed_plan_parser::ProposedPlanSegment;
@@ -243,31 +239,6 @@ pub struct CodexSpawnOk {
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64;
static CHAT_WIRE_API_DEPRECATION_EMITTED: AtomicBool = AtomicBool::new(false);
fn maybe_push_chat_wire_api_deprecation(
config: &Config,
post_session_configured_events: &mut Vec<Event>,
) {
if config.model_provider.wire_api != WireApi::Chat {
return;
}
if CHAT_WIRE_API_DEPRECATION_EMITTED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent {
summary: CHAT_WIRE_API_DEPRECATION_SUMMARY.to_string(),
details: None,
}),
});
}
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
@@ -865,7 +836,6 @@ impl Session {
}),
});
}
maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events);
maybe_push_unstable_features_warning(&config, &mut post_session_configured_events);
let auth = auth.as_ref();
+73 -23
View File
@@ -32,9 +32,10 @@ use crate::features::FeatureOverrides;
use crate::features::Features;
use crate::features::FeaturesToml;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_ID;
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use crate::model_provider_info::built_in_model_providers;
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
@@ -757,14 +758,20 @@ pub fn set_project_trust_level(
pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> {
// Validate that the provider is one of the known OSS providers
match provider {
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => {
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => {
// Valid provider, continue
}
LEGACY_OLLAMA_CHAT_PROVIDER_ID => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
OLLAMA_CHAT_PROVIDER_REMOVED_ERROR,
));
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID}"
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
),
));
}
@@ -985,7 +992,7 @@ pub struct ConfigToml {
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
/// Preferred OSS provider for local models, e.g. "lmstudio", "ollama", or "ollama-chat".
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
pub oss_provider: Option<String>,
}
@@ -1411,10 +1418,12 @@ impl Config {
let model_provider = model_providers
.get(&model_provider_id)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Model provider `{model_provider_id}` not found"),
)
let message = if model_provider_id == LEGACY_OLLAMA_CHAT_PROVIDER_ID {
OLLAMA_CHAT_PROVIDER_REMOVED_ERROR.to_string()
} else {
format!("Model provider `{model_provider_id}` not found")
};
std::io::Error::new(std::io::ErrorKind::NotFound, message)
})?
.clone();
@@ -3576,7 +3585,7 @@ model = "gpt-5.1-codex"
cfg: ConfigToml,
model_provider_map: HashMap<String, ModelProviderInfo>,
openai_provider: ModelProviderInfo,
openai_chat_completions_provider: ModelProviderInfo,
openai_custom_provider: ModelProviderInfo,
}
impl PrecedenceTestFixture {
@@ -3658,11 +3667,11 @@ profile = "gpt3"
[analytics]
enabled = true
[model_providers.openai-chat-completions]
name = "OpenAI using Chat Completions"
[model_providers.openai-custom]
name = "OpenAI custom"
base_url = "https://api.openai.com/v1"
env_key = "OPENAI_API_KEY"
wire_api = "chat"
wire_api = "responses"
request_max_retries = 4 # retry failed HTTP requests
stream_max_retries = 10 # retry dropped SSE streams
stream_idle_timeout_ms = 300000 # 5m idle timeout
@@ -3676,7 +3685,7 @@ model_reasoning_summary = "detailed"
[profiles.gpt3]
model = "gpt-3.5-turbo"
model_provider = "openai-chat-completions"
model_provider = "openai-custom"
[profiles.zdr]
model = "o3"
@@ -3707,11 +3716,11 @@ model_verbosity = "high"
let codex_home_temp_dir = TempDir::new().unwrap();
let openai_chat_completions_provider = ModelProviderInfo {
name: "OpenAI using Chat Completions".to_string(),
let openai_custom_provider = ModelProviderInfo {
name: "OpenAI custom".to_string(),
base_url: Some("https://api.openai.com/v1".to_string()),
env_key: Some("OPENAI_API_KEY".to_string()),
wire_api: crate::WireApi::Chat,
wire_api: crate::WireApi::Responses,
env_key_instructions: None,
experimental_bearer_token: None,
query_params: None,
@@ -3725,10 +3734,7 @@ model_verbosity = "high"
};
let model_provider_map = {
let mut model_provider_map = built_in_model_providers();
model_provider_map.insert(
"openai-chat-completions".to_string(),
openai_chat_completions_provider.clone(),
);
model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone());
model_provider_map
};
@@ -3743,7 +3749,7 @@ model_verbosity = "high"
cfg,
model_provider_map,
openai_provider,
openai_chat_completions_provider,
openai_custom_provider,
})
}
@@ -3864,8 +3870,8 @@ model_verbosity = "high"
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_provider_id: "openai-chat-completions".to_string(),
model_provider: fixture.openai_chat_completions_provider.clone(),
model_provider_id: "openai-custom".to_string(),
model_provider: fixture.openai_custom_provider.clone(),
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
enforce_residency: Constrained::allow_any(None),
@@ -4266,6 +4272,50 @@ trust_level = "trusted"
Ok(())
}
#[test]
fn test_set_default_oss_provider_rejects_legacy_ollama_chat_provider() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path();
let result = set_default_oss_provider(codex_home, LEGACY_OLLAMA_CHAT_PROVIDER_ID);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(
error
.to_string()
.contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR)
);
Ok(())
}
#[test]
fn test_load_config_rejects_legacy_ollama_chat_provider_with_helpful_error()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
model_provider: Some(LEGACY_OLLAMA_CHAT_PROVIDER_ID.to_string()),
..Default::default()
};
let result = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(
error
.to_string()
.contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR)
);
Ok(())
}
#[test]
fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> {
let config_with_untrusted = r#"
+112 -5
View File
@@ -286,13 +286,42 @@ pub struct UnexpectedResponseError {
pub status: StatusCode,
pub body: String,
pub url: Option<String>,
pub cf_ray: Option<String>,
pub request_id: Option<String>,
}
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000;
impl UnexpectedResponseError {
fn display_body(&self) -> String {
if let Some(message) = self.extract_error_message() {
return message;
}
let trimmed_body = self.body.trim();
if trimmed_body.is_empty() {
return "Unknown error".to_string();
}
truncate_with_ellipsis(trimmed_body, UNEXPECTED_RESPONSE_BODY_MAX_BYTES)
}
fn extract_error_message(&self) -> Option<String> {
let json = serde_json::from_str::<serde_json::Value>(&self.body).ok()?;
let message = json
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)?;
let message = message.trim();
if message.is_empty() {
None
} else {
Some(message.to_string())
}
}
fn friendly_message(&self) -> Option<String> {
if self.status != StatusCode::FORBIDDEN {
return None;
@@ -307,6 +336,9 @@ impl UnexpectedResponseError {
if let Some(url) = &self.url {
message.push_str(&format!(", url: {url}"));
}
if let Some(cf_ray) = &self.cf_ray {
message.push_str(&format!(", cf-ray: {cf_ray}"));
}
if let Some(id) = &self.request_id {
message.push_str(&format!(", request id: {id}"));
}
@@ -321,11 +353,14 @@ impl std::fmt::Display for UnexpectedResponseError {
write!(f, "{friendly}")
} else {
let status = self.status;
let body = &self.body;
let body = self.display_body();
let mut message = format!("unexpected status {status}: {body}");
if let Some(url) = &self.url {
message.push_str(&format!(", url: {url}"));
}
if let Some(cf_ray) = &self.cf_ray {
message.push_str(&format!(", cf-ray: {cf_ray}"));
}
if let Some(id) = &self.request_id {
message.push_str(&format!(", request id: {id}"));
}
@@ -335,6 +370,21 @@ impl std::fmt::Display for UnexpectedResponseError {
}
impl std::error::Error for UnexpectedResponseError {}
fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut cut = max_bytes;
while !text.is_char_boundary(cut) {
cut = cut.saturating_sub(1);
}
let mut truncated = text[..cut].to_string();
truncated.push_str("...");
truncated
}
#[derive(Debug)]
pub struct RetryLimitReachedError {
pub status: StatusCode,
@@ -952,15 +1002,14 @@ mod tests {
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
.to_string(),
url: Some("http://example.com/blocked".to_string()),
request_id: Some("ray-id".to_string()),
cf_ray: Some("ray-id".to_string()),
request_id: None,
};
let status = StatusCode::FORBIDDEN.to_string();
let url = "http://example.com/blocked";
assert_eq!(
err.to_string(),
format!(
"{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, request id: ray-id"
)
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id")
);
}
@@ -970,6 +1019,7 @@ mod tests {
status: StatusCode::FORBIDDEN,
body: "plain text error".to_string(),
url: Some("http://example.com/plain".to_string()),
cf_ray: None,
request_id: None,
};
let status = StatusCode::FORBIDDEN.to_string();
@@ -980,6 +1030,63 @@ mod tests {
);
}
#[test]
fn unexpected_status_prefers_error_message_when_present() {
let err = UnexpectedResponseError {
status: StatusCode::UNAUTHORIZED,
body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"#
.to_string(),
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
cf_ray: None,
request_id: Some("req-123".to_string()),
};
let status = StatusCode::UNAUTHORIZED.to_string();
assert_eq!(
err.to_string(),
format!(
"unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123"
)
);
}
#[test]
fn unexpected_status_truncates_long_body_with_ellipsis() {
let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10);
let err = UnexpectedResponseError {
status: StatusCode::BAD_GATEWAY,
body: long_body,
url: Some("http://example.com/long".to_string()),
cf_ray: None,
request_id: Some("req-long".to_string()),
};
let status = StatusCode::BAD_GATEWAY.to_string();
let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES));
assert_eq!(
err.to_string(),
format!(
"unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long"
)
);
}
#[test]
fn unexpected_status_includes_cf_ray_and_request_id() {
let err = UnexpectedResponseError {
status: StatusCode::UNAUTHORIZED,
body: "plain text error".to_string(),
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()),
request_id: Some("req-xyz".to_string()),
};
let status = StatusCode::UNAUTHORIZED.to_string();
assert_eq!(
err.to_string(),
format!(
"unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz"
)
);
}
#[test]
fn usage_limit_reached_includes_hours_and_minutes() {
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
+1 -1
View File
@@ -358,7 +358,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
}
fn web_search_details() -> &'static str {
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml."
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml."
}
/// Keys accepted in `[features]` tables.
-2
View File
@@ -61,12 +61,10 @@ pub mod token_data;
mod truncate;
mod unified_exec;
pub mod windows_sandbox;
pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::OLLAMA_CHAT_PROVIDER_ID;
pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
+37 -31
View File
@@ -8,7 +8,6 @@
use crate::auth::AuthMode;
use crate::error::EnvVarError;
use codex_api::Provider as ApiProvider;
use codex_api::WireApi as ApiWireApi;
use codex_api::is_azure_responses_wire_base_url;
use codex_api::provider::RetryConfig as ApiRetryConfig;
use http::HeaderMap;
@@ -28,25 +27,33 @@ const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
const MAX_STREAM_MAX_RETRIES: u64 = 100;
/// Hard cap for user-configured `request_max_retries`.
const MAX_REQUEST_MAX_RETRIES: u64 = 100;
pub const CHAT_WIRE_API_DEPRECATION_SUMMARY: &str = r#"Support for the "chat" wire API is deprecated and will soon be removed. Update your model provider definition in config.toml to use wire_api = "responses"."#;
const OPENAI_PROVIDER_NAME: &str = "OpenAI";
const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782";
pub(crate) const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
pub(crate) const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782";
/// Wire protocol that the provider speaks. Most third-party services only
/// implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI
/// itself (and a handful of others) additionally expose the more modern
/// *Responses* API. The two protocols use different request/response shapes
/// and *cannot* be auto-detected at runtime, therefore each provider entry
/// must declare which one it expects.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
/// Wire protocol that the provider speaks.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
/// The Responses API exposed by OpenAI at `/v1/responses`.
Responses,
/// Regular Chat Completions compatible with `/v1/chat/completions`.
#[default]
Chat,
Responses,
}
impl<'de> Deserialize<'de> for WireApi {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
match value.as_str() {
"responses" => Ok(Self::Responses),
"chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)),
_ => Err(serde::de::Error::unknown_variant(&value, &["responses"])),
}
}
}
/// Serializable representation of a provider definition.
@@ -161,10 +168,6 @@ impl ModelProviderInfo {
name: self.name.clone(),
base_url,
query_params: self.query_params.clone(),
wire: match self.wire_api {
WireApi::Responses => ApiWireApi::Responses,
WireApi::Chat => ApiWireApi::Chat,
},
headers,
retry,
stream_idle_timeout: self.stream_idle_timeout(),
@@ -172,12 +175,7 @@ impl ModelProviderInfo {
}
pub(crate) fn is_azure_responses_endpoint(&self) -> bool {
let wire = match self.wire_api {
WireApi::Responses => ApiWireApi::Responses,
WireApi::Chat => ApiWireApi::Chat,
};
is_azure_responses_wire_base_url(wire, &self.name, self.base_url.as_deref())
is_azure_responses_wire_base_url(&self.name, self.base_url.as_deref())
}
/// If `env_key` is Some, returns the API key for this provider if present
@@ -277,7 +275,6 @@ pub const DEFAULT_OLLAMA_PORT: u16 = 11434;
pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";
pub const OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
/// Built-in default provider list.
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
@@ -293,10 +290,6 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
OLLAMA_OSS_PROVIDER_ID,
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses),
),
(
OLLAMA_CHAT_PROVIDER_ID,
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat),
),
(
LMSTUDIO_OSS_PROVIDER_ID,
create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses),
@@ -363,7 +356,7 @@ base_url = "http://localhost:11434/v1"
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Chat,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
@@ -392,7 +385,7 @@ query_params = { api-version = "2025-04-01-preview" }
env_key: Some("AZURE_OPENAI_API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Chat,
wire_api: WireApi::Responses,
query_params: Some(maplit::hashmap! {
"api-version".to_string() => "2025-04-01-preview".to_string(),
}),
@@ -424,7 +417,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
env_key: Some("API_KEY".into()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Chat,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(maplit::hashmap! {
"X-Example-Header".to_string() => "example-value".to_string(),
@@ -442,4 +435,17 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
assert_eq!(expected_provider, provider);
}
#[test]
fn test_deserialize_chat_wire_api_shows_helpful_error() {
let provider_toml = r#"
name = "OpenAI using Chat Completions"
base_url = "https://api.openai.com/v1"
env_key = "OPENAI_API_KEY"
wire_api = "chat"
"#;
let err = toml::from_str::<ModelProviderInfo>(provider_toml).unwrap_err();
assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR));
}
}
-57
View File
@@ -1048,42 +1048,6 @@ pub fn create_tools_json_for_responses_api(
Ok(tools_json)
}
/// Returns JSON values that are compatible with Function Calling in the
/// Chat Completions API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
pub(crate) fn create_tools_json_for_chat_completions_api(
tools: &[ToolSpec],
) -> crate::error::Result<Vec<serde_json::Value>> {
// We start with the JSON for the Responses API and than rewrite it to match
// the chat completions tool call format.
let responses_api_tools_json = create_tools_json_for_responses_api(tools)?;
let tools_json = responses_api_tools_json
.into_iter()
.filter_map(|mut tool| {
if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) {
return None;
}
if let Some(map) = tool.as_object_mut() {
let name = map
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Remove "type" field as it is not needed in chat completions.
map.remove("type");
Some(json!({
"type": "function",
"name": name,
"function": map,
}))
} else {
None
}
})
.collect::<Vec<serde_json::Value>>();
Ok(tools_json)
}
pub(crate) fn mcp_tool_to_openai_tool(
fully_qualified_name: String,
@@ -2578,26 +2542,5 @@ Examples of valid command strings:
},
})]
);
let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap();
assert_eq!(
tools_json,
vec![json!({
"type": "function",
"name": "demo",
"function": {
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
}
})]
);
}
}
@@ -1,338 +0,0 @@
#![allow(clippy::expect_used)]
use std::sync::Arc;
use codex_app_server_protocol::AuthMode;
use codex_core::ContentItem;
use codex_core::LocalShellAction;
use codex_core::LocalShellExecAction;
use codex_core::LocalShellStatus;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
use codex_core::Prompt;
use codex_core::ResponseItem;
use codex_core::TransportManager;
use codex_core::WireApi;
use codex_core::models_manager::manager::ModelsManager;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::protocol::SessionSource;
use core_test_support::load_default_config_for_test;
use core_test_support::skip_if_no_network;
use futures::StreamExt;
use serde_json::Value;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
async fn run_request(input: Vec<ResponseItem>) -> Value {
let server = MockServer::start().await;
let template = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
"data: {\"choices\":[{\"delta\":{}}]}\n\ndata: [DONE]\n\n",
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(template)
.expect(1)
.mount(&server)
.await;
let provider = ModelProviderInfo {
name: "mock".into(),
base_url: Some(format!("{}/v1", server.uri())),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Chat,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
requires_openai_auth: false,
supports_websockets: false,
};
let codex_home = match TempDir::new() {
Ok(dir) => dir,
Err(e) => panic!("failed to create TempDir: {e}"),
};
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider_id = provider.name.clone();
config.model_provider = provider.clone();
config.show_raw_agent_reasoning = true;
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let model = ModelsManager::get_model_offline(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config);
let otel_manager = OtelManager::new(
conversation_id,
model.as_str(),
model_info.slug.as_str(),
None,
Some("test@test.com".to_string()),
Some(AuthMode::ApiKey),
false,
"test".to_string(),
SessionSource::Exec,
);
let mut client_session = ModelClient::new(
Arc::clone(&config),
None,
model_info,
otel_manager,
provider,
effort,
summary,
conversation_id,
SessionSource::Exec,
TransportManager::new(),
)
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = input;
let mut stream = match client_session.stream(&prompt).await {
Ok(s) => s,
Err(e) => panic!("stream chat failed: {e}"),
};
while let Some(event) = stream.next().await {
if let Err(e) = event {
panic!("stream event error: {e}");
}
}
let all_requests = server.received_requests().await.expect("received requests");
let requests: Vec<_> = all_requests
.iter()
.filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions"))
.collect();
let request = requests
.first()
.unwrap_or_else(|| panic!("expected POST request to /chat/completions"));
match request.body_json() {
Ok(v) => v,
Err(e) => panic!("invalid json body: {e}"),
}
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn assistant_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn reasoning_item(text: &str) -> ResponseItem {
ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: Some(vec![ReasoningItemContent::ReasoningText {
text: text.to_string(),
}]),
encrypted_content: None,
}
}
fn function_call() -> ResponseItem {
ResponseItem::FunctionCall {
id: None,
name: "f".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
}
}
fn local_shell_call() -> ResponseItem {
ResponseItem::LocalShellCall {
id: Some("id1".to_string()),
call_id: None,
status: LocalShellStatus::InProgress,
action: LocalShellAction::Exec(LocalShellExecAction {
command: vec!["echo".to_string()],
timeout_ms: Some(1_000),
working_directory: None,
env: None,
user: None,
}),
}
}
fn messages_from(body: &Value) -> Vec<Value> {
match body["messages"].as_array() {
Some(arr) => arr.clone(),
None => panic!("messages array missing"),
}
}
fn first_assistant(messages: &[Value]) -> &Value {
match messages.iter().find(|msg| msg["role"] == "assistant") {
Some(v) => v,
None => panic!("assistant message not present"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn omits_reasoning_when_none_present() {
skip_if_no_network!();
let body = run_request(vec![user_message("u1"), assistant_message("a1")]).await;
let messages = messages_from(&body);
let assistant = first_assistant(&messages);
assert_eq!(assistant["content"], Value::String("a1".into()));
assert!(assistant.get("reasoning").is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attaches_reasoning_to_previous_assistant() {
skip_if_no_network!();
let body = run_request(vec![
user_message("u1"),
assistant_message("a1"),
reasoning_item("rA"),
])
.await;
let messages = messages_from(&body);
let assistant = first_assistant(&messages);
assert_eq!(assistant["content"], Value::String("a1".into()));
assert_eq!(assistant["reasoning"], Value::String("rA".into()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attaches_reasoning_to_function_call_anchor() {
skip_if_no_network!();
let body = run_request(vec![
user_message("u1"),
reasoning_item("rFunc"),
function_call(),
])
.await;
let messages = messages_from(&body);
let assistant = first_assistant(&messages);
assert_eq!(assistant["reasoning"], Value::String("rFunc".into()));
let tool_calls = match assistant["tool_calls"].as_array() {
Some(arr) => arr,
None => panic!("tool call list missing"),
};
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["type"], Value::String("function".into()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attaches_reasoning_to_local_shell_call() {
skip_if_no_network!();
let body = run_request(vec![
user_message("u1"),
reasoning_item("rShell"),
local_shell_call(),
])
.await;
let messages = messages_from(&body);
let assistant = first_assistant(&messages);
assert_eq!(assistant["reasoning"], Value::String("rShell".into()));
assert_eq!(
assistant["tool_calls"][0]["type"],
Value::String("local_shell_call".into())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn drops_reasoning_when_last_role_is_user() {
skip_if_no_network!();
let body = run_request(vec![
assistant_message("aPrev"),
reasoning_item("rHist"),
user_message("uNew"),
])
.await;
let messages = messages_from(&body);
assert!(messages.iter().all(|msg| msg.get("reasoning").is_none()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ignores_reasoning_before_last_user() {
skip_if_no_network!();
let body = run_request(vec![
user_message("u1"),
assistant_message("a1"),
user_message("u2"),
reasoning_item("rAfterU1"),
])
.await;
let messages = messages_from(&body);
assert!(messages.iter().all(|msg| msg.get("reasoning").is_none()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skips_empty_reasoning_segments() {
skip_if_no_network!();
let body = run_request(vec![
user_message("u1"),
assistant_message("a1"),
reasoning_item(""),
reasoning_item(" "),
])
.await;
let messages = messages_from(&body);
let assistant = first_assistant(&messages);
assert!(assistant.get("reasoning").is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn suppresses_duplicate_assistant_messages() {
skip_if_no_network!();
let body = run_request(vec![assistant_message("dup"), assistant_message("dup")]).await;
let messages = messages_from(&body);
let assistant_messages: Vec<_> = messages
.iter()
.filter(|msg| msg["role"] == "assistant")
.collect();
assert_eq!(assistant_messages.len(), 1);
assert_eq!(
assistant_messages[0]["content"],
Value::String("dup".into())
);
}
-466
View File
@@ -1,466 +0,0 @@
use assert_matches::assert_matches;
use codex_core::AuthManager;
use std::sync::Arc;
use tracing_test::traced_test;
use codex_core::CodexAuth;
use codex_core::ContentItem;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::ResponseItem;
use codex_core::TransportManager;
use codex_core::WireApi;
use codex_core::models_manager::manager::ModelsManager;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::protocol::SessionSource;
use core_test_support::load_default_config_for_test;
use core_test_support::skip_if_no_network;
use futures::StreamExt;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
run_stream_with_bytes(sse_body.as_bytes()).await
}
async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
let server = MockServer::start().await;
let template = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_bytes(sse_body.to_vec());
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(template)
.expect(1)
.mount(&server)
.await;
let provider = ModelProviderInfo {
name: "mock".into(),
base_url: Some(format!("{}/v1", server.uri())),
env_key: None,
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Chat,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
requires_openai_auth: false,
supports_websockets: false,
};
let codex_home = match TempDir::new() {
Ok(dir) => dir,
Err(e) => panic!("failed to create TempDir: {e}"),
};
let mut config = load_default_config_for_test(&codex_home).await;
config.model_provider_id = provider.name.clone();
config.model_provider = provider.clone();
config.show_raw_agent_reasoning = true;
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let auth_mode = auth_manager.get_auth_mode();
let model = ModelsManager::get_model_offline(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config);
let otel_manager = OtelManager::new(
conversation_id,
model.as_str(),
model_info.slug.as_str(),
None,
Some("test@test.com".to_string()),
auth_mode,
false,
"test".to_string(),
SessionSource::Exec,
);
let mut client = ModelClient::new(
Arc::clone(&config),
None,
model_info,
otel_manager,
provider,
effort,
summary,
conversation_id,
SessionSource::Exec,
TransportManager::new(),
)
.new_session(None);
let mut prompt = Prompt::default();
prompt.input = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hello".to_string(),
}],
end_turn: None,
phase: None,
}];
let mut stream = match client.stream(&prompt).await {
Ok(s) => s,
Err(e) => panic!("stream chat failed: {e}"),
};
let mut events = Vec::new();
while let Some(event) = stream.next().await {
match event {
Ok(ev) => events.push(ev),
// We still collect the error to exercise telemetry and complete the task.
Err(_e) => break,
}
}
events
}
fn assert_message(item: &ResponseItem, expected: &str) {
if let ResponseItem::Message { content, .. } = item {
let text = content.iter().find_map(|part| match part {
ContentItem::OutputText { text } | ContentItem::InputText { text } => Some(text),
_ => None,
});
let Some(text) = text else {
panic!("message missing text: {item:?}");
};
assert_eq!(text, expected);
} else {
panic!("expected message item, got: {item:?}");
}
}
fn assert_reasoning(item: &ResponseItem, expected: &str) {
if let ResponseItem::Reasoning {
content: Some(parts),
..
} = item
{
let mut combined = String::new();
for part in parts {
match part {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => combined.push_str(text),
}
}
assert_eq!(combined, expected);
} else {
panic!("expected reasoning item, got: {item:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_text_without_reasoning() {
skip_if_no_network!();
let sse = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{}}]}\n\n",
"data: [DONE]\n\n",
);
let events = run_stream(sse).await;
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
other => panic!("expected initial assistant item, got {other:?}"),
}
match &events[1] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"),
other => panic!("expected terminal message, got {other:?}"),
}
assert_matches!(events[3], ResponseEvent::Completed { .. });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_reasoning_from_string_delta() {
skip_if_no_network!();
let sse = concat!(
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"think1\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n",
);
let events = run_stream(sse).await;
assert_eq!(events.len(), 7, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
other => panic!("expected initial reasoning item, got {other:?}"),
}
match &events[1] {
ResponseEvent::ReasoningContentDelta {
delta,
content_index,
} => {
assert_eq!(delta, "think1");
assert_eq!(content_index, &0);
}
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
other => panic!("expected initial message item, got {other:?}"),
}
match &events[3] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[4] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"),
other => panic!("expected terminal reasoning, got {other:?}"),
}
match &events[5] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"),
other => panic!("expected terminal message, got {other:?}"),
}
assert_matches!(events[6], ResponseEvent::Completed { .. });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_reasoning_from_object_delta() {
skip_if_no_network!();
let sse = concat!(
"data: {\"choices\":[{\"delta\":{\"reasoning\":{\"text\":\"partA\"}}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"reasoning\":{\"content\":\"partB\"}}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n",
);
let events = run_stream(sse).await;
assert_eq!(events.len(), 8, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
other => panic!("expected initial reasoning item, got {other:?}"),
}
match &events[1] {
ResponseEvent::ReasoningContentDelta {
delta,
content_index,
} => {
assert_eq!(delta, "partA");
assert_eq!(content_index, &0);
}
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::ReasoningContentDelta {
delta,
content_index,
} => {
assert_eq!(delta, "partB");
assert_eq!(content_index, &1);
}
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[3] {
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
other => panic!("expected initial message item, got {other:?}"),
}
match &events[4] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[5] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"),
other => panic!("expected terminal reasoning, got {other:?}"),
}
match &events[6] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"),
other => panic!("expected terminal message, got {other:?}"),
}
assert_matches!(events[7], ResponseEvent::Completed { .. });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_reasoning_from_final_message() {
skip_if_no_network!();
let sse = "data: {\"choices\":[{\"message\":{\"reasoning\":\"final-cot\"},\"finish_reason\":\"stop\"}]}\n\n";
let events = run_stream(sse).await;
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
other => panic!("expected initial reasoning item, got {other:?}"),
}
match &events[1] {
ResponseEvent::ReasoningContentDelta {
delta,
content_index,
} => {
assert_eq!(delta, "final-cot");
assert_eq!(content_index, &0);
}
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"),
other => panic!("expected reasoning item, got {other:?}"),
}
assert_matches!(events[3], ResponseEvent::Completed { .. });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn streams_reasoning_before_tool_call() {
skip_if_no_network!();
let sse = concat!(
"data: {\"choices\":[{\"delta\":{\"reasoning\":\"pre-tool\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"run\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
);
let events = run_stream(sse).await;
assert_eq!(events.len(), 5, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
other => panic!("expected initial reasoning item, got {other:?}"),
}
match &events[1] {
ResponseEvent::ReasoningContentDelta {
delta,
content_index,
} => {
assert_eq!(delta, "pre-tool");
assert_eq!(content_index, &0);
}
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"),
other => panic!("expected reasoning item, got {other:?}"),
}
match &events[3] {
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall {
name,
arguments,
call_id,
..
}) => {
assert_eq!(name, "run");
assert_eq!(arguments, "{}");
assert_eq!(call_id, "call_1");
}
other => panic!("expected function call, got {other:?}"),
}
assert_matches!(events[4], ResponseEvent::Completed { .. });
}
#[tokio::test]
#[traced_test]
async fn chat_sse_emits_failed_on_parse_error() {
skip_if_no_network!();
let sse_body = concat!("data: not-json\n\n", "data: [DONE]\n\n");
let _ = run_stream(sse_body).await;
logs_assert(|lines: &[&str]| {
lines
.iter()
.find(|line| {
line.contains("codex.api_request") && line.contains("http.response.status_code=200")
})
.map(|_| Ok(()))
.unwrap_or(Err("cannot find codex.api_request event".to_string()))
});
logs_assert(|lines: &[&str]| {
lines
.iter()
.find(|line| {
line.contains("codex.sse_event")
&& line.contains("error.message")
&& line.contains("expected ident at line 1 column 2")
})
.map(|_| Ok(()))
.unwrap_or(Err("cannot find SSE event".to_string()))
});
}
#[tokio::test]
#[traced_test]
async fn chat_sse_done_chunk_emits_event() {
skip_if_no_network!();
let sse_body = "data: [DONE]\n\n";
let _ = run_stream(sse_body).await;
logs_assert(|lines: &[&str]| {
lines
.iter()
.find(|line| line.contains("codex.sse_event") && line.contains("event.kind=message"))
.map(|_| Ok(()))
.unwrap_or(Err("cannot find SSE event".to_string()))
});
}
#[tokio::test]
#[traced_test]
async fn chat_sse_emits_error_on_invalid_utf8() {
skip_if_no_network!();
let _ = run_stream_with_bytes(b"data: \x80\x80\n\n").await;
logs_assert(|lines: &[&str]| {
lines
.iter()
.find(|line| {
line.contains("codex.sse_event")
&& line.contains("error.message")
&& line.contains("UTF8 error: invalid utf-8 sequence of 1 bytes from index 0")
})
.map(|_| Ok(()))
.unwrap_or(Err("cannot find SSE event".to_string()))
});
}
+13 -28
View File
@@ -4,15 +4,12 @@ use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use codex_core::protocol::GitInfo;
use codex_utils_cargo_bin::find_resource;
use core_test_support::fs_wait;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use std::time::Duration;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn repo_root() -> std::path::PathBuf {
#[expect(clippy::expect_used)]
@@ -24,41 +21,28 @@ fn cli_responses_fixture() -> std::path::PathBuf {
find_resource!("tests/cli_responses_fixture.sse").expect("failed to resolve fixture path")
}
/// Tests streaming chat completions through the CLI using a mock server.
/// This test:
/// 1. Sets up a mock server that simulates OpenAI's chat completions API
/// 2. Configures codex to use this mock server via a custom provider
/// 3. Sends a simple "hello?" prompt and verifies the streamed response
/// 4. Ensures the response is received exactly once and contains "hi"
/// Tests streaming the Responses API through the CLI using a mock server.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn chat_mode_stream_cli() {
async fn responses_mode_stream_cli() {
skip_if_no_network!();
let server = MockServer::start().await;
let repo_root = repo_root();
let sse = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{}}]}\n\n",
"data: [DONE]\n\n"
);
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream"),
)
.expect(1)
.mount(&server)
.await;
let sse = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "hi"),
responses::ev_completed("resp-1"),
]);
let resp_mock = responses::mount_sse_once(&server, sse).await;
let home = TempDir::new().unwrap();
let provider_override = format!(
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}",
server.uri()
);
let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap();
let mut cmd = AssertCommand::new(bin);
cmd.timeout(Duration::from_secs(30));
cmd.arg("exec")
.arg("--skip-git-repo-check")
.arg("-c")
@@ -81,7 +65,8 @@ async fn chat_mode_stream_cli() {
let hi_lines = stdout.lines().filter(|line| line.trim() == "hi").count();
assert_eq!(hi_lines, 1, "Expected exactly one line with 'hi'");
server.verify().await;
let request = resp_mock.single_request();
assert_eq!(request.path(), "/v1/responses");
// Verify a new session rollout was created and is discoverable via list_conversations
let provider_filter = vec!["mock".to_string()];
@@ -222,6 +222,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
Ok(())
}
#[cfg_attr(target_os = "windows", ignore)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_compact_trims_function_call_history_to_fit_context_window() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -141,7 +141,9 @@ async fn emits_deprecation_notice_for_web_search_feature_flags() -> anyhow::Resu
);
assert_eq!(
details.as_deref(),
Some("Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml."),
Some(
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml."
),
);
Ok(())
@@ -176,7 +178,9 @@ async fn emits_deprecation_notice_for_disabled_web_search_feature_flag() -> anyh
);
assert_eq!(
details.as_deref(),
Some("Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml."),
Some(
"Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml."
),
);
Ok(())
-194
View File
@@ -329,200 +329,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-cc-1";
let server_name = "rmcp";
let tool_name = format!("mcp__{server_name}__image");
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {"name": tool_name, "arguments": "{}"}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse_tool_call = format!(
"data: {}\n\ndata: [DONE]\n\n",
serde_json::to_string(&tool_call)?
);
let final_assistant = json!({
"choices": [
{
"delta": {"content": "rmcp image tool completed successfully."},
"finish_reason": "stop"
}
]
});
let sse_final = format!(
"data: {}\n\ndata: [DONE]\n\n",
serde_json::to_string(&final_assistant)?
);
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
struct ChatSeqResponder {
num_calls: AtomicUsize,
bodies: Vec<String>,
}
impl wiremock::Respond for ChatSeqResponder {
fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate {
let idx = self.num_calls.fetch_add(1, Ordering::SeqCst);
match self.bodies.get(idx) {
Some(body) => wiremock::ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(body.clone()),
None => panic!("no chat completion response for index {idx}"),
}
}
}
let chat_seq = ChatSeqResponder {
num_calls: AtomicUsize::new(0),
bodies: vec![sse_tool_call, sse_final],
};
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/chat/completions"))
.respond_with(chat_seq)
.expect(2)
.mount(&server)
.await;
let rmcp_test_server_bin = stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
config.model_provider.wire_api = codex_core::WireApi::Chat;
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
let session_model = fixture.session_configured.model.clone();
fixture
.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "call the rmcp image tool".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("begin");
};
assert_eq!(
begin,
McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation: McpInvocation {
server: server_name.to_string(),
tool: "image".to_string(),
arguments: Some(json!({})),
},
},
);
let end_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallEnd(_))
})
.await;
let EventMsg::McpToolCallEnd(end) = end_event else {
unreachable!("end");
};
assert!(end.result.as_ref().is_ok(), "tool call should succeed");
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// Chat Completions assertion: the second POST should include a tool role message
// with an array `content` containing an item with the expected data URL.
let all_requests = server.received_requests().await.expect("requests captured");
let requests: Vec<_> = all_requests
.iter()
.filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions"))
.collect();
assert!(requests.len() >= 2, "expected two chat completion calls");
let second = requests[1];
let body: Value = serde_json::from_slice(&second.body)?;
let messages = body
.get("messages")
.and_then(Value::as_array)
.cloned()
.expect("messages array");
let tool_msg = messages
.iter()
.find(|m| {
m.get("role") == Some(&json!("tool")) && m.get("tool_call_id") == Some(&json!(call_id))
})
.cloned()
.expect("tool message present");
assert_eq!(
tool_msg,
json!({
"role": "tool",
"tool_call_id": call_id,
"content": [{"type": "image_url", "image_url": {"url": OPENAI_PNG}}]
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
+1 -1
View File
@@ -30,7 +30,7 @@ pub struct Cli {
#[arg(long = "oss", default_value_t = false)]
pub oss: bool,
/// Specify which local provider to use (lmstudio, ollama, or ollama-chat).
/// Specify which local provider to use (lmstudio or ollama).
/// If not specified with --oss, will use config default or show selection.
#[arg(long = "local-provider")]
pub oss_provider: Option<String>,
+1 -17
View File
@@ -16,11 +16,9 @@ pub use cli::ReviewArgs;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_common::oss::ensure_oss_provider_ready;
use codex_common::oss::get_default_model_for_oss_provider;
use codex_common::oss::ollama_chat_deprecation_notice;
use codex_core::AuthManager;
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
use codex_core::NewThread;
use codex_core::OLLAMA_CHAT_PROVIDER_ID;
use codex_core::OLLAMA_OSS_PROVIDER_ID;
use codex_core::ThreadManager;
use codex_core::auth::enforce_login_restrictions;
@@ -219,7 +217,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
Some(provider)
} else {
return Err(anyhow::anyhow!(
"No default OSS provider configured. Use --local-provider=provider or set oss_provider to one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID} in config.toml"
"No default OSS provider configured. Use --local-provider=provider or set oss_provider to one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID} in config.toml"
));
}
} else {
@@ -273,14 +271,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await {
Ok(notice) => notice,
Err(err) => {
tracing::warn!(?err, "Failed to detect Ollama wire API");
None
}
};
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false)
})) {
@@ -313,12 +303,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
last_message_file.clone(),
)),
};
if let Some(notice) = ollama_chat_support_notice {
event_processor.process_event(Event {
id: String::new(),
msg: EventMsg::DeprecationNotice(notice),
});
}
if oss {
// We're in the oss section, so provider_id should be Some
+6
View File
@@ -22,6 +22,8 @@ codex-utils-absolute-path = { workspace = true }
landlock = { workspace = true }
libc = { workspace = true }
seccompiler = { workspace = true }
serde_json = { workspace = true }
which = "8.0.0"
[target.'cfg(target_os = "linux")'.dev-dependencies]
pretty_assertions = { workspace = true }
@@ -33,3 +35,7 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
[build-dependencies]
cc = "1"
pkg-config = "0.3"
+115
View File
@@ -0,0 +1,115 @@
use std::env;
use std::path::Path;
use std::path::PathBuf;
fn main() {
// Tell rustc/clippy that this is an expected cfg value.
println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)");
println!("cargo:rerun-if-env-changed=CODEX_BWRAP_ENABLE_FFI");
println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR");
// Rebuild if the vendored bwrap sources change.
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("bubblewrap.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("bind-mount.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("network.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("utils.c").display()
);
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "linux" {
return;
}
// Opt-in: do not attempt to fetch/compile bwrap unless explicitly enabled.
let enable_ffi = matches!(env::var("CODEX_BWRAP_ENABLE_FFI"), Ok(value) if value == "1");
if !enable_ffi {
return;
}
if let Err(err) = try_build_vendored_bwrap() {
// Keep normal builds working even if the experiment fails.
println!("cargo:warning=build-time bubblewrap disabled: {err}");
}
}
fn try_build_vendored_bwrap() -> Result<(), String> {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?);
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?);
let src_dir = resolve_bwrap_source_dir(&manifest_dir)?;
let libcap = pkg_config::Config::new()
.probe("libcap")
.map_err(|err| format!("libcap not available via pkg-config: {err}"))?;
let config_h = out_dir.join("config.h");
std::fs::write(
&config_h,
r#"#pragma once
#define PACKAGE_STRING "bubblewrap built at codex build-time"
"#,
)
.map_err(|err| format!("failed to write {}: {err}", config_h.display()))?;
let mut build = cc::Build::new();
build
.file(src_dir.join("bubblewrap.c"))
.file(src_dir.join("bind-mount.c"))
.file(src_dir.join("network.c"))
.file(src_dir.join("utils.c"))
.include(&out_dir)
.include(&src_dir)
.define("_GNU_SOURCE", None)
// Rename `main` so we can call it via FFI.
.define("main", Some("bwrap_main"));
for include_path in libcap.include_paths {
build.include(include_path);
}
build.compile("build_time_bwrap");
println!("cargo:rustc-cfg=vendored_bwrap_available");
Ok(())
}
/// Resolve the bubblewrap source directory used for build-time compilation.
///
/// Priority:
/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout.
/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`.
fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result<PathBuf, String> {
if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") {
let src_dir = PathBuf::from(path);
if src_dir.exists() {
return Ok(src_dir);
}
return Err(format!(
"CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}",
src_dir.display()
));
}
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
if vendor_dir.exists() {
return Ok(vendor_dir);
}
Err(format!(
"expected vendored bubblewrap at {}, but it was not found.\n\
Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.",
vendor_dir.display()
))
}
+291
View File
@@ -0,0 +1,291 @@
//! Bubblewrap-based filesystem sandboxing for Linux.
//!
//! This module mirrors the semantics used by the macOS Seatbelt sandbox:
//! - the filesystem is read-only by default,
//! - explicit writable roots are layered on top, and
//! - sensitive subpaths such as `.git` and `.codex` remain read-only even when
//! their parent root is writable.
//!
//! The overall Linux sandbox is composed of:
//! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and
//! - bubblewrap used to construct the filesystem view before exec.
use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WritableRoot;
/// Options that control how bubblewrap is invoked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BwrapOptions {
/// Whether to mount a fresh `/proc` inside the PID namespace.
///
/// This is the secure default, but some restrictive container environments
/// deny `--proc /proc` even when PID namespaces are available.
pub mount_proc: bool,
}
impl Default for BwrapOptions {
fn default() -> Self {
Self { mount_proc: true }
}
}
/// Wrap a command with bubblewrap so the filesystem is read-only by default,
/// with explicit writable roots and read-only subpaths layered afterward.
///
/// When the policy grants full disk write access, this returns `command`
/// unchanged so we avoid unnecessary sandboxing overhead.
pub(crate) fn create_bwrap_command_args(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
options: BwrapOptions,
bwrap_path: Option<&Path>,
) -> Result<Vec<String>> {
if sandbox_policy.has_full_disk_write_access() {
return Ok(command);
}
let bwrap_path = match bwrap_path {
Some(path) => {
if path.exists() {
path.to_path_buf()
} else {
return Err(CodexErr::UnsupportedOperation(format!(
"bubblewrap (bwrap) not found at configured path: {}",
path.display()
)));
}
}
None => which::which("bwrap").map_err(|err| {
CodexErr::UnsupportedOperation(format!("bubblewrap (bwrap) not found on PATH: {err}"))
})?,
};
let mut args = Vec::new();
args.push(path_to_string(&bwrap_path));
args.extend(create_bwrap_flags(command, sandbox_policy, cwd, options)?);
Ok(args)
}
/// Doc-hidden helper that builds bubblewrap arguments without a program path.
///
/// This is intended for experiments where we call a build-time bubblewrap
/// `main` symbol via FFI rather than exec'ing the `bwrap` binary. The caller
/// is responsible for providing a suitable `argv[0]`.
#[doc(hidden)]
pub(crate) fn create_bwrap_command_args_vendored(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
options: BwrapOptions,
) -> Result<Vec<String>> {
if sandbox_policy.has_full_disk_write_access() {
return Ok(command);
}
create_bwrap_flags(command, sandbox_policy, cwd, options)
}
/// Build the bubblewrap flags (everything after `argv[0]`).
fn create_bwrap_flags(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
options: BwrapOptions,
) -> Result<Vec<String>> {
let mut args = Vec::new();
args.push("--new-session".to_string());
args.push("--die-with-parent".to_string());
args.extend(create_filesystem_args(sandbox_policy, cwd)?);
// Isolate the PID namespace.
args.push("--unshare-pid".to_string());
// Mount a fresh /proc unless the caller explicitly disables it.
if options.mount_proc {
args.push("--proc".to_string());
args.push("/proc".to_string());
}
args.push("--".to_string());
args.extend(command);
Ok(args)
}
/// Build the bubblewrap filesystem mounts for a given sandbox policy.
///
/// The mount order is important:
/// 1. `--ro-bind / /` makes the entire filesystem read-only.
/// 2. `--bind <root> <root>` re-enables writes for allowed roots.
/// 3. `--ro-bind <subpath> <subpath>` re-applies read-only protections under
/// those writable roots so protected subpaths win.
/// 4. `--dev-bind /dev/null /dev/null` preserves the common sink even under a
/// read-only root.
fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<Vec<String>> {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
ensure_mount_targets_exist(&writable_roots)?;
let mut args = Vec::new();
// Read-only root, then selectively re-enable writes.
args.push("--ro-bind".to_string());
args.push("/".to_string());
args.push("/".to_string());
for writable_root in &writable_roots {
let root = writable_root.root.as_path();
args.push("--bind".to_string());
args.push(path_to_string(root));
args.push(path_to_string(root));
}
// Re-apply read-only subpaths after the writable binds so they win.
let allowed_write_paths: Vec<PathBuf> = writable_roots
.iter()
.map(|writable_root| writable_root.root.as_path().to_path_buf())
.collect();
for subpath in collect_read_only_subpaths(&writable_roots) {
if let Some(symlink_path) = find_symlink_in_path(&subpath, &allowed_write_paths) {
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&symlink_path));
continue;
}
if !subpath.exists() {
if let Some(first_missing) = find_first_non_existent_component(&subpath)
&& is_within_allowed_write_paths(&first_missing, &allowed_write_paths)
{
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&first_missing));
}
continue;
}
if is_within_allowed_write_paths(&subpath, &allowed_write_paths) {
args.push("--ro-bind".to_string());
args.push(path_to_string(&subpath));
args.push(path_to_string(&subpath));
}
}
// Ensure `/dev/null` remains usable regardless of the root bind.
args.push("--dev-bind".to_string());
args.push("/dev/null".to_string());
args.push("/dev/null".to_string());
Ok(args)
}
/// Collect unique read-only subpaths across all writable roots.
fn collect_read_only_subpaths(writable_roots: &[WritableRoot]) -> Vec<PathBuf> {
let mut subpaths: BTreeSet<PathBuf> = BTreeSet::new();
for writable_root in writable_roots {
for subpath in &writable_root.read_only_subpaths {
subpaths.insert(subpath.as_path().to_path_buf());
}
}
subpaths.into_iter().collect()
}
/// Validate that writable roots exist before constructing mounts.
///
/// Bubblewrap requires bind mount targets to exist. We fail fast with a clear
/// error so callers can present an actionable message.
fn ensure_mount_targets_exist(writable_roots: &[WritableRoot]) -> Result<()> {
for writable_root in writable_roots {
let root = writable_root.root.as_path();
if !root.exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Sandbox expected writable root {root}, but it does not exist.",
root = root.display()
)));
}
}
Ok(())
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
/// Returns true when `path` is under any allowed writable root.
fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -> bool {
allowed_write_paths
.iter()
.any(|root| path.starts_with(root))
}
/// Find the first symlink along `target_path` that is also under a writable root.
///
/// This blocks symlink replacement attacks where a protected path is a symlink
/// inside a writable root (e.g., `.codex -> ./decoy`). In that case we mount
/// `/dev/null` on the symlink itself to prevent rewiring it.
fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) -> Option<PathBuf> {
let mut current = PathBuf::new();
for component in target_path.components() {
use std::path::Component;
match component {
Component::RootDir => {
current.push(Path::new("/"));
continue;
}
Component::CurDir => continue,
Component::ParentDir => {
current.pop();
continue;
}
Component::Normal(part) => current.push(part),
Component::Prefix(_) => continue,
}
let metadata = match std::fs::symlink_metadata(&current) {
Ok(metadata) => metadata,
Err(_) => break,
};
if metadata.file_type().is_symlink()
&& is_within_allowed_write_paths(&current, allowed_write_paths)
{
return Some(current);
}
}
None
}
/// Find the first missing path component while walking `target_path`.
///
/// Mounting `/dev/null` on the first missing component prevents the sandboxed
/// process from creating the protected path hierarchy.
fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
let mut current = PathBuf::new();
for component in target_path.components() {
use std::path::Component;
match component {
Component::RootDir => {
current.push(Path::new("/"));
continue;
}
Component::CurDir => continue,
Component::ParentDir => {
current.pop();
continue;
}
Component::Normal(part) => current.push(part),
Component::Prefix(_) => continue,
}
if !current.exists() {
return Some(current);
}
}
None
}
+8 -1
View File
@@ -1,9 +1,16 @@
//! Linux sandbox helper entry point.
//!
//! On Linux, `codex-linux-sandbox` applies:
//! - in-process restrictions (`no_new_privs` + seccomp), and
//! - bubblewrap for filesystem isolation.
#[cfg(target_os = "linux")]
mod bwrap;
#[cfg(target_os = "linux")]
mod landlock;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(target_os = "linux")]
mod mounts;
mod vendored_bwrap;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {
+197 -5
View File
@@ -1,10 +1,19 @@
use clap::Parser;
use std::ffi::CString;
use std::path::Path;
use std::path::PathBuf;
use crate::bwrap::BwrapOptions;
use crate::bwrap::create_bwrap_command_args;
use crate::bwrap::create_bwrap_command_args_vendored;
use crate::landlock::apply_sandbox_policy_to_current_thread;
use crate::vendored_bwrap::exec_vendored_bwrap;
#[derive(Debug, Parser)]
/// CLI surface for the Linux sandbox helper.
///
/// The type name remains `LandlockCommand` for compatibility with existing
/// wiring, but the filesystem sandbox now uses bubblewrap.
pub struct LandlockCommand {
/// It is possible that the cwd used in the context of the sandbox policy
/// is different from the cwd of the process to spawn.
@@ -14,26 +23,179 @@ pub struct LandlockCommand {
#[arg(long = "sandbox-policy")]
pub sandbox_policy: codex_core::protocol::SandboxPolicy,
/// Full command args to run under landlock.
/// Opt-in: use the bubblewrap-based Linux sandbox pipeline.
///
/// When not set, we fall back to the legacy Landlock + mount pipeline.
#[arg(long = "use-bwrap-sandbox", hide = true, default_value_t = false)]
pub use_bwrap_sandbox: bool,
/// Optional explicit path to the `bwrap` binary to use.
///
/// When provided, this implies bubblewrap opt-in and avoids PATH lookups.
#[arg(long = "bwrap-path", hide = true)]
pub bwrap_path: Option<PathBuf>,
/// Experimental: call a build-time bubblewrap `main()` via FFI.
///
/// This is opt-in and only works when the build script compiles bwrap.
#[arg(long = "use-vendored-bwrap", hide = true, default_value_t = false)]
pub use_vendored_bwrap: bool,
/// Internal: apply seccomp and `no_new_privs` in the already-sandboxed
/// process, then exec the user command.
///
/// This exists so we can run bubblewrap first (which may rely on setuid)
/// and only tighten with seccomp after the filesystem view is established.
#[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)]
pub apply_seccomp_then_exec: bool,
/// When set, skip mounting a fresh `/proc` even though PID isolation is
/// still enabled. This is primarily intended for restrictive container
/// environments that deny `--proc /proc`.
#[arg(long = "no-proc", default_value_t = false)]
pub no_proc: bool,
/// Full command args to run under the Linux sandbox helper.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
/// Entry point for the Linux sandbox helper.
///
/// The sequence is:
/// 1. When needed, wrap the command with bubblewrap to construct the
/// filesystem view.
/// 2. Apply in-process restrictions (no_new_privs + seccomp).
/// 3. `execvp` into the final command.
pub fn run_main() -> ! {
let LandlockCommand {
sandbox_policy_cwd,
sandbox_policy,
use_bwrap_sandbox,
bwrap_path,
use_vendored_bwrap,
apply_seccomp_then_exec,
no_proc,
command,
} = LandlockCommand::parse();
if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) {
panic!("error running landlock: {e:?}");
}
let use_bwrap_sandbox = use_bwrap_sandbox || bwrap_path.is_some() || use_vendored_bwrap;
if command.is_empty() {
panic!("No command specified to execute.");
}
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
// established the filesystem view.
if apply_seccomp_then_exec {
if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd)
{
panic!("error applying Linux sandbox restrictions: {e:?}");
}
exec_or_panic(command);
}
let command = if sandbox_policy.has_full_disk_write_access() {
if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd)
{
panic!("error applying Linux sandbox restrictions: {e:?}");
}
command
} else if use_bwrap_sandbox {
// Outer stage: bubblewrap first, then re-enter this binary in the
// sandboxed environment to apply seccomp.
let inner = build_inner_seccomp_command(
&sandbox_policy_cwd,
&sandbox_policy,
use_bwrap_sandbox,
bwrap_path.as_deref(),
command,
);
let options = BwrapOptions {
mount_proc: !no_proc,
};
if use_vendored_bwrap {
let mut argv0 = bwrap_path
.as_deref()
.map(|path| path.to_string_lossy().to_string())
.unwrap_or_else(|| "bwrap".to_string());
if argv0.is_empty() {
argv0 = "bwrap".to_string();
}
let mut argv = vec![argv0];
argv.extend(
create_bwrap_command_args_vendored(
inner,
&sandbox_policy,
&sandbox_policy_cwd,
options,
)
.unwrap_or_else(|err| {
panic!("error building build-time bubblewrap command: {err:?}")
}),
);
exec_vendored_bwrap(argv);
}
ensure_bwrap_available(bwrap_path.as_deref());
create_bwrap_command_args(
inner,
&sandbox_policy,
&sandbox_policy_cwd,
options,
bwrap_path.as_deref(),
)
.unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}"))
} else {
// Legacy path: Landlock enforcement only.
if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd)
{
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
}
command
};
exec_or_panic(command);
}
/// Build the inner command that applies seccomp after bubblewrap.
fn build_inner_seccomp_command(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_core::protocol::SandboxPolicy,
use_bwrap_sandbox: bool,
bwrap_path: Option<&Path>,
command: Vec<String>,
) -> Vec<String> {
let current_exe = match std::env::current_exe() {
Ok(path) => path,
Err(err) => panic!("failed to resolve current executable path: {err}"),
};
let policy_json = match serde_json::to_string(sandbox_policy) {
Ok(json) => json,
Err(err) => panic!("failed to serialize sandbox policy: {err}"),
};
let mut inner = vec![
current_exe.to_string_lossy().to_string(),
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd.to_string_lossy().to_string(),
"--sandbox-policy".to_string(),
policy_json,
];
if use_bwrap_sandbox {
inner.push("--use-bwrap-sandbox".to_string());
inner.push("--apply-seccomp-then-exec".to_string());
}
if let Some(bwrap_path) = bwrap_path {
inner.push("--bwrap-path".to_string());
inner.push(bwrap_path.to_string_lossy().to_string());
}
inner.push("--".to_string());
inner.extend(command);
inner
}
/// Exec the provided argv, panicking with context if it fails.
fn exec_or_panic(command: Vec<String>) -> ! {
#[expect(clippy::expect_used)]
let c_command =
CString::new(command[0].as_str()).expect("Failed to convert command to CString");
@@ -54,3 +216,33 @@ pub fn run_main() -> ! {
let err = std::io::Error::last_os_error();
panic!("Failed to execvp {}: {err}", command[0].as_str());
}
/// Ensure the `bwrap` binary is available when the sandbox needs it.
fn ensure_bwrap_available(bwrap_path: Option<&Path>) {
if let Some(path) = bwrap_path {
if path.exists() {
return;
}
panic!(
"bubblewrap (bwrap) is required for Linux filesystem sandboxing but was not found at the configured path: {}\n\
Install it and retry. Examples:\n\
- Debian/Ubuntu: apt-get install bubblewrap\n\
- Fedora/RHEL: dnf install bubblewrap\n\
- Arch: pacman -S bubblewrap\n\
If you are running the Codex Node package, ensure bwrap is installed on the host system.",
path.display()
);
}
if which::which("bwrap").is_ok() {
return;
}
panic!(
"bubblewrap (bwrap) is required for Linux filesystem sandboxing but was not found on PATH.\n\
Install it and retry. Examples:\n\
- Debian/Ubuntu: apt-get install bubblewrap\n\
- Fedora/RHEL: dnf install bubblewrap\n\
- Arch: pacman -S bubblewrap\n\
If you are running the Codex Node package, ensure bwrap is installed on the host system."
);
}
@@ -0,0 +1,54 @@
//! Build-time bubblewrap entrypoint.
//!
//! This module is intentionally behind a build-time opt-in. When enabled, the
//! build script compiles bubblewrap's C sources and exposes a `bwrap_main`
//! symbol that we can call via FFI.
#[cfg(vendored_bwrap_available)]
mod imp {
use std::ffi::CString;
use std::os::raw::c_char;
unsafe extern "C" {
fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int;
}
/// Execute the build-time bubblewrap `main` function with the given argv.
pub(crate) fn exec_vendored_bwrap(argv: Vec<String>) -> ! {
let mut cstrings: Vec<CString> = Vec::with_capacity(argv.len());
for arg in &argv {
match CString::new(arg.as_str()) {
Ok(value) => cstrings.push(value),
Err(err) => panic!("failed to convert argv to CString: {err}"),
}
}
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect();
argv_ptrs.push(std::ptr::null());
// SAFETY: We provide a null-terminated argv vector whose pointers
// remain valid for the duration of the call.
let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) };
std::process::exit(exit_code);
}
}
#[cfg(not(vendored_bwrap_available))]
mod imp {
/// Panics with a clear error when the build-time bwrap path is not enabled.
pub(crate) fn exec_vendored_bwrap(_argv: Vec<String>) -> ! {
panic!(
"build-time bubblewrap is not available in this build.\n\
Rebuild codex-linux-sandbox on Linux with CODEX_BWRAP_ENABLE_FFI=1.\n\
Example:\n\
- cd codex-rs && CODEX_BWRAP_ENABLE_FFI=1 cargo build -p codex-linux-sandbox\n\
If this crate was already built without it, run:\n\
- cargo clean -p codex-linux-sandbox\n\
Notes:\n\
- libcap headers must be available via pkg-config\n\
- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"
);
}
}
pub(crate) use imp::exec_vendored_bwrap;
+1 -1
View File
@@ -6,7 +6,7 @@ pub use core_test_support::format_with_current_shell;
pub use core_test_support::format_with_current_shell_display_non_login;
pub use core_test_support::format_with_current_shell_non_login;
pub use mcp_process::McpProcess;
pub use mock_model_server::create_mock_chat_completions_server;
pub use mock_model_server::create_mock_responses_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_shell_command_sse_response;
@@ -9,8 +9,8 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
/// Create a mock server that will provide the responses, in order, for
/// requests to the `/v1/chat/completions` endpoint.
pub async fn create_mock_chat_completions_server(responses: Vec<String>) -> MockServer {
/// requests to the `/v1/responses` endpoint.
pub async fn create_mock_responses_server(responses: Vec<String>) -> MockServer {
let server = MockServer::start().await;
let num_calls = responses.len();
@@ -20,7 +20,7 @@ pub async fn create_mock_chat_completions_server(responses: Vec<String>) -> Mock
};
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(path("/v1/responses"))
.respond_with(seq_responder)
.expect(num_calls as u64)
.mount(&server)
+24 -73
View File
@@ -1,96 +1,47 @@
use serde_json::json;
use std::path::Path;
use core_test_support::responses;
use serde_json::json;
pub fn create_shell_command_sse_response(
command: Vec<String>,
workdir: Option<&Path>,
timeout_ms: Option<u64>,
call_id: &str,
) -> anyhow::Result<String> {
// The `arguments` for the `shell_command` tool is a serialized JSON object.
let command_str = shlex::try_join(command.iter().map(String::as_str))?;
let tool_call_arguments = serde_json::to_string(&json!({
let arguments = serde_json::to_string(&json!({
"command": command_str,
"workdir": workdir.map(|w| w.to_string_lossy()),
"timeout_ms": timeout_ms
"timeout_ms": timeout_ms,
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell_command",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
let response_id = format!("resp-{call_id}");
Ok(responses::sse(vec![
responses::ev_response_created(&response_id),
responses::ev_function_call(call_id, "shell_command", &arguments),
responses::ev_completed(&response_id),
]))
}
pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result<String> {
let assistant_message = json!({
"choices": [
{
"delta": {
"content": message
},
"finish_reason": "stop"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&assistant_message)?
);
Ok(sse)
let response_id = "resp-final";
Ok(responses::sse(vec![
responses::ev_response_created(response_id),
responses::ev_assistant_message("msg-final", message),
responses::ev_completed(response_id),
]))
}
pub fn create_apply_patch_sse_response(
patch_content: &str,
call_id: &str,
) -> anyhow::Result<String> {
// Use shell_command to call apply_patch with heredoc format
let command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF");
let tool_call_arguments = serde_json::to_string(&json!({
"command": command
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell_command",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
let arguments = serde_json::to_string(&json!({ "command": command }))?;
let response_id = format!("resp-{call_id}");
Ok(responses::sse(vec![
responses::ev_response_created(&response_id),
responses::ev_function_call(call_id, "shell_command", &arguments),
responses::ev_completed(&response_id),
]))
}
+16 -13
View File
@@ -25,7 +25,7 @@ use core_test_support::skip_if_no_network;
use mcp_test_support::McpProcess;
use mcp_test_support::create_apply_patch_sse_response;
use mcp_test_support::create_final_assistant_message_sse_response;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_test_support::create_mock_responses_server;
use mcp_test_support::create_shell_command_sse_response;
use mcp_test_support::format_with_current_shell;
@@ -87,7 +87,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
])
.await?;
// Send a "codex" tool request, which should hit the completions endpoint.
// Send a "codex" tool request, which should hit the responses endpoint.
// In turn, it should reply with a tool call, which the MCP should forward
// as an elicitation.
let codex_request_id = mcp_process
@@ -349,10 +349,8 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
#![expect(clippy::expect_used, clippy::unwrap_used)]
let server =
create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response(
"Enjoy!",
)?])
.await;
create_mock_responses_server(vec![create_final_assistant_message_sse_response("Enjoy!")?])
.await;
// Run `codex mcp` with a specific config.toml.
let codex_home = TempDir::new()?;
@@ -360,7 +358,7 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
let mut mcp_process = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??;
// Send a "codex" tool request, which should hit the completions endpoint.
// Send a "codex" tool request, which should hit the responses endpoint.
let codex_request_id = mcp_process
.send_codex_tool_call(CodexToolCallParam {
prompt: "How are you?".to_string(),
@@ -400,18 +398,23 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
let requests = server.received_requests().await.unwrap();
let request = requests[0].body_json::<serde_json::Value>()?;
let instructions = request["messages"][0]["content"].as_str().unwrap();
let instructions = request["instructions"]
.as_str()
.expect("responses request should include instructions");
assert!(instructions.starts_with("You are a helpful assistant."));
let developer_messages: Vec<&serde_json::Value> = request["messages"]
let developer_messages: Vec<&serde_json::Value> = request["input"]
.as_array()
.unwrap()
.expect("responses request should include input items")
.iter()
.filter(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer"))
.collect();
let developer_contents: Vec<&str> = developer_messages
.iter()
.filter_map(|msg| msg.get("content").and_then(|value| value.as_str()))
.filter_map(|msg| msg.get("content").and_then(serde_json::Value::as_array))
.flat_map(|content| content.iter())
.filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(serde_json::Value::as_str))
.collect();
assert!(
developer_contents
@@ -469,7 +472,7 @@ pub struct McpHandle {
}
async fn create_mcp_process(responses: Vec<String>) -> anyhow::Result<McpHandle> {
let server = create_mock_chat_completions_server(responses).await;
let server = create_mock_responses_server(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp_process = McpProcess::new(codex_home.path()).await?;
@@ -499,7 +502,7 @@ model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
+2 -5
View File
@@ -13,7 +13,6 @@ use crate::url::base_url_to_host_root;
use crate::url::is_openai_compatible_base_url;
use codex_core::ModelProviderInfo;
use codex_core::OLLAMA_OSS_PROVIDER_ID;
use codex_core::WireApi;
use codex_core::config::Config;
const OLLAMA_CONNECTION_ERROR: &str = "No running Ollama server detected. Start it with: `ollama serve` (after installing). Install instructions: https://github.com/ollama/ollama?tab=readme-ov-file#ollama";
@@ -49,7 +48,7 @@ impl OllamaClient {
#[cfg(test)]
async fn try_from_provider_with_base_url(base_url: &str) -> io::Result<Self> {
let provider =
codex_core::create_oss_provider_with_base_url(base_url, codex_core::WireApi::Chat);
codex_core::create_oss_provider_with_base_url(base_url, codex_core::WireApi::Responses);
Self::try_from_provider(&provider).await
}
@@ -60,9 +59,7 @@ impl OllamaClient {
.base_url
.as_ref()
.expect("oss provider must have a base_url");
let uses_openai_compat = is_openai_compatible_base_url(base_url)
|| matches!(provider.wire_api, WireApi::Chat)
&& is_openai_compatible_base_url(base_url);
let uses_openai_compat = is_openai_compatible_base_url(base_url);
let host_root = base_url_to_host_root(base_url);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
+21 -41
View File
@@ -5,7 +5,6 @@ mod url;
pub use client::OllamaClient;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::config::Config;
pub use pull::CliProgressReporter;
pub use pull::PullEvent;
@@ -16,11 +15,6 @@ use semver::Version;
/// Default OSS model to use when `--oss` is passed without an explicit `-m`.
pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b";
pub struct WireApiDetection {
pub wire_api: WireApi,
pub version: Option<Version>,
}
/// Prepare the local OSS environment when `--oss` is selected.
///
/// - Ensures a local Ollama server is reachable.
@@ -58,60 +52,46 @@ fn min_responses_version() -> Version {
Version::new(0, 13, 4)
}
fn wire_api_for_version(version: &Version) -> WireApi {
if *version == Version::new(0, 0, 0) || *version >= min_responses_version() {
WireApi::Responses
} else {
WireApi::Chat
}
fn supports_responses(version: &Version) -> bool {
*version == Version::new(0, 0, 0) || *version >= min_responses_version()
}
/// Detect which wire API the running Ollama server supports based on its version.
/// Returns `Ok(None)` when the version endpoint is missing or unparsable; callers
/// should keep the configured default in that case.
pub async fn detect_wire_api(
provider: &ModelProviderInfo,
) -> std::io::Result<Option<WireApiDetection>> {
/// Ensure the running Ollama server is new enough to support the Responses API.
///
/// Returns `Ok(())` when the version endpoint is missing or unparsable.
pub async fn ensure_responses_supported(provider: &ModelProviderInfo) -> std::io::Result<()> {
let client = crate::OllamaClient::try_from_provider(provider).await?;
let Some(version) = client.fetch_version().await? else {
return Ok(None);
return Ok(());
};
let wire_api = wire_api_for_version(&version);
if supports_responses(&version) {
return Ok(());
}
Ok(Some(WireApiDetection {
wire_api,
version: Some(version),
}))
let min = min_responses_version();
Err(std::io::Error::other(format!(
"Ollama {version} is too old. Codex requires Ollama {min} or newer."
)))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_wire_api_for_version_dev_zero_keeps_responses() {
assert_eq!(
wire_api_for_version(&Version::new(0, 0, 0)),
WireApi::Responses
);
fn supports_responses_for_dev_zero() {
assert!(supports_responses(&Version::new(0, 0, 0)));
}
#[test]
fn test_wire_api_for_version_before_cutoff_is_chat() {
assert_eq!(wire_api_for_version(&Version::new(0, 13, 3)), WireApi::Chat);
fn does_not_support_responses_before_cutoff() {
assert!(!supports_responses(&Version::new(0, 13, 3)));
}
#[test]
fn test_wire_api_for_version_at_or_after_cutoff_is_responses() {
assert_eq!(
wire_api_for_version(&Version::new(0, 13, 4)),
WireApi::Responses
);
assert_eq!(
wire_api_for_version(&Version::new(0, 14, 0)),
WireApi::Responses
);
fn supports_responses_at_or_after_cutoff() {
assert!(supports_responses(&Version::new(0, 13, 4)));
assert!(supports_responses(&Version::new(0, 14, 0)));
}
}
+3 -5
View File
@@ -109,7 +109,7 @@ pub enum ResponseItem {
encrypted_content: Option<String>,
},
LocalShellCall {
/// Set when using the chat completions API.
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing)]
#[ts(skip)]
id: Option<String>,
@@ -125,8 +125,7 @@ pub enum ResponseItem {
name: String,
// The Responses API returns the function call arguments as a *string* that contains
// JSON, not as an already‑parsed object. We keep it as a raw string here and let
// Session::handle_function_call parse it into a Value. This exactly matches the
// Chat Completions + Responses API behavior.
// Session::handle_function_call parse it into a Value.
arguments: String,
call_id: String,
},
@@ -786,8 +785,7 @@ pub enum FunctionCallOutputContentItem {
/// `content` preserves the historical plain-string payload so downstream
/// integrations (tests, logging, etc.) can keep treating tool output as
/// `String`. When an MCP server returns richer data we additionally populate
/// `content_items` with the structured form that the Responses/Chat
/// Completions APIs understand.
/// `content_items` with the structured form that the Responses API understands.
#[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)]
pub struct FunctionCallOutputPayload {
pub content: String,
+11 -6
View File
@@ -589,13 +589,18 @@ impl SandboxPolicy {
}
subpaths.push(top_level_git);
}
#[allow(clippy::expect_used)]
let top_level_codex = writable_root
.join(".codex")
.expect(".codex is a valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
// Make .agents/skills and .codex/config.toml and
// related files read-only to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex =
writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
WritableRoot {
root: writable_root,
read_only_subpaths: subpaths,
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "codex-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
age = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
codex-keyring-store = { workspace = true }
rand = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
keyring = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+243
View File
@@ -0,0 +1,243 @@
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use sha2::Sha256;
mod local;
pub use local::LocalSecretsBackend;
const KEYRING_SERVICE: &str = "codex";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SecretName(String);
impl SecretName {
pub fn new(raw: &str) -> Result<Self> {
let trimmed = raw.trim();
anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty");
anyhow::ensure!(
trimmed
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'),
"secret name must contain only A-Z, 0-9, or _"
);
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for SecretName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SecretScope {
Global,
Environment(String),
}
impl SecretScope {
pub fn environment(environment_id: impl Into<String>) -> Result<Self> {
let env_id = environment_id.into();
let trimmed = env_id.trim();
anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty");
Ok(Self::Environment(trimmed.to_string()))
}
pub fn canonical_key(&self, name: &SecretName) -> String {
// Stable, env-safe identifier used as the on-disk map key.
match self {
Self::Global => format!("global/{}", name.as_str()),
Self::Environment(environment_id) => {
format!("env/{environment_id}/{}", name.as_str())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretListEntry {
pub scope: SecretScope,
pub name: SecretName,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum SecretsBackendKind {
#[default]
Local,
}
pub trait SecretsBackend: Send + Sync {
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>;
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>;
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>;
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>;
}
#[derive(Clone)]
pub struct SecretsManager {
backend: Arc<dyn SecretsBackend>,
}
impl SecretsManager {
pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self {
let backend: Arc<dyn SecretsBackend> = match backend_kind {
SecretsBackendKind::Local => {
let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
Arc::new(LocalSecretsBackend::new(codex_home, keyring_store))
}
};
Self { backend }
}
pub fn new_with_keyring_store(
codex_home: PathBuf,
backend_kind: SecretsBackendKind,
keyring_store: Arc<dyn KeyringStore>,
) -> Self {
let backend: Arc<dyn SecretsBackend> = match backend_kind {
SecretsBackendKind::Local => {
Arc::new(LocalSecretsBackend::new(codex_home, keyring_store))
}
};
Self { backend }
}
pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
self.backend.set(scope, name, value)
}
pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
self.backend.get(scope, name)
}
pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
self.backend.delete(scope, name)
}
pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
self.backend.list(scope_filter)
}
}
pub fn environment_id_from_cwd(cwd: &Path) -> String {
if let Some(repo_root) = get_git_repo_root(cwd)
&& let Some(name) = repo_root.file_name()
{
let name = name.to_string_lossy().trim().to_string();
if !name.is_empty() {
return name;
}
}
let canonical = cwd
.canonicalize()
.unwrap_or_else(|_| cwd.to_path_buf())
.to_string_lossy()
.into_owned();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let short = hex.get(..12).unwrap_or(hex.as_str());
format!("cwd-{short}")
}
fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
let mut dir = base_dir.to_path_buf();
loop {
if dir.join(".git").exists() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
pub(crate) fn compute_keyring_account(codex_home: &Path) -> String {
let canonical = codex_home
.canonicalize()
.unwrap_or_else(|_| codex_home.to_path_buf())
.to_string_lossy()
.into_owned();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let short = hex.get(..16).unwrap_or(hex.as_str());
format!("secrets|{short}")
}
pub(crate) fn keyring_service() -> &'static str {
KEYRING_SERVICE
}
#[cfg(test)]
mod tests {
use super::*;
use codex_keyring_store::tests::MockKeyringStore;
use pretty_assertions::assert_eq;
#[test]
fn environment_id_fallback_has_cwd_prefix() {
let dir = tempfile::tempdir().expect("tempdir");
let env_id = environment_id_from_cwd(dir.path());
let canonical = dir
.path()
.canonicalize()
.expect("tempdir canonical path should exist")
.to_string_lossy()
.into_owned();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let short = hex.get(..12).expect("digest has at least 12 chars");
assert_eq!(env_id, format!("cwd-{short}"));
}
#[test]
fn manager_round_trips_local_backend() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let manager = SecretsManager::new_with_keyring_store(
codex_home.path().to_path_buf(),
SecretsBackendKind::Local,
keyring,
);
let scope = SecretScope::Global;
let name = SecretName::new("GITHUB_TOKEN")?;
manager.set(&scope, &name, "token-1")?;
assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string()));
let listed = manager.list(None)?;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, name);
assert!(manager.delete(&scope, &name)?);
assert_eq!(manager.get(&scope, &name)?, None);
Ok(())
}
}
+411
View File
@@ -0,0 +1,411 @@
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::compiler_fence;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use age::decrypt;
use age::encrypt;
use age::scrypt::Identity as ScryptIdentity;
use age::scrypt::Recipient as ScryptRecipient;
use age::secrecy::ExposeSecret;
use age::secrecy::SecretString;
use anyhow::Context;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_keyring_store::KeyringStore;
use rand::TryRngCore;
use rand::rngs::OsRng;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;
use super::SecretListEntry;
use super::SecretName;
use super::SecretScope;
use super::SecretsBackend;
use super::compute_keyring_account;
use super::keyring_service;
const SECRETS_VERSION: u8 = 1;
const LOCAL_SECRETS_FILENAME: &str = "local.age";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct SecretsFile {
version: u8,
secrets: BTreeMap<String, String>,
}
impl SecretsFile {
fn new_empty() -> Self {
Self {
version: SECRETS_VERSION,
secrets: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct LocalSecretsBackend {
codex_home: PathBuf,
keyring_store: Arc<dyn KeyringStore>,
}
impl LocalSecretsBackend {
pub fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
Self {
codex_home,
keyring_store,
}
}
pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
anyhow::ensure!(!value.is_empty(), "secret value must not be empty");
let canonical_key = scope.canonical_key(name);
let mut file = self.load_file()?;
file.secrets.insert(canonical_key, value.to_string());
self.save_file(&file)
}
pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
let canonical_key = scope.canonical_key(name);
let file = self.load_file()?;
Ok(file.secrets.get(&canonical_key).cloned())
}
pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
let canonical_key = scope.canonical_key(name);
let mut file = self.load_file()?;
let removed = file.secrets.remove(&canonical_key).is_some();
if removed {
self.save_file(&file)?;
}
Ok(removed)
}
pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
let file = self.load_file()?;
let mut entries = Vec::new();
for canonical_key in file.secrets.keys() {
let Some(entry) = parse_canonical_key(canonical_key) else {
warn!("skipping invalid canonical secret key: {canonical_key}");
continue;
};
if let Some(scope) = scope_filter
&& entry.scope != *scope
{
continue;
}
entries.push(entry);
}
Ok(entries)
}
fn secrets_dir(&self) -> PathBuf {
self.codex_home.join("secrets")
}
fn secrets_path(&self) -> PathBuf {
self.secrets_dir().join(LOCAL_SECRETS_FILENAME)
}
fn load_file(&self) -> Result<SecretsFile> {
let path = self.secrets_path();
if !path.exists() {
return Ok(SecretsFile::new_empty());
}
let ciphertext = fs::read(&path)
.with_context(|| format!("failed to read secrets file at {}", path.display()))?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = decrypt_with_passphrase(&ciphertext, &passphrase)?;
let mut parsed: SecretsFile = serde_json::from_slice(&plaintext).with_context(|| {
format!(
"failed to deserialize decrypted secrets file at {}",
path.display()
)
})?;
if parsed.version == 0 {
parsed.version = SECRETS_VERSION;
}
anyhow::ensure!(
parsed.version <= SECRETS_VERSION,
"secrets file version {} is newer than supported version {}",
parsed.version,
SECRETS_VERSION
);
Ok(parsed)
}
fn save_file(&self, file: &SecretsFile) -> Result<()> {
let dir = self.secrets_dir();
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create secrets dir {}", dir.display()))?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = serde_json::to_vec(file).context("failed to serialize secrets file")?;
let ciphertext = encrypt_with_passphrase(&plaintext, &passphrase)?;
let path = self.secrets_path();
write_file_atomically(&path, &ciphertext)?;
Ok(())
}
fn load_or_create_passphrase(&self) -> Result<SecretString> {
let account = compute_keyring_account(&self.codex_home);
let loaded = self
.keyring_store
.load(keyring_service(), &account)
.map_err(|err| anyhow::anyhow!(err.message()))
.with_context(|| format!("failed to load secrets key from keyring for {account}"))?;
match loaded {
Some(existing) => Ok(SecretString::from(existing)),
None => {
// Generate a high-entropy key and persist it in the OS keyring.
// This keeps secrets out of plaintext config while remaining
// fully local/offline for the MVP.
let generated = generate_passphrase()?;
self.keyring_store
.save(keyring_service(), &account, generated.expose_secret())
.map_err(|err| anyhow::anyhow!(err.message()))
.context("failed to persist secrets key in keyring")?;
Ok(generated)
}
}
}
}
impl SecretsBackend for LocalSecretsBackend {
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
LocalSecretsBackend::set(self, scope, name, value)
}
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
LocalSecretsBackend::get(self, scope, name)
}
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
LocalSecretsBackend::delete(self, scope, name)
}
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
LocalSecretsBackend::list(self, scope_filter)
}
}
fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<()> {
let dir = path.parent().with_context(|| {
format!(
"failed to compute parent directory for secrets file at {}",
path.display()
)
})?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let tmp_path = dir.join(format!(
".{LOCAL_SECRETS_FILENAME}.tmp-{}-{nonce}",
std::process::id()
));
{
let mut tmp_file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)
.with_context(|| {
format!(
"failed to create temp secrets file at {}",
tmp_path.display()
)
})?;
tmp_file.write_all(contents).with_context(|| {
format!(
"failed to write temp secrets file at {}",
tmp_path.display()
)
})?;
tmp_file.sync_all().with_context(|| {
format!("failed to sync temp secrets file at {}", tmp_path.display())
})?;
}
match fs::rename(&tmp_path, path) {
Ok(()) => Ok(()),
Err(initial_error) => {
#[cfg(target_os = "windows")]
{
if path.exists() {
fs::remove_file(path).with_context(|| {
format!(
"failed to remove existing secrets file at {} before replace",
path.display()
)
})?;
fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to replace secrets file at {} with {}",
path.display(),
tmp_path.display()
)
})?;
return Ok(());
}
}
let _ = fs::remove_file(&tmp_path);
Err(initial_error).with_context(|| {
format!(
"failed to atomically replace secrets file at {} with {}",
path.display(),
tmp_path.display()
)
})
}
}
}
fn generate_passphrase() -> Result<SecretString> {
let mut bytes = [0_u8; 32];
let mut rng = OsRng;
rng.try_fill_bytes(&mut bytes)
.context("failed to generate random secrets key")?;
// Base64 keeps the keyring payload ASCII-safe without reducing entropy.
let encoded = BASE64_STANDARD.encode(bytes);
wipe_bytes(&mut bytes);
Ok(SecretString::from(encoded))
}
fn wipe_bytes(bytes: &mut [u8]) {
for byte in bytes {
// Volatile writes make it much harder for the compiler to elide the wipe.
// SAFETY: `byte` is a valid mutable reference into `bytes`.
unsafe { std::ptr::write_volatile(byte, 0) };
}
compiler_fence(Ordering::SeqCst);
}
fn encrypt_with_passphrase(plaintext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
let recipient = ScryptRecipient::new(passphrase.clone());
encrypt(&recipient, plaintext).context("failed to encrypt secrets file")
}
fn decrypt_with_passphrase(ciphertext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
let identity = ScryptIdentity::new(passphrase.clone());
decrypt(&identity, ciphertext).context("failed to decrypt secrets file")
}
fn parse_canonical_key(canonical_key: &str) -> Option<SecretListEntry> {
let mut parts = canonical_key.split('/');
let scope_kind = parts.next()?;
match scope_kind {
"global" => {
let name = parts.next()?;
if parts.next().is_some() {
return None;
}
let name = SecretName::new(name).ok()?;
Some(SecretListEntry {
scope: SecretScope::Global,
name,
})
}
"env" => {
let environment_id = parts.next()?;
let name = parts.next()?;
if parts.next().is_some() {
return None;
}
let name = SecretName::new(name).ok()?;
let scope = SecretScope::environment(environment_id.to_string()).ok()?;
Some(SecretListEntry { scope, name })
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_keyring_store::tests::MockKeyringStore;
use keyring::Error as KeyringError;
use pretty_assertions::assert_eq;
#[test]
fn load_file_rejects_newer_schema_versions() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
let file = SecretsFile {
version: SECRETS_VERSION + 1,
secrets: BTreeMap::new(),
};
backend.save_file(&file)?;
let error = backend
.load_file()
.expect_err("must reject newer schema version");
assert!(
error.to_string().contains("newer than supported version"),
"unexpected error: {error:#}"
);
Ok(())
}
#[test]
fn set_fails_when_keyring_is_unavailable() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let account = compute_keyring_account(codex_home.path());
keyring.set_error(
&account,
KeyringError::Invalid("error".into(), "load".into()),
);
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
let scope = SecretScope::Global;
let name = SecretName::new("TEST_SECRET")?;
let error = backend
.set(&scope, &name, "secret-value")
.expect_err("must fail when keyring load fails");
assert!(
error
.to_string()
.contains("failed to load secrets key from keyring"),
"unexpected error: {error:#}"
);
Ok(())
}
#[test]
fn save_file_does_not_leave_temp_files() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
let scope = SecretScope::Global;
let name = SecretName::new("TEST_SECRET")?;
backend.set(&scope, &name, "one")?;
backend.set(&scope, &name, "two")?;
let secrets_dir = backend.secrets_dir();
let entries = fs::read_dir(&secrets_dir)
.with_context(|| format!("failed to read {}", secrets_dir.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("failed to enumerate {}", secrets_dir.display()))?;
let filenames: Vec<String> = entries
.into_iter()
.filter_map(|entry| entry.file_name().to_str().map(ToString::to_string))
.collect();
assert_eq!(filenames, vec![LOCAL_SECRETS_FILENAME.to_string()]);
assert_eq!(backend.get(&scope, &name)?, Some("two".to_string()));
Ok(())
}
}
+2 -12
View File
@@ -418,17 +418,7 @@ ON CONFLICT(id) DO UPDATE SET
if tools.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let thread_id = thread_id.to_string();
let existing: Option<i64> =
sqlx::query_scalar("SELECT 1 FROM thread_dynamic_tools WHERE thread_id = ? LIMIT 1")
.bind(thread_id.as_str())
.fetch_optional(&mut *tx)
.await?;
if existing.is_some() {
tx.commit().await?;
return Ok(());
}
for (idx, tool) in tools.iter().enumerate() {
let position = i64::try_from(idx).unwrap_or(i64::MAX);
let input_schema = serde_json::to_string(&tool.input_schema)?;
@@ -441,6 +431,7 @@ INSERT INTO thread_dynamic_tools (
description,
input_schema
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(thread_id, position) DO NOTHING
"#,
)
.bind(thread_id.as_str())
@@ -448,10 +439,9 @@ INSERT INTO thread_dynamic_tools (
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
.execute(&mut *tx)
.execute(self.pool.as_ref())
.await?;
}
tx.commit().await?;
Ok(())
}
-12
View File
@@ -48,7 +48,6 @@ use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::DeprecationNoticeEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
@@ -184,15 +183,6 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI
}
}
fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option<DeprecationNoticeEvent>) {
let Some(DeprecationNoticeEvent { summary, details }) = notice else {
return;
};
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_deprecation_notice(summary, details),
)));
}
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
@@ -926,12 +916,10 @@ impl App {
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
ollama_chat_support_notice: Option<DeprecationNoticeEvent>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice);
emit_project_config_warnings(&app_event_tx, &config);
tui.set_notification_method(config.tui_notification_method);
+1
View File
@@ -2081,6 +2081,7 @@ impl ChatWidget {
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.bottom_pane.ensure_status_indicator();
self.running_commands.insert(
ev.call_id.clone(),
RunningCommand {
+19
View File
@@ -1830,6 +1830,25 @@ async fn streaming_final_answer_keeps_task_running_state() {
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn exec_begin_restores_status_indicator_after_preamble() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
assert_eq!(chat.bottom_pane.is_task_running(), true);
begin_exec(&mut chat, "call-1", "echo hi");
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
}
#[tokio::test]
async fn ctrl_c_shutdown_works_with_caps_lock() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+1 -1
View File
@@ -58,7 +58,7 @@ pub struct Cli {
#[arg(long = "oss", default_value_t = false)]
pub oss: bool,
/// Specify which local provider to use (lmstudio, ollama, or ollama-chat).
/// Specify which local provider to use (lmstudio or ollama).
/// If not specified with --oss, will use config default or show selection.
#[arg(long = "local-provider")]
pub oss_provider: Option<String>,
-10
View File
@@ -11,7 +11,6 @@ use codex_app_server_protocol::AuthMode;
use codex_cloud_requirements::cloud_requirements_loader;
use codex_common::oss::ensure_oss_provider_ready;
use codex_common::oss::get_default_model_for_oss_provider;
use codex_common::oss::ollama_chat_deprecation_notice;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::INTERACTIVE_SESSION_SOURCES;
@@ -508,14 +507,6 @@ async fn run_ratatui_app(
} else {
initial_config
};
let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await {
Ok(notice) => notice,
Err(err) => {
tracing::warn!(?err, "Failed to detect Ollama wire API");
None
}
};
let mut missing_session_exit = |id_str: &str, action: &str| {
error!("Error finding conversation path: {id_str}");
restore();
@@ -701,7 +692,6 @@ async fn run_ratatui_app(
session_selection,
feedback,
should_show_trust_screen, // Proxy to: is it a first run in this directory?
ollama_chat_support_notice,
)
.await;
-7
View File
@@ -4,7 +4,6 @@ use std::sync::LazyLock;
use codex_core::DEFAULT_LMSTUDIO_PORT;
use codex_core::DEFAULT_OLLAMA_PORT;
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
use codex_core::OLLAMA_CHAT_PROVIDER_ID;
use codex_core::OLLAMA_OSS_PROVIDER_ID;
use codex_core::config::set_default_oss_provider;
use crossterm::event::Event;
@@ -75,12 +74,6 @@ static OSS_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
key: KeyCode::Char('o'),
provider_id: OLLAMA_OSS_PROVIDER_ID,
},
SelectOption {
label: Line::from(vec!["Ollama (".into(), "c".underlined(), "hat)".into()]),
description: "Local Ollama server (chat wire API, default port 11434)",
key: KeyCode::Char('c'),
provider_id: OLLAMA_CHAT_PROVIDER_ID,
},
]
});
+1
View File
@@ -0,0 +1 @@
((c-mode . ((indent-tabs-mode . nil) (c-file-style . "gnu"))))
+5
View File
@@ -0,0 +1,5 @@
[*.[ch]]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
indent_brace_style = gnu
+105
View File
@@ -0,0 +1,105 @@
name: CI checks
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
meson:
name: Build with Meson and gcc, and test
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Install build-dependencies
run: sudo ./ci/builddeps.sh
- name: Enable user namespaces
run: sudo ./ci/enable-userns.sh
- name: Create logs dir
run: mkdir test-logs
- name: setup
run: |
meson _build
env:
CFLAGS: >-
-O2
-Wp,-D_FORTIFY_SOURCE=2
-fsanitize=address
-fsanitize=undefined
- name: compile
run: ninja -C _build -v
- name: smoke-test
run: |
set -x
./_build/bwrap --bind / / --tmpfs /tmp true
env:
ASAN_OPTIONS: detect_leaks=0
- name: test
run: |
BWRAP_MUST_WORK=1 meson test -C _build
env:
ASAN_OPTIONS: detect_leaks=0
- name: Collect overall test logs on failure
if: failure()
run: mv _build/meson-logs/testlog.txt test-logs/ || true
- name: install
run: |
DESTDIR="$(pwd)/DESTDIR" meson install -C _build
( cd DESTDIR && find -ls )
- name: dist
run: |
BWRAP_MUST_WORK=1 meson dist -C _build
- name: Collect dist test logs on failure
if: failure()
run: mv _build/meson-private/dist-build/meson-logs/testlog.txt test-logs/disttestlog.txt || true
- name: use as subproject
run: |
mkdir tests/use-as-subproject/subprojects
tar -C tests/use-as-subproject/subprojects -xf _build/meson-dist/bubblewrap-*.tar.xz
mv tests/use-as-subproject/subprojects/bubblewrap-* tests/use-as-subproject/subprojects/bubblewrap
( cd tests/use-as-subproject && meson _build )
ninja -C tests/use-as-subproject/_build -v
meson test -C tests/use-as-subproject/_build
DESTDIR="$(pwd)/DESTDIR-as-subproject" meson install -C tests/use-as-subproject/_build
( cd DESTDIR-as-subproject && find -ls )
test -x DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap
test ! -e DESTDIR-as-subproject/usr/local/bin/bwrap
test ! -e DESTDIR-as-subproject/usr/local/libexec/bwrap
tests/use-as-subproject/assert-correct-rpath.py DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap
- name: Upload test logs
uses: actions/upload-artifact@v4
if: failure() || cancelled()
with:
name: test logs
path: test-logs
clang:
name: Build with clang and analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language:
- cpp
steps:
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Check out
uses: actions/checkout@v4
- name: Install build-dependencies
run: sudo ./ci/builddeps.sh --clang
- run: meson build -Dselinux=enabled
env:
CC: clang
CFLAGS: >-
-O2
-Werror=unused-variable
- run: meson compile -C build
- name: CodeQL analysis
uses: github/codeql-action/analyze@v2
+3
View File
@@ -0,0 +1,3 @@
## The bubblewrap Project Community Code of Conduct
The bubblewrap project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/HEAD/CODE-OF-CONDUCT.md).
+481
View File
@@ -0,0 +1,481 @@
GNU LIBRARY GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the library GPL. It is
numbered 2 because it goes with version 2 of the ordinary GPL.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Library General Public License, applies to some
specially designated Free Software Foundation software, and to any
other libraries whose authors decide to use it. You can use it for
your libraries, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if
you distribute copies of the library, or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link a program with the library, you must provide
complete object files to the recipients so that they can relink them
with the library, after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
Our method of protecting your rights has two steps: (1) copyright
the library, and (2) offer you this license which gives you legal
permission to copy, distribute and/or modify the library.
Also, for each distributor's protection, we want to make certain
that everyone understands that there is no warranty for this free
library. If the library is modified by someone else and passed on, we
want its recipients to know that what they have is not the original
version, so that any problems introduced by others will not reflect on
the original authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that companies distributing free
software will individually obtain patent licenses, thus in effect
transforming the program into proprietary software. To prevent this,
we have made it clear that any patent must be licensed for everyone's
free use or not licensed at all.
Most GNU software, including some libraries, is covered by the ordinary
GNU General Public License, which was designed for utility programs. This
license, the GNU Library General Public License, applies to certain
designated libraries. This license is quite different from the ordinary
one; be sure to read it in full, and don't assume that anything in it is
the same as in the ordinary license.
The reason we have a separate public license for some libraries is that
they blur the distinction we usually make between modifying or adding to a
program and simply using it. Linking a program with a library, without
changing the library, is in some sense simply using the library, and is
analogous to running a utility program or application program. However, in
a textual and legal sense, the linked executable is a combined work, a
derivative of the original library, and the ordinary General Public License
treats it as such.
Because of this blurred distinction, using the ordinary General
Public License for libraries did not effectively promote software
sharing, because most developers did not use the libraries. We
concluded that weaker conditions might promote sharing better.
However, unrestricted linking of non-free programs would deprive the
users of those programs of all benefit from the free status of the
libraries themselves. This Library General Public License is intended to
permit developers of non-free programs to use free libraries, while
preserving your freedom as a user of such programs to change the free
libraries that are incorporated in them. (We have not seen how to achieve
this as regards changes in header files, but we have achieved it as regards
changes in the actual functions of the Library.) The hope is that this
will lead to faster development of free libraries.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, while the latter only
works together with the library.
Note that it is possible for a library to be covered by the ordinary
General Public License rather than by this special one.
GNU LIBRARY GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library which
contains a notice placed by the copyright holder or other authorized
party saying it may be distributed under the terms of this Library
General Public License (also called "this License"). Each licensee is
addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also compile or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
c) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
d) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the source code distributed need not include anything that is normally
distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Library General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Library General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Library General Public License for more details.
You should have received a copy of the GNU Library General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
+1
View File
@@ -0,0 +1 @@
COPYING
+51
View File
@@ -0,0 +1,51 @@
bubblewrap 0.11.0
=================
Released: 2024-10-30
Dependencies:
* Remove the Autotools build system. Meson ≥ 0.49.0 is now required
at build-time. (#625, Hugo Osvaldo Barrera)
* For users of bash-completion, bash-completion ≥ 2.10 is recommended.
With older bash-completion, bubblewrap might install completions
outside its `${prefix}` unless overridden with `-Dbash_completion_dir=…`.
Enhancements:
* New `--overlay`, `--tmp-overlay`, `--ro-overlay` and `--overlay-src`
options allow creation of overlay mounts.
This feature is not available when bubblewrap is installed setuid.
(#412, #663; Ryan Hendrickson, William Manley, Simon McVittie)
* New `--level-prefix` option produces output that can be parsed by
tools like `logger --prio-prefix` and `systemd-cat --level-prefix=1`
(#646, Simon McVittie)
Bug fixes:
* Handle `EINTR` when doing I/O on files or sockets (#657, Simon McVittie)
* Don't make assumptions about alignment of socket control message data
(#637, Simon McVittie)
* Silence some Meson deprecation warnings (#647, @Sertonix)
* Update URLs in documentation to https (#566, @TotalCaesar659)
* Improve tests' compatibility with busybox (#627, @Sertonix)
* Improve compatibility with Meson < 1.3.0 (#664, Simon McVittie)
Internal changes:
* Consistently use `<stdbool.h>` for booleans (#660, Simon McVittie)
* Avoid `-Wshadow` compiler warnings (#661, Simon McVittie)
* Update Github Actions configuration (#658, Simon McVittie)
----
See also <https://github.com/containers/bubblewrap/releases>
+255
View File
@@ -0,0 +1,255 @@
Bubblewrap
==========
Many container runtime tools like `systemd-nspawn`, `docker`,
etc. focus on providing infrastructure for system administrators and
orchestration tools (e.g. Kubernetes) to run containers.
These tools are not suitable to give to unprivileged users, because it
is trivial to turn such access into a fully privileged root shell
on the host.
User namespaces
---------------
There is an effort in the Linux kernel called
[user namespaces](https://www.google.com/search?q=user+namespaces+site%3Ahttps%3A%2F%2Flwn.net)
which attempts to allow unprivileged users to use container features.
While significant progress has been made, there are
[still concerns](https://lwn.net/Articles/673597/) about it, and
it is not available to unprivileged users in several production distributions
such as CentOS/Red Hat Enterprise Linux 7, Debian Jessie, etc.
See for example
[CVE-2016-3135](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-3135)
which is a local root vulnerability introduced by userns.
[This March 2016 post](https://lkml.org/lkml/2016/3/9/555) has some
more discussion.
Bubblewrap could be viewed as setuid implementation of a *subset* of
user namespaces. Emphasis on subset - specifically relevant to the
above CVE, bubblewrap does not allow control over iptables.
The original bubblewrap code existed before user namespaces - it inherits code from
[xdg-app helper](https://cgit.freedesktop.org/xdg-app/xdg-app/tree/common/xdg-app-helper.c?id=4c3bf179e2e4a2a298cd1db1d045adaf3f564532)
which in turn distantly derives from
[linux-user-chroot](https://git.gnome.org/browse/linux-user-chroot).
System security
---------------
The maintainers of this tool believe that it does not, even when used
in combination with typical software installed on that distribution,
allow privilege escalation. It may increase the ability of a logged
in user to perform denial of service attacks, however.
In particular, bubblewrap uses `PR_SET_NO_NEW_PRIVS` to turn off
setuid binaries, which is the [traditional way](https://en.wikipedia.org/wiki/Chroot#Limitations) to get out of things
like chroots.
Sandbox security
----------------
bubblewrap is a tool for constructing sandbox environments.
bubblewrap is not a complete, ready-made sandbox with a specific security
policy.
Some of bubblewrap's use-cases want a security boundary between the sandbox
and the real system; other use-cases want the ability to change the layout of
the filesystem for processes inside the sandbox, but do not aim to be a
security boundary.
As a result, the level of protection between the sandboxed processes and
the host system is entirely determined by the arguments passed to
bubblewrap.
Whatever program constructs the command-line arguments for bubblewrap
(often a larger framework like Flatpak, libgnome-desktop, sandwine
or an ad-hoc script) is responsible for defining its own security model,
and choosing appropriate bubblewrap command-line arguments to implement
that security model.
Some aspects of sandbox security that require particular care are described
in the [Limitations](#limitations) section below.
Users
-----
This program can be shared by all container tools which perform
non-root operation, such as:
- [Flatpak](https://www.flatpak.org)
- [rpm-ostree unprivileged](https://github.com/projectatomic/rpm-ostree/pull/209)
- [bwrap-oci](https://github.com/projectatomic/bwrap-oci)
We would also like to see this be available in Kubernetes/OpenShift
clusters. Having the ability for unprivileged users to use container
features would make it significantly easier to do interactive
debugging scenarios and the like.
Installation
------------
bubblewrap is available in the package repositories of the most Linux distributions
and can be installed from there.
If you need to build bubblewrap from source, you can do this with meson:
```sh
meson _builddir
meson compile -C _builddir
meson test -C _builddir
meson install -C _builddir
```
Usage
-----
bubblewrap works by creating a new, completely empty, mount
namespace where the root is on a tmpfs that is invisible from the
host, and will be automatically cleaned up when the last process
exits. You can then use commandline options to construct the root
filesystem and process environment and command to run in the
namespace.
There's a larger [demo script](./demos/bubblewrap-shell.sh) in the
source code, but here's a trimmed down version which runs
a new shell reusing the host's `/usr`.
```
bwrap \
--ro-bind /usr /usr \
--symlink usr/lib64 /lib64 \
--proc /proc \
--dev /dev \
--unshare-pid \
--new-session \
bash
```
This is an incomplete example, but useful for purposes of
illustration. More often, rather than creating a container using the
host's filesystem tree, you want to target a chroot. There, rather
than creating the symlink `lib64 -> usr/lib64` in the tmpfs, you might
have already created it in the target rootfs.
Sandboxing
----------
The goal of bubblewrap is to run an application in a sandbox, where it
has restricted access to parts of the operating system or user data
such as the home directory.
bubblewrap always creates a new mount namespace, and the user can specify
exactly what parts of the filesystem should be visible in the sandbox.
Any such directories you specify mounted `nodev` by default, and can be made readonly.
Additionally you can use these kernel features:
User namespaces ([CLONE_NEWUSER](https://linux.die.net/man/2/clone)): This hides all but the current uid and gid from the
sandbox. You can also change what the value of uid/gid should be in the sandbox.
IPC namespaces ([CLONE_NEWIPC](https://linux.die.net/man/2/clone)): The sandbox will get its own copy of all the
different forms of IPCs, like SysV shared memory and semaphores.
PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/).
Network namespaces ([CLONE_NEWNET](https://linux.die.net/man/2/clone)): The sandbox will not see the network. Instead it will have its own network namespace with only a loopback device.
UTS namespace ([CLONE_NEWUTS](https://linux.die.net/man/2/clone)): The sandbox will have its own hostname.
Seccomp filters: You can pass in seccomp filters that limit which syscalls can be done in the sandbox. For more information, see [Seccomp](https://en.wikipedia.org/wiki/Seccomp).
Limitations
-----------
As noted in the [Sandbox security](#sandbox-security) section above,
the level of protection between the sandboxed processes and the host system
is entirely determined by the arguments passed to bubblewrap.
Some aspects that require special care are noted here.
- If you are not filtering out `TIOCSTI` commands using seccomp filters,
argument `--new-session` is needed to protect against out-of-sandbox
command execution
(see [CVE-2017-5226](https://github.com/containers/bubblewrap/issues/142)).
- Everything mounted into the sandbox can potentially be used to escalate
privileges.
For example, if you bind a D-Bus socket into the sandbox, it can be used to
execute commands via systemd. You can use
[xdg-dbus-proxy](https://github.com/flatpak/xdg-dbus-proxy) to filter
D-Bus communication.
- Some applications deploy their own sandboxing mechanisms, and these can be
restricted by the constraints imposed by bubblewrap's sandboxing.
For example, some web browsers which configure their child proccesses via
seccomp to not have access to the filesystem. If you limit the syscalls and
don't allow the seccomp syscall, a browser cannot apply these restrictions.
Similarly, if these rules were compiled into a file that is not available in
the sandbox, the browser cannot load these rules from this file and cannot
apply these restrictions.
Related project comparison: Firejail
------------------------------------
[Firejail](https://github.com/netblue30/firejail/tree/HEAD/src/firejail)
is similar to Flatpak before bubblewrap was split out in that it combines
a setuid tool with a lot of desktop-specific sandboxing features. For
example, Firejail knows about Pulseaudio, whereas bubblewrap does not.
The bubblewrap authors believe it's much easier to audit a small
setuid program, and keep features such as Pulseaudio filtering as an
unprivileged process, as now occurs in Flatpak.
Also, @cgwalters thinks trying to
[whitelist file paths](https://github.com/netblue30/firejail/blob/37a5a3545ef6d8d03dad8bbd888f53e13274c9e5/src/firejail/fs_whitelist.c#L176)
is a bad idea given the myriad ways users have to manipulate paths,
and the myriad ways in which system administrators may configure a
system. The bubblewrap approach is to only retain a few specific
Linux capabilities such as `CAP_SYS_ADMIN`, but to always access the
filesystem as the invoking uid. This entirely closes
[TOCTTOU attacks](https://cwe.mitre.org/data/definitions/367.html) and
such.
Related project comparison: Sandstorm.io
----------------------------------------
[Sandstorm.io](https://sandstorm.io/) requires unprivileged user
namespaces to set up its sandbox, though it could easily be adapted
to operate in a setuid mode as well. @cgwalters believes their code is
fairly good, but it could still make sense to unify on bubblewrap.
However, @kentonv (of Sandstorm) feels that while this makes sense
in principle, the switching cost outweighs the practical benefits for
now. This decision could be re-evaluated in the future, but it is not
being actively pursued today.
Related project comparison: runc/binctr
----------------------------------------
[runC](https://github.com/opencontainers/runc) is currently working on
supporting [rootless containers](https://github.com/opencontainers/runc/pull/774),
without needing `setuid` or any other privileges during installation of
runC (using unprivileged user namespaces rather than `setuid`),
creation, and management of containers. However, the standard mode of
using runC is similar to [systemd nspawn](https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html)
in that it is tooling intended to be invoked by root.
The bubblewrap authors believe that runc and systemd-nspawn are not
designed to be made setuid, and are distant from supporting such a mode.
However with rootless containers, runC will be able to fulfill certain usecases
that bubblewrap supports (with the added benefit of being a standardised and
complete OCI runtime).
[binctr](https://github.com/jfrazelle/binctr) is just a wrapper for
runC, so inherits all of its design tradeoffs.
What's with the name?!
----------------------
The name bubblewrap was chosen to convey that this
tool runs as the parent of the application (so wraps it in some sense) and creates
a protective layer (the sandbox) around it.
![](bubblewrap.jpg)
(Bubblewrap cat by [dancing_stupidity](https://www.flickr.com/photos/27549668@N03/))
+42
View File
@@ -0,0 +1,42 @@
## Security and Disclosure Information Policy for the bubblewrap Project
The bubblewrap Project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/HEAD/SECURITY.md) for the Containers Projects.
### System security
If bubblewrap is setuid root, then the goal is that it does not allow
a malicious local user to do anything that would not have been possible
on a kernel that allows unprivileged users to create new user namespaces.
For example, [CVE-2020-5291](https://github.com/containers/bubblewrap/security/advisories/GHSA-j2qp-rvxj-43vj)
was treated as a security vulnerability in bubblewrap.
If bubblewrap is not setuid root, then it is not a security boundary
between the user and the OS, because anything bubblewrap could do, a
malicious user could equally well do by writing their own tool equivalent
to bubblewrap.
### Sandbox security
bubblewrap is a toolkit for constructing sandbox environments.
bubblewrap is not a complete, ready-made sandbox with a specific security
policy.
Some of bubblewrap's use-cases want a security boundary between the sandbox
and the real system; other use-cases want the ability to change the layout of
the filesystem for processes inside the sandbox, but do not aim to be a
security boundary.
As a result, the level of protection between the sandboxed processes and
the host system is entirely determined by the arguments passed to
bubblewrap.
Whatever program constructs the command-line arguments for bubblewrap
(often a larger framework like Flatpak, libgnome-desktop, sandwine
or an ad-hoc script) is responsible for defining its own security model,
and choosing appropriate bubblewrap command-line arguments to implement
that security model.
For example,
[CVE-2017-5226](https://github.com/flatpak/flatpak/security/advisories/GHSA-7gfv-rvfx-h87x)
(in which a Flatpak app could send input to a parent terminal using the
`TIOCSTI` ioctl) is considered to be a Flatpak vulnerability, not a
bubblewrap vulnerability.
+598
View File
@@ -0,0 +1,598 @@
/* bubblewrap
* Copyright (C) 2016 Alexander Larsson
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <sys/mount.h>
#include "utils.h"
#include "bind-mount.h"
static char *
skip_token (char *line, bool eat_whitespace)
{
while (*line != ' ' && *line != '\n')
line++;
if (eat_whitespace && *line == ' ')
line++;
return line;
}
static char *
unescape_inline (char *escaped)
{
char *unescaped, *res;
const char *end;
res = escaped;
end = escaped + strlen (escaped);
unescaped = escaped;
while (escaped < end)
{
if (*escaped == '\\')
{
*unescaped++ =
((escaped[1] - '0') << 6) |
((escaped[2] - '0') << 3) |
((escaped[3] - '0') << 0);
escaped += 4;
}
else
{
*unescaped++ = *escaped++;
}
}
*unescaped = 0;
return res;
}
static bool
match_token (const char *token, const char *token_end, const char *str)
{
while (token != token_end && *token == *str)
{
token++;
str++;
}
if (token == token_end)
return *str == 0;
return false;
}
static unsigned long
decode_mountoptions (const char *options)
{
const char *token, *end_token;
int i;
unsigned long flags = 0;
static const struct { int flag;
const char *name;
} flags_data[] = {
{ 0, "rw" },
{ MS_RDONLY, "ro" },
{ MS_NOSUID, "nosuid" },
{ MS_NODEV, "nodev" },
{ MS_NOEXEC, "noexec" },
{ MS_NOATIME, "noatime" },
{ MS_NODIRATIME, "nodiratime" },
{ MS_RELATIME, "relatime" },
{ 0, NULL }
};
token = options;
do
{
end_token = strchr (token, ',');
if (end_token == NULL)
end_token = token + strlen (token);
for (i = 0; flags_data[i].name != NULL; i++)
{
if (match_token (token, end_token, flags_data[i].name))
{
flags |= flags_data[i].flag;
break;
}
}
if (*end_token != 0)
token = end_token + 1;
else
token = NULL;
}
while (token != NULL);
return flags;
}
typedef struct MountInfo MountInfo;
struct MountInfo {
char *mountpoint;
unsigned long options;
};
typedef MountInfo *MountTab;
static void
mount_tab_free (MountTab tab)
{
int i;
for (i = 0; tab[i].mountpoint != NULL; i++)
free (tab[i].mountpoint);
free (tab);
}
static inline void
cleanup_mount_tabp (void *p)
{
void **pp = (void **) p;
if (*pp)
mount_tab_free ((MountTab)*pp);
}
#define cleanup_mount_tab __attribute__((cleanup (cleanup_mount_tabp)))
typedef struct MountInfoLine MountInfoLine;
struct MountInfoLine {
const char *mountpoint;
const char *options;
bool covered;
int id;
int parent_id;
MountInfoLine *first_child;
MountInfoLine *next_sibling;
};
static unsigned int
count_lines (const char *data)
{
unsigned int count = 0;
const char *p = data;
while (*p != 0)
{
if (*p == '\n')
count++;
p++;
}
/* If missing final newline, add one */
if (p > data && *(p-1) != '\n')
count++;
return count;
}
static int
count_mounts (MountInfoLine *line)
{
MountInfoLine *child;
int res = 0;
if (!line->covered)
res += 1;
child = line->first_child;
while (child != NULL)
{
res += count_mounts (child);
child = child->next_sibling;
}
return res;
}
static MountInfo *
collect_mounts (MountInfo *info, MountInfoLine *line)
{
MountInfoLine *child;
if (!line->covered)
{
info->mountpoint = xstrdup (line->mountpoint);
info->options = decode_mountoptions (line->options);
info ++;
}
child = line->first_child;
while (child != NULL)
{
info = collect_mounts (info, child);
child = child->next_sibling;
}
return info;
}
static MountTab
parse_mountinfo (int proc_fd,
const char *root_mount)
{
cleanup_free char *mountinfo = NULL;
cleanup_free MountInfoLine *lines = NULL;
cleanup_free MountInfoLine **by_id = NULL;
cleanup_mount_tab MountTab mount_tab = NULL;
MountInfo *end_tab;
int n_mounts;
char *line;
unsigned int i;
int max_id;
unsigned int n_lines;
int root;
mountinfo = load_file_at (proc_fd, "self/mountinfo");
if (mountinfo == NULL)
die_with_error ("Can't open /proc/self/mountinfo");
n_lines = count_lines (mountinfo);
lines = xcalloc (n_lines, sizeof (MountInfoLine));
max_id = 0;
line = mountinfo;
i = 0;
root = -1;
while (*line != 0)
{
int rc, consumed = 0;
unsigned int maj, min;
char *end;
char *rest;
char *mountpoint;
char *mountpoint_end;
char *options;
char *options_end;
char *next_line;
assert (i < n_lines);
end = strchr (line, '\n');
if (end != NULL)
{
*end = 0;
next_line = end + 1;
}
else
next_line = line + strlen (line);
rc = sscanf (line, "%d %d %u:%u %n", &lines[i].id, &lines[i].parent_id, &maj, &min, &consumed);
if (rc != 4)
die ("Can't parse mountinfo line");
rest = line + consumed;
rest = skip_token (rest, true); /* mountroot */
mountpoint = rest;
rest = skip_token (rest, false); /* mountpoint */
mountpoint_end = rest++;
options = rest;
rest = skip_token (rest, false); /* vfs options */
options_end = rest;
*mountpoint_end = 0;
lines[i].mountpoint = unescape_inline (mountpoint);
*options_end = 0;
lines[i].options = options;
if (lines[i].id > max_id)
max_id = lines[i].id;
if (lines[i].parent_id > max_id)
max_id = lines[i].parent_id;
if (path_equal (lines[i].mountpoint, root_mount))
root = i;
i++;
line = next_line;
}
assert (i == n_lines);
if (root == -1)
{
mount_tab = xcalloc (1, sizeof (MountInfo));
return steal_pointer (&mount_tab);
}
by_id = xcalloc (max_id + 1, sizeof (MountInfoLine*));
for (i = 0; i < n_lines; i++)
by_id[lines[i].id] = &lines[i];
for (i = 0; i < n_lines; i++)
{
MountInfoLine *this = &lines[i];
MountInfoLine *parent = by_id[this->parent_id];
MountInfoLine **to_sibling;
MountInfoLine *sibling;
bool covered = false;
if (!has_path_prefix (this->mountpoint, root_mount))
continue;
if (parent == NULL)
continue;
if (strcmp (parent->mountpoint, this->mountpoint) == 0)
parent->covered = true;
to_sibling = &parent->first_child;
sibling = parent->first_child;
while (sibling != NULL)
{
/* If this mountpoint is a path prefix of the sibling,
* say this->mp=/foo/bar and sibling->mp=/foo, then it is
* covered by the sibling, and we drop it. */
if (has_path_prefix (this->mountpoint, sibling->mountpoint))
{
covered = true;
break;
}
/* If the sibling is a path prefix of this mount point,
* say this->mp=/foo and sibling->mp=/foo/bar, then the sibling
* is covered, and we drop it.
*/
if (has_path_prefix (sibling->mountpoint, this->mountpoint))
*to_sibling = sibling->next_sibling;
else
to_sibling = &sibling->next_sibling;
sibling = sibling->next_sibling;
}
if (covered)
continue;
*to_sibling = this;
}
n_mounts = count_mounts (&lines[root]);
mount_tab = xcalloc (n_mounts + 1, sizeof (MountInfo));
end_tab = collect_mounts (&mount_tab[0], &lines[root]);
assert (end_tab == &mount_tab[n_mounts]);
return steal_pointer (&mount_tab);
}
bind_mount_result
bind_mount (int proc_fd,
const char *src,
const char *dest,
bind_option_t options,
char **failing_path)
{
bool readonly = (options & BIND_READONLY) != 0;
bool devices = (options & BIND_DEVICES) != 0;
bool recursive = (options & BIND_RECURSIVE) != 0;
unsigned long current_flags, new_flags;
cleanup_mount_tab MountTab mount_tab = NULL;
cleanup_free char *resolved_dest = NULL;
cleanup_free char *dest_proc = NULL;
cleanup_free char *oldroot_dest_proc = NULL;
cleanup_free char *kernel_case_combination = NULL;
cleanup_fd int dest_fd = -1;
int i;
if (src)
{
if (mount (src, dest, NULL, MS_SILENT | MS_BIND | (recursive ? MS_REC : 0), NULL) != 0)
return BIND_MOUNT_ERROR_MOUNT;
}
/* The mount operation will resolve any symlinks in the destination
path, so to find it in the mount table we need to do that too. */
resolved_dest = realpath (dest, NULL);
if (resolved_dest == NULL)
return BIND_MOUNT_ERROR_REALPATH_DEST;
dest_fd = TEMP_FAILURE_RETRY (open (resolved_dest, O_PATH | O_CLOEXEC));
if (dest_fd < 0)
{
if (failing_path != NULL)
*failing_path = steal_pointer (&resolved_dest);
return BIND_MOUNT_ERROR_REOPEN_DEST;
}
/* If we are in a case-insensitive filesystem, mountinfo might contain a
* different case combination of the path we requested to mount.
* This is due to the fact that the kernel, as of the beginning of 2021,
* populates mountinfo with whatever case combination first appeared in the
* dcache; kernel developers plan to change this in future so that it
* reflects the on-disk encoding instead.
* To avoid throwing an error when this happens, we use readlink() result
* instead of the provided @root_mount, so that we can compare the mountinfo
* entries with the same case combination that the kernel is expected to
* use. */
dest_proc = xasprintf ("/proc/self/fd/%d", dest_fd);
oldroot_dest_proc = get_oldroot_path (dest_proc);
kernel_case_combination = readlink_malloc (oldroot_dest_proc);
if (kernel_case_combination == NULL)
{
if (failing_path != NULL)
*failing_path = steal_pointer (&resolved_dest);
return BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD;
}
mount_tab = parse_mountinfo (proc_fd, kernel_case_combination);
if (mount_tab[0].mountpoint == NULL)
{
if (failing_path != NULL)
*failing_path = steal_pointer (&kernel_case_combination);
errno = EINVAL;
return BIND_MOUNT_ERROR_FIND_DEST_MOUNT;
}
assert (path_equal (mount_tab[0].mountpoint, kernel_case_combination));
current_flags = mount_tab[0].options;
new_flags = current_flags | (devices ? 0 : MS_NODEV) | MS_NOSUID | (readonly ? MS_RDONLY : 0);
if (new_flags != current_flags &&
mount ("none", resolved_dest,
NULL, MS_SILENT | MS_BIND | MS_REMOUNT | new_flags, NULL) != 0)
{
if (failing_path != NULL)
*failing_path = steal_pointer (&resolved_dest);
return BIND_MOUNT_ERROR_REMOUNT_DEST;
}
/* We need to work around the fact that a bind mount does not apply the flags, so we need to manually
* apply the flags to all submounts in the recursive case.
* Note: This does not apply the flags to mounts which are later propagated into this namespace.
*/
if (recursive)
{
for (i = 1; mount_tab[i].mountpoint != NULL; i++)
{
current_flags = mount_tab[i].options;
new_flags = current_flags | (devices ? 0 : MS_NODEV) | MS_NOSUID | (readonly ? MS_RDONLY : 0);
if (new_flags != current_flags &&
mount ("none", mount_tab[i].mountpoint,
NULL, MS_SILENT | MS_BIND | MS_REMOUNT | new_flags, NULL) != 0)
{
/* If we can't read the mountpoint we can't remount it, but that should
be safe to ignore because its not something the user can access. */
if (errno != EACCES)
{
if (failing_path != NULL)
*failing_path = xstrdup (mount_tab[i].mountpoint);
return BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT;
}
}
}
}
return BIND_MOUNT_SUCCESS;
}
/**
* Return a string representing bind_mount_result, like strerror().
* If want_errno_p is non-NULL, *want_errno_p is used to indicate whether
* it would make sense to print strerror(saved_errno).
*/
static char *
bind_mount_result_to_string (bind_mount_result res,
const char *failing_path,
bool *want_errno_p)
{
char *string = NULL;
bool want_errno = true;
switch (res)
{
case BIND_MOUNT_ERROR_MOUNT:
string = xstrdup ("Unable to mount source on destination");
break;
case BIND_MOUNT_ERROR_REALPATH_DEST:
string = xstrdup ("realpath(destination)");
break;
case BIND_MOUNT_ERROR_REOPEN_DEST:
string = xasprintf ("open(\"%s\", O_PATH)", failing_path);
break;
case BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD:
string = xasprintf ("readlink(/proc/self/fd/N) for \"%s\"", failing_path);
break;
case BIND_MOUNT_ERROR_FIND_DEST_MOUNT:
string = xasprintf ("Unable to find \"%s\" in mount table", failing_path);
want_errno = false;
break;
case BIND_MOUNT_ERROR_REMOUNT_DEST:
string = xasprintf ("Unable to remount destination \"%s\" with correct flags",
failing_path);
break;
case BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT:
string = xasprintf ("Unable to apply mount flags: remount \"%s\"",
failing_path);
break;
case BIND_MOUNT_SUCCESS:
string = xstrdup ("Success");
break;
default:
string = xstrdup ("(unknown/invalid bind_mount_result)");
break;
}
if (want_errno_p != NULL)
*want_errno_p = want_errno;
return string;
}
void
die_with_bind_result (bind_mount_result res,
int saved_errno,
const char *failing_path,
const char *format,
...)
{
va_list args;
bool want_errno = true;
char *message;
if (bwrap_level_prefix)
fprintf (stderr, "<%d>", LOG_ERR);
fprintf (stderr, "bwrap: ");
va_start (args, format);
vfprintf (stderr, format, args);
va_end (args);
message = bind_mount_result_to_string (res, failing_path, &want_errno);
fprintf (stderr, ": %s", message);
/* message is leaked, but we're exiting unsuccessfully anyway, so ignore */
if (want_errno)
{
switch (res)
{
case BIND_MOUNT_ERROR_MOUNT:
case BIND_MOUNT_ERROR_REMOUNT_DEST:
case BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT:
fprintf (stderr, ": %s", mount_strerror (saved_errno));
break;
case BIND_MOUNT_ERROR_REALPATH_DEST:
case BIND_MOUNT_ERROR_REOPEN_DEST:
case BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD:
case BIND_MOUNT_ERROR_FIND_DEST_MOUNT:
case BIND_MOUNT_SUCCESS:
default:
fprintf (stderr, ": %s", strerror (saved_errno));
}
}
fprintf (stderr, "\n");
exit (1);
}
+54
View File
@@ -0,0 +1,54 @@
/* bubblewrap
* Copyright (C) 2016 Alexander Larsson
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/
#pragma once
#include "utils.h"
typedef enum {
BIND_READONLY = (1 << 0),
BIND_DEVICES = (1 << 2),
BIND_RECURSIVE = (1 << 3),
} bind_option_t;
typedef enum
{
BIND_MOUNT_SUCCESS = 0,
BIND_MOUNT_ERROR_MOUNT,
BIND_MOUNT_ERROR_REALPATH_DEST,
BIND_MOUNT_ERROR_REOPEN_DEST,
BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD,
BIND_MOUNT_ERROR_FIND_DEST_MOUNT,
BIND_MOUNT_ERROR_REMOUNT_DEST,
BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT,
} bind_mount_result;
bind_mount_result bind_mount (int proc_fd,
const char *src,
const char *dest,
bind_option_t options,
char **failing_path);
void die_with_bind_result (bind_mount_result res,
int saved_errno,
const char *failing_path,
const char *format,
...)
__attribute__((__noreturn__))
__attribute__((format (printf, 4, 5)));
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+648
View File
@@ -0,0 +1,648 @@
<?xml version="1.0"?>
<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.3//EN"
"http://www.oasis-open.org/docbook/xml/4.3/docbookx.dtd" [
]>
<refentry id="bwrap">
<refentryinfo>
<title>bwrap</title>
<productname>Containers</productname>
<authorgroup>
<author>
<contrib>Developer</contrib>
<firstname>Alexander</firstname>
<surname>Larsson</surname>
</author>
<author>
<contrib>Developer</contrib>
<firstname>Colin</firstname>
<surname>Walters</surname>
</author>
</authorgroup>
</refentryinfo>
<refmeta>
<refentrytitle>bwrap</refentrytitle>
<manvolnum>1</manvolnum>
<refmiscinfo class="manual">User Commands</refmiscinfo>
</refmeta>
<refnamediv>
<refname>bwrap</refname>
<refpurpose>container setup utility</refpurpose>
</refnamediv>
<refsynopsisdiv>
<cmdsynopsis>
<command>bwrap</command>
<arg choice="opt" rep="repeat"><replaceable>OPTION</replaceable></arg>
<arg choice="opt"><replaceable>COMMAND</replaceable></arg>
</cmdsynopsis>
</refsynopsisdiv>
<refsect1><title>Description</title>
<para>
<command>bwrap</command> is a unprivileged low-level sandboxing tool
(optionally setuid on older distributions). You
are unlikely to use it directly from the commandline, although that is possible.
</para>
<para>
It works by creating a new, completely empty, filesystem namespace where the root
is on a tmpfs that is invisible from the host, and which will be automatically
cleaned up when the last process exits. You can then use commandline options to
construct the root filesystem and process environment for the command to run in
the namespace.
</para>
<para>
By default, <command>bwrap</command> creates a new mount namespace for the sandbox.
Optionally it also sets up new user, ipc, pid, network and uts namespaces (but note the
user namespace is required if bwrap is not installed setuid root).
The application in the sandbox can be made to run with a different UID and GID.
</para>
<para>
If needed (e.g. when using a PID namespace) <command>bwrap</command>
is running a minimal pid 1 process in the sandbox that is
responsible for reaping zombies. It also detects when the initial
application process (pid 2) dies and reports its exit status back to
the original spawner. The pid 1 process exits to clean up the
sandbox when there are no other processes in the sandbox left.
</para>
</refsect1>
<refsect1><title>Options</title>
<para>
When options are used multiple times, the last option wins, unless otherwise
specified.
</para>
<para>General options:</para>
<variablelist>
<varlistentry>
<term><option>--help</option></term>
<listitem><para>Print help and exit</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--version</option></term>
<listitem><para>Print version</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--args <arg choice="plain">FD</arg></option></term>
<listitem><para>
Parse nul-separated arguments from the given file descriptor.
This option can be used multiple times to parse options from
multiple sources.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--argv0 <arg choice="plain">VALUE</arg></option></term>
<listitem><para>Set argv[0] to the value <arg choice="plain">VALUE</arg> before running the program</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--level-prefix</option></term>
<listitem>
<para>
Prefix each line of diagnostic output with a numeric severity
level enclosed in angle brackets.
The severity levels used are based on the constants used by
<citerefentry><refentrytitle>syslog</refentrytitle><manvolnum>3</manvolnum></citerefentry>:
for example, <literal>&lt;4&gt;</literal> indicates a warning,
because <literal>LOG_WARNING</literal> has numeric value 4.
Numbers smaller than 4 indicate fatal errors, and numbers larger
than 4 indicate informational messages.
These prefixes can be parsed by tools compatible with
<literal>logger --prio-prefix</literal> (see
<citerefentry><refentrytitle>logger</refentrytitle><manvolnum>1</manvolnum></citerefentry>)
or <literal>systemd-cat --level-prefix=1</literal> (see
<citerefentry><refentrytitle>systemd-cat</refentrytitle><manvolnum>1</manvolnum></citerefentry>).
</para>
</listitem>
</varlistentry>
</variablelist>
<para>Options related to kernel namespaces:</para>
<variablelist>
<varlistentry>
<term><option>--unshare-user</option></term>
<listitem><para>Create a new user namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-user-try</option></term>
<listitem><para>Create a new user namespace if possible else skip it</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-ipc</option></term>
<listitem><para>Create a new ipc namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-pid</option></term>
<listitem><para>Create a new pid namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-net</option></term>
<listitem><para>Create a new network namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-uts</option></term>
<listitem><para>Create a new uts namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-cgroup</option></term>
<listitem><para>Create a new cgroup namespace</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-cgroup-try</option></term>
<listitem><para>Create a new cgroup namespace if possible else skip it</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unshare-all</option></term>
<listitem><para>Unshare all possible namespaces. Currently equivalent with: <option>--unshare-user-try</option> <option>--unshare-ipc</option> <option>--unshare-pid</option> <option>--unshare-net</option> <option>--unshare-uts</option> <option>--unshare-cgroup-try</option></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--share-net</option></term>
<listitem><para>Retain the network namespace, overriding an earlier <option>--unshare-all</option> or <option>--unshare-net</option></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--userns <arg choice="plain">FD</arg></option></term>
<listitem><para>Use an existing user namespace instead of creating a new one. The namespace must fulfil the permission requirements for setns(), which generally means that it must be a descendant of the currently active user namespace, owned by the same user. </para>
<para>This is incompatible with --unshare-user, and doesn't work in the setuid version of bubblewrap.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--userns2 <arg choice="plain">FD</arg></option></term>
<listitem><para>After setting up the new namespace, switch into the specified namespace. For this to work the specified namespace must be a descendant of the user namespace used for the setup, so this is only useful in combination with --userns.</para>
<para>This is useful because sometimes bubblewrap itself creates nested user namespaces (to work around some kernel issues) and --userns2 can be used to enter these.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--disable-userns</option></term>
<listitem><para>
Prevent the process in the sandbox from creating further user namespaces,
so that it cannot rearrange the filesystem namespace or do other more
complex namespace modification.
This is currently implemented by setting the
<literal>user.max_user_namespaces</literal> sysctl to 1, and then
entering a nested user namespace which is unable to raise that limit
in the outer namespace.
This option requires <option>--unshare-user</option>, and doesn't work
in the setuid version of bubblewrap.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--assert-userns-disabled</option></term>
<listitem><para>
Confirm that the process in the sandbox has been prevented from
creating further user namespaces, but without taking any particular
action to prevent that. For example, this can be combined with
<option>--userns</option> to check that the given user namespace
has already been set up to prevent the creation of further user
namespaces.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--pidns <arg choice="plain">FD</arg></option></term>
<listitem><para>Use an existing pid namespace instead of creating one. This is often used with --userns, because the pid namespace must be owned by the same user namespace that bwrap uses. </para>
<para>Note that this can be combined with --unshare-pid, and in that case it means that the sandbox will be in its own pid namespace, which is a child of the passed in one.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--uid <arg choice="plain">UID</arg></option></term>
<listitem><para>Use a custom user id in the sandbox (requires <option>--unshare-user</option>)</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--gid <arg choice="plain">GID</arg></option></term>
<listitem><para>Use a custom group id in the sandbox (requires <option>--unshare-user</option>)</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--hostname <arg choice="plain">HOSTNAME</arg></option></term>
<listitem><para>Use a custom hostname in the sandbox (requires <option>--unshare-uts</option>)</para></listitem>
</varlistentry>
</variablelist>
<para>Options about environment setup:</para>
<variablelist>
<varlistentry>
<term><option>--chdir <arg choice="plain">DIR</arg></option></term>
<listitem><para>Change directory to <arg choice="plain">DIR</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--setenv <arg choice="plain">VAR</arg> <arg choice="plain">VALUE</arg></option></term>
<listitem><para>Set an environment variable</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--unsetenv <arg choice="plain">VAR</arg></option></term>
<listitem><para>Unset an environment variable</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--clearenv</option></term>
<listitem><para>Unset all environment variables, except for
<envar>PWD</envar> and any that are subsequently set by
<option>--setenv</option></para></listitem>
</varlistentry>
</variablelist>
<para>Options for monitoring the sandbox from the outside:</para>
<variablelist>
<varlistentry>
<term><option>--lock-file <arg choice="plain">DEST</arg></option></term>
<listitem><para>
Take a lock on <arg choice="plain">DEST</arg> while the sandbox is running.
This option can be used multiple times to take locks on multiple files.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--sync-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>Keep this file descriptor open while the sandbox is running</para></listitem>
</varlistentry>
</variablelist>
<para>
Filesystem related options. These are all operations that modify the filesystem directly, or
mounts stuff in the filesystem. These are applied in the order they are given as arguments.
</para>
<para>
Any missing parent directories that are required to create a specified destination are
automatically created as needed. Their permissions are normally set to 0755
(rwxr-xr-x). However, if a <option>--perms</option> option is in effect, and
it sets the permissions for group or other to zero, then newly-created
parent directories will also have their corresponding permission set to zero.
<option>--size</option> modifies the size of the created mount when preceding a
<option>--tmpfs</option> action; <option>--perms</option> and <option>--size</option>
can be combined.
</para>
<variablelist>
<varlistentry>
<term><option>--perms <arg choice="plain">OCTAL</arg></option></term>
<listitem><para>This option does nothing on its own, and must be followed
by one of the options that it affects. It sets the permissions
for the next operation to <arg choice="plain">OCTAL</arg>.
Subsequent operations are not affected: for example,
<literal>--perms 0700 --tmpfs /a --tmpfs /b</literal> will mount
<filename>/a</filename> with permissions 0700, then return to
the default permissions for <filename>/b</filename>.
Note that <option>--perms</option> and <option>--size</option> can be
combined: <literal>--perms 0700 --size 10485760 --tmpfs /s</literal> will apply
permissions as well as a maximum size to the created tmpfs.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--size <arg choice="plain">BYTES</arg></option></term>
<listitem><para>This option does nothing on its own, and must be followed
by <literal>--tmpfs</literal>. It sets the size in bytes for the next tmpfs.
For example, <literal>--size 10485760 --tmpfs /tmp</literal> will create a tmpfs
at <filename>/tmp</filename> of size 10MiB. Subsequent operations are not
affected: for example,
<literal>--size 10485760 --tmpfs /a --tmpfs /b</literal> will mount
<filename>/a</filename> with size 10MiB, then return to the default size for
<filename>/b</filename>.
Note that <option>--perms</option> and <option>--size</option> can be
combined: <literal>--size 10485760 --perms 0700 --tmpfs /s</literal> will apply
permissions as well as a maximum size to the created tmpfs.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--bind <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Bind mount the host path <arg choice="plain">SRC</arg> on <arg choice="plain">DEST</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--bind-try <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Equal to <option>--bind</option> but ignores non-existent <arg choice="plain">SRC</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--dev-bind <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Bind mount the host path <arg choice="plain">SRC</arg> on <arg choice="plain">DEST</arg>, allowing device access</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--dev-bind-try <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Equal to <option>--dev-bind</option> but ignores non-existent <arg choice="plain">SRC</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--ro-bind <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Bind mount the host path <arg choice="plain">SRC</arg> readonly on <arg choice="plain">DEST</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--ro-bind-try <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem><para>Equal to <option>--ro-bind</option> but ignores non-existent <arg choice="plain">SRC</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--remount-ro <arg choice="plain">DEST</arg></option></term>
<listitem><para>Remount the path <arg choice="plain">DEST</arg> as readonly. It works only on the specified mount point, without changing any other mount point under the specified path</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--overlay-src <arg choice="plain">SRC</arg></option></term>
<listitem>
<para>
This option does nothing on its own, and must be followed by one of
the other <literal>overlay</literal> options. It specifies a host
path from which files should be read if they aren't present in a
higher layer.
</para>
<para>
This option can be used multiple times to provide multiple sources.
The sources are overlaid in the order given, with the first source on
the command line at the bottom of the stack: if a given path to be
read exists in more than one source, the file is read from the last
such source specified.
</para>
<para>
(For readers familiar with overlayfs, note that this is the
reverse of the order used by the kernel's <literal>lowerdir</literal>
mount option.)
</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--overlay <arg choice="plain">RWSRC</arg> <arg choice="plain">WORKDIR</arg> <arg choice="plain">DEST</arg></option></term>
</varlistentry>
<varlistentry>
<term><option>--tmp-overlay <arg choice="plain">DEST</arg></option></term>
</varlistentry>
<varlistentry>
<term><option>--ro-overlay <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>
Use overlayfs to mount the host paths specified by
<arg choice="plain">RWSRC</arg> and all immediately preceding
<option>--overlay-src</option> on <arg choice="plain">DEST</arg>.
<arg choice="plain">DEST</arg> will contain the union of all the files
in all the layers.
</para>
<para>
With <arg choice="plain">--overlay</arg> all writes will go to
<arg choice="plain">RWSRC</arg>. Reads will come preferentially from
<arg choice="plain">RWSRC</arg>, and then from any
<option>--overlay-src</option> paths.
<arg choice="plain">WORKDIR</arg> must be an empty directory on the
same filesystem as <arg choice="plain">RWSRC</arg>, and is used
internally by the kernel.
</para>
<para>
With <arg choice="plain">--tmp-overlay</arg> all writes will go to
the tmpfs that hosts the sandbox root, in a location not accessible
from either the host or the child process. Writes will therefore not
be persisted across multiple runs.
</para>
<para>
With <arg choice="plain">--ro-overlay</arg> the filesystem will be
mounted read-only. This option requires at least two
<option>--overlay-src</option> to precede it.
</para>
<para>
None of these options are available in the setuid version of
bubblewrap. Using <arg choice="plain">--ro-overlay</arg> or providing
more than one <option>--overlay-src</option> requires a Linux kernel
version of 4.0 or later.
</para>
<para>
Due to limitations of overlayfs, no host directory given via
<arg choice="plain">--overlay-src</arg> or
<arg choice="plain">--overlay</arg> may be an ancestor of another,
after resolving symlinks. Depending on version, the Linux kernel may
or may not enforce this, but if not then overlayfs's behavior is
undefined.
</para>
<para>
For more information see the Overlay Filesystem documentation in the
Linux kernel at
https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt
</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--proc <arg choice="plain">DEST</arg></option></term>
<listitem><para>Mount procfs on <arg choice="plain">DEST</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--dev <arg choice="plain">DEST</arg></option></term>
<listitem><para>Mount new devtmpfs on <arg choice="plain">DEST</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--tmpfs <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Mount new tmpfs on <arg choice="plain">DEST</arg>.
If the previous option was <option>--perms</option>, it sets the
mode of the tmpfs. Otherwise, the tmpfs has mode 0755.
If the previous option was <option>--size</option>, it sets the
size in bytes of the tmpfs. Otherwise, the tmpfs has the default size.</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--mqueue <arg choice="plain">DEST</arg></option></term>
<listitem><para>Mount new mqueue on <arg choice="plain">DEST</arg></para></listitem>
</varlistentry>
<varlistentry>
<term><option>--dir <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Create a directory at <arg choice="plain">DEST</arg>.
If the directory already exists, its permissions are unmodified,
ignoring <option>--perms</option> (use <option>--chmod</option>
if the permissions of an existing directory need to be changed).
If the directory is newly created and the previous option was
<option>--perms</option>, it sets the mode of the directory.
Otherwise, newly-created directories have mode 0755.</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--file <arg choice="plain">FD</arg> <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Copy from the file descriptor <arg choice="plain">FD</arg> to
<arg choice="plain">DEST</arg>.
If the previous option was <option>--perms</option>, it sets the
mode of the new file. Otherwise, the file has mode 0666
(note that this is not the same as <option>--bind-data</option>).</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--bind-data <arg choice="plain">FD</arg> <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Copy from the file descriptor <arg choice="plain">FD</arg> to
a file which is bind-mounted on <arg choice="plain">DEST</arg>.
If the previous option was <option>--perms</option>, it sets the
mode of the new file. Otherwise, the file has mode 0600
(note that this is not the same as <option>--file</option>).</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--ro-bind-data <arg choice="plain">FD</arg> <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Copy from the file descriptor <arg choice="plain">FD</arg> to
a file which is bind-mounted read-only on
<arg choice="plain">DEST</arg>.
If the previous option was <option>--perms</option>, it sets the
mode of the new file. Otherwise, the file has mode 0600
(note that this is not the same as <option>--file</option>).</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--symlink <arg choice="plain">SRC</arg> <arg choice="plain">DEST</arg></option></term>
<listitem>
<para>Create a symlink at <arg choice="plain">DEST</arg> with target
<arg choice="plain">SRC</arg>.</para>
<para>Since version 0.9.0, it is not considered to be an error if
<arg choice="plain">DEST</arg> already exists as a symbolic link and its
target is exactly <arg choice="plain">SRC</arg>.</para>
<para>Before version 0.9.0, if <arg choice="plain">DEST</arg> already
existed, this would be treated as an error (even if its target
was identical to <arg choice="plain">SRC</arg>).</para>
</listitem>
</varlistentry>
<varlistentry>
<term><option>--chmod <arg choice="plain">OCTAL</arg> <arg choice="plain">PATH</arg></option></term>
<listitem>
<para>
Set the permissions of <arg choice="plain">PATH</arg>, which
must already exist, to <arg choice="plain">OCTAL</arg>.
</para>
</listitem>
</varlistentry>
</variablelist>
<para>Lockdown options:</para>
<variablelist>
<varlistentry>
<term><option>--seccomp <arg choice="plain">FD</arg></option></term>
<listitem><para>
Load and use seccomp rules from <arg choice="plain">FD</arg>.
The rules need to be in the form of a compiled cBPF program,
as generated by seccomp_export_bpf.
If this option is given more than once, only the last one is used.
Use <option>--add-seccomp-fd</option> if multiple seccomp programs
are needed.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--add-seccomp-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>
Load and use seccomp rules from <arg choice="plain">FD</arg>.
The rules need to be in the form of a compiled cBPF program,
as generated by seccomp_export_bpf.
This option can be repeated, in which case all the seccomp
programs will be loaded in the order given (note that the kernel
will evaluate them in reverse order, so the last program on the
bwrap command-line is evaluated first). All of them, except
possibly the last, must allow use of the PR_SET_SECCOMP prctl.
This option cannot be combined with <option>--seccomp</option>.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--exec-label <arg choice="plain">LABEL</arg></option></term>
<listitem><para>
Exec Label from the sandbox. On an SELinux system you can specify the SELinux
context for the sandbox process(s).
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--file-label <arg choice="plain">LABEL</arg></option></term>
<listitem><para>
File label for temporary sandbox content. On an SELinux system you can specify
the SELinux context for the sandbox content.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--block-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>
Block the sandbox on reading from FD until some data is available.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--userns-block-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>
Do not initialize the user namespace but wait on FD until it is ready. This allow
external processes (like newuidmap/newgidmap) to setup the user namespace before it
is used by the sandbox process.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--info-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>
Write information in JSON format about the sandbox to FD.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--json-status-fd <arg choice="plain">FD</arg></option></term>
<listitem><para>
Multiple JSON documents are written to <arg choice="plain">FD</arg>,
one per line (<ulink url="https://jsonlines.org/">"JSON lines" format</ulink>).
Each line is a single JSON object.
After <command>bwrap</command> has started the child process inside the sandbox,
it writes an object with a <literal>child-pid</literal> member to the
<option>--json-status-fd</option> (this duplicates the older <option>--info-fd</option>).
The corresponding value is the process ID of the child process in the pid namespace from
which <command>bwrap</command> was run.
If available, the namespace IDs are also included in the object with the <literal>child-pid</literal>;
again, this duplicates the older <option>--info-fd</option>.
When the child process inside the sandbox exits, <command>bwrap</command> writes an object
with an exit-code member, and then closes the <option>--json-status-fd</option>. The value
corresponding to <literal>exit-code</literal> is the exit status of the child, in the usual
shell encoding (n if it exited normally with status n, or 128+n if it was killed by signal n).
Other members may be added to those objects in future versions of <command>bwrap</command>,
and other JSON objects may be added before or after the current objects, so readers must
ignore members and objects that they do not understand.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--new-session</option></term>
<listitem><para>
Create a new terminal session for the sandbox (calls setsid()). This
disconnects the sandbox from the controlling terminal which means
the sandbox can't for instance inject input into the terminal.
</para><para>
Note: In a general sandbox, if you don't use --new-session, it is
recommended to use seccomp to disallow the TIOCSTI ioctl, otherwise
the application can feed keyboard input to the terminal
which can e.g. lead to out-of-sandbox command execution
(see CVE-2017-5226).
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--die-with-parent</option></term>
<listitem><para>
Ensures child process (COMMAND) dies when bwrap's parent dies. Kills (SIGKILL)
all bwrap sandbox processes in sequence from parent to child
including COMMAND process when bwrap or bwrap's parent dies.
See prctl, PR_SET_PDEATHSIG.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--as-pid-1</option></term>
<listitem><para>
Do not create a process with PID=1 in the sandbox to reap child processes.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--cap-add <arg choice="plain">CAP</arg></option></term>
<listitem><para>
Add the specified capability <arg choice="plain">CAP</arg>, e.g.
CAP_DAC_READ_SEARCH, when running as privileged user. It accepts
the special value ALL to add all the permitted caps.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--cap-drop <arg choice="plain">CAP</arg></option></term>
<listitem><para>
Drop the specified capability when running as privileged user. It accepts
the special value ALL to drop all the caps.
By default no caps are left in the sandboxed process. The
<option>--cap-add</option> and <option>--cap-drop</option>
options are processed in the order they are specified on the
command line. Please be careful to the order they are specified.
</para></listitem>
</varlistentry>
</variablelist>
</refsect1>
<refsect1>
<title>Environment</title>
<variablelist>
<varlistentry>
<term><envar>HOME</envar></term>
<listitem><para>
Used as the cwd in the sandbox if <option>--chdir</option> has not been
explicitly specified and the current cwd is not present inside the sandbox.
The <option>--setenv</option> option can be used to override the value
that is used here.
</para></listitem>
</varlistentry>
</variablelist>
</refsect1>
<refsect1>
<title>Exit status</title>
<para>
The <command>bwrap</command> command returns the exit status of the
initial application process (pid 2 in the sandbox).
</para>
</refsect1>
</refentry>
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Copyright 2021 Simon McVittie
# SPDX-License-Identifier: LGPL-2.0-or-later
set -eux
set -o pipefail
usage() {
if [ "${1-2}" -ne 0 ]; then
exec >&2
fi
cat <<EOF
Usage: see source code
EOF
exit "${1-2}"
}
opt_clang=
getopt_temp="help"
getopt_temp="$getopt_temp,clang"
getopt_temp="$(getopt -o '' --long "${getopt_temp}" -n "$0" -- "$@")"
eval set -- "$getopt_temp"
unset getopt_temp
while true; do
case "$1" in
(--clang)
clang=yes
shift
;;
(--help)
usage 0
# not reached
;;
(--)
shift
break
;;
(*)
echo 'Error parsing options' >&2
usage 2
;;
esac
done
# No more arguments please
for arg in "$@"; do
usage 2
done
if dpkg-vendor --derives-from Debian; then
apt-get -y update
apt-get -q -y install \
build-essential \
docbook-xml \
docbook-xsl \
libcap-dev \
libselinux1-dev \
libtool \
meson \
pkg-config \
python3 \
xsltproc \
${NULL+}
if [ -n "${opt_clang}" ]; then
apt-get -y install clang
fi
exit 0
fi
if command -v yum; then
yum -y install \
'pkgconfig(libselinux)' \
/usr/bin/eu-readelf \
docbook-style-xsl \
gcc \
git \
libasan \
libcap-devel \
libtool \
libtsan \
libubsan \
libxslt \
make \
meson \
redhat-rpm-config \
rsync \
${NULL+}
if [ -n "${opt_clang}" ]; then
yum -y install clang
fi
exit 0
fi
echo "Unknown distribution" >&2
exit 1
# vim:set sw=4 sts=4 et:
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -e
echo "kernel.apparmor_restrict_unprivileged_userns = 0" > /etc/sysctl.d/99-userns.conf
sysctl --system
+80
View File
@@ -0,0 +1,80 @@
# shellcheck shell=bash
# bash completion file for bubblewrap commands
#
_bwrap() {
local cur prev words cword
_init_completion || return
# Please keep sorted in LC_ALL=C order
local boolean_options="
--as-pid-1
--assert-userns-disabled
--clearenv
--disable-userns
--help
--new-session
--unshare-all
--unshare-cgroup
--unshare-cgroup-try
--unshare-ipc
--unshare-net
--unshare-pid
--unshare-user
--unshare-user-try
--unshare-uts
--version
"
# Please keep sorted in LC_ALL=C order
local options_with_args="
$boolean_optons
--add-seccomp-fd
--args
--argv0
--bind
--bind-data
--block-fd
--cap-add
--cap-drop
--chdir
--chmod
--dev
--dev-bind
--die-with-parent
--dir
--exec-label
--file
--file-label
--gid
--hostname
--info-fd
--lock-file
--overlay
--overlay-src
--perms
--proc
--remount-ro
--ro-bind
--ro-overlay
--seccomp
--setenv
--size
--symlink
--sync-fd
--tmp-overlay
--uid
--unsetenv
--userns-block-fd
"
if [[ "$cur" == -* ]]; then
COMPREPLY=( $( compgen -W "$boolean_options $options_with_args" -- "$cur" ) )
fi
return 0
}
complete -F _bwrap bwrap
# vim:set ft=bash:
+35
View File
@@ -0,0 +1,35 @@
bash_completion_dir = get_option('bash_completion_dir')
if bash_completion_dir == ''
bash_completion = dependency(
'bash-completion',
version : '>=2.0',
required : false,
)
if bash_completion.found()
if meson.version().version_compare('>=0.51.0')
bash_completion_dir = bash_completion.get_variable(
default_value: '',
pkgconfig: 'completionsdir',
pkgconfig_define: [
'datadir', get_option('prefix') / get_option('datadir'),
],
)
else
bash_completion_dir = bash_completion.get_pkgconfig_variable(
'completionsdir',
default: '',
define_variable: [
'datadir', get_option('prefix') / get_option('datadir'),
],
)
endif
endif
endif
if bash_completion_dir == ''
bash_completion_dir = get_option('datadir') / 'bash-completion' / 'completions'
endif
install_data('bwrap', install_dir : bash_completion_dir)
+7
View File
@@ -0,0 +1,7 @@
if get_option('bash_completion').enabled()
subdir('bash')
endif
if get_option('zsh_completion').enabled()
subdir('zsh')
endif
+115
View File
@@ -0,0 +1,115 @@
#compdef bwrap
_bwrap_args_after_perms_size=(
# Please sort alphabetically (in LC_ALL=C order) by option name
'--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/'
)
_bwrap_args_after_perms=(
# Please sort alphabetically (in LC_ALL=C order) by option name
'--bind-data[Copy from FD to file which is bind-mounted on DEST]: :_guard "[0-9]#" "file descriptor to read content":destination:_files'
'--dir[Create dir at DEST]:directory to create:_files -/'
'--file[Copy from FD to destination DEST]: :_guard "[0-9]#" "file descriptor to read content from":destination:_files'
'--ro-bind-data[Copy from FD to file which is readonly bind-mounted on DEST]: :_guard "[0-9]#" "file descriptor to read content from":destination:_files'
'--size[Set size in bytes for next action argument]: :->after_perms_size'
'--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/'
)
_bwrap_args_after_size=(
# Please sort alphabetically (in LC_ALL=C order) by option name
'--perms[Set permissions for next action argument]: :_guard "[0-7]#" "permissions in octal": :->after_perms_size'
'--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/'
)
_bwrap_args=(
'*::arguments:_normal'
$_bwrap_args_after_perms
# Please sort alphabetically (in LC_ALL=C order) by option name
'--add-seccomp-fd[Load and use seccomp rules from FD]: :_guard "[0-9]#" "file descriptor to read seccomp rules from"'
'--assert-userns-disabled[Fail unless further use of user namespace inside sandbox is disabled]'
'--args[Parse NUL-separated args from FD]: :_guard "[0-9]#" "file descriptor with NUL-separated arguments"'
'--argv0[Set argv0 to the value VALUE before running the program]:value:'
'--as-pid-1[Do not install a reaper process with PID=1]'
'--bind-try[Equal to --bind but ignores non-existent SRC]:source:_files:destination:_files'
'--bind[Bind mount the host path SRC on DEST]:source:_files:destination:_files'
'--block-fd[Block on FD until some data to read is available]: :_guard "[0-9]#" "file descriptor to block on"'
'--cap-add[Add cap CAP when running as privileged user]:capability to add:->caps'
'--cap-drop[Drop cap CAP when running as privileged user]:capability to add:->caps'
'--chdir[Change directory to DIR]:working directory for sandbox: _files -/'
'--chmod[Set permissions]: :_guard "[0-7]#" "permissions in octal":path to set permissions:_files'
'--clearenv[Unset all environment variables]'
'--dev-bind-try[Equal to --dev-bind but ignores non-existent SRC]:source:_files:destination:_files'
'--dev-bind[Bind mount the host path SRC on DEST, allowing device access]:source:_files:destination:_files'
'--dev[Mount new dev on DEST]:mount point for /dev:_files -/'
"--die-with-parent[Kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.]"
'--disable-userns[Disable further use of user namespaces inside sandbox]'
'--exec-label[Exec label for the sandbox]:SELinux label:_selinux_contexts'
'--file-label[File label for temporary sandbox content]:SELinux label:_selinux_contexts'
'--gid[Custom gid in the sandbox (requires --unshare-user or --userns)]: :_guard "[0-9]#" "numeric group ID"'
'--help[Print help and exit]'
'--hostname[Custom hostname in the sandbox (requires --unshare-uts)]:hostname:'
'--info-fd[Write information about the running container to FD]: :_guard "[0-9]#" "file descriptor to write to"'
'--json-status-fd[Write container status to FD as multiple JSON documents]: :_guard "[0-9]#" "file descriptor to write to"'
'--lock-file[Take a lock on DEST while sandbox is running]:lock file:_files'
'--mqueue[Mount new mqueue on DEST]:mount point for mqueue:_files -/'
'--new-session[Create a new terminal session]'
'--perms[Set permissions for next action argument]: :_guard "[0-7]#" "permissions in octal": :->after_perms'
'--pidns[Use this user namespace (as parent namespace if using --unshare-pid)]: :'
'--proc[Mount new procfs on DEST]:mount point for procfs:_files -/'
'--remount-ro[Remount DEST as readonly; does not recursively remount]:mount point to remount read-only:_files'
'--ro-bind-try[Equal to --ro-bind but ignores non-existent SRC]:source:_files:destination:_files'
'--ro-bind[Bind mount the host path SRC readonly on DEST]:source:_files:destination:_files'
'--seccomp[Load and use seccomp rules from FD]: :_guard "[0-9]#" "file descriptor to read seccomp rules from"'
'--setenv[Set an environment variable]:variable to set:_parameters -g "*export*":value of variable: :'
'--size[Set size in bytes for next action argument]: :->after_size'
'--symlink[Create symlink at DEST with target SRC]:symlink target:_files:symlink to create:_files:'
'--sync-fd[Keep this fd open while sandbox is running]: :_guard "[0-9]#" "file descriptor to keep open"'
'--uid[Custom uid in the sandbox (requires --unshare-user or --userns)]: :_guard "[0-9]#" "numeric group ID"'
'(--clearenv)--unsetenv[Unset an environment variable]:variable to unset:_parameters -g "*export*"'
'--unshare-all[Unshare every namespace we support by default]'
'--unshare-cgroup-try[Create new cgroup namespace if possible else continue by skipping it]'
'--unshare-cgroup[Create new cgroup namespace]'
'--unshare-ipc[Create new ipc namespace]'
'--unshare-net[Create new network namespace]'
'--unshare-pid[Create new pid namespace]'
'(--userns --userns2)--unshare-user[Create new user namespace (may be automatically implied if not setuid)]'
'--unshare-user-try[Create new user namespace if possible else continue by skipping it]'
'--unshare-uts[Create new uts namespace]'
'(--unshare-user)--userns[Use this user namespace (cannot combine with --unshare-user)]: :'
'--userns-block-fd[Block on FD until the user namespace is ready]: :_guard "[0-9]#" "file descriptor to block on"'
'(--unshare-user)--userns2[After setup switch to this user namespace, only useful with --userns]: :'
'--version[Print version]'
)
_bwrap() {
_arguments -S $_bwrap_args
case "$state" in
after_perms)
_values -S ' ' 'option' $_bwrap_args_after_perms
;;
after_size)
_values -S ' ' 'option' $_bwrap_args_after_size
;;
after_perms_size)
_values -S ' ' 'option' $_bwrap_args_after_perms_size
;;
caps)
# $ grep -E '#define\sCAP_\w+\s+[0-9]+' /usr/include/linux/capability.h | awk '{print $2}' | xargs echo
local all_caps=(
CAP_CHOWN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_FSETID \
CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_LINUX_IMMUTABLE \
CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_ADMIN CAP_NET_RAW \
CAP_IPC_LOCK CAP_IPC_OWNER CAP_SYS_MODULE CAP_SYS_RAWIO CAP_SYS_CHROOT \
CAP_SYS_PTRACE CAP_SYS_PACCT CAP_SYS_ADMIN CAP_SYS_BOOT CAP_SYS_NICE \
CAP_SYS_RESOURCE CAP_SYS_TIME CAP_SYS_TTY_CONFIG CAP_MKNOD CAP_LEASE \
CAP_AUDIT_WRITE CAP_AUDIT_CONTROL CAP_SETFCAP CAP_MAC_OVERRIDE \
CAP_MAC_ADMIN CAP_SYSLOG CAP_WAKE_ALARM CAP_BLOCK_SUSPEND CAP_AUDIT_READ
)
_values 'caps' $all_caps
;;
esac
}
@@ -0,0 +1,7 @@
zsh_completion_dir = get_option('zsh_completion_dir')
if zsh_completion_dir == ''
zsh_completion_dir = get_option('datadir') / 'zsh' / 'site-functions'
endif
install_data('_bwrap', install_dir : zsh_completion_dir)

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