diff --git a/.codex/skills/secrets-poc/SKILL.json b/.codex/skills/secrets-poc/SKILL.json new file mode 100644 index 0000000000..156d93c0d2 --- /dev/null +++ b/.codex/skills/secrets-poc/SKILL.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "tools": [ + { + "type": "env_var", + "value": "TEST_SECRET", + "description": "POC secret" + } + ] + } +} diff --git a/.codex/skills/secrets-poc/SKILL.md b/.codex/skills/secrets-poc/SKILL.md new file mode 100644 index 0000000000..aaab29de93 --- /dev/null +++ b/.codex/skills/secrets-poc/SKILL.md @@ -0,0 +1,6 @@ +--- +name: secrets-poc +description: Secrets auth POC (repo-local) +--- + +When invoked, run `printenv TEST_SECRET` with the shell tool and report it. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 38d44c44b5..c606b38b5a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", @@ -686,6 +739,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -698,6 +757,21 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[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" @@ -717,7 +791,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.104", ] @@ -911,6 +985,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" @@ -950,6 +1048,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1081,7 +1180,7 @@ dependencies = [ "anyhow", "app_test_support", "axum", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-arg0", @@ -1295,7 +1394,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "chrono", "clap", "codex-cloud-tasks-client", @@ -1352,13 +1451,14 @@ dependencies = [ name = "codex-core" version = "0.0.0" dependencies = [ + "age", "anyhow", "arc-swap", "assert_cmd", "assert_matches", "async-channel", "async-trait", - "base64", + "base64 0.22.1", "chardetng", "chrono", "clap", @@ -1639,7 +1739,7 @@ name = "codex-login" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-core", @@ -1879,7 +1979,7 @@ dependencies = [ "anyhow", "arboard", "assert_matches", - "base64", + "base64 0.22.1", "chrono", "clap", "codex-ansi-escape", @@ -1983,7 +2083,7 @@ dependencies = [ name = "codex-utils-image" version = "0.0.0" dependencies = [ - "base64", + "base64 0.22.1", "codex-utils-cache", "image", "tempfile", @@ -2036,7 +2136,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-protocol", "codex-utils-absolute-path", @@ -2187,6 +2287,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" @@ -2219,7 +2328,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "base64", + "base64 0.22.1", "codex-core", "codex-protocol", "codex-utils-absolute-path", @@ -2409,6 +2518,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.104", +] + [[package]] name = "darling" version = "0.20.11" @@ -3099,6 +3234,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" @@ -3110,6 +3251,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.7" @@ -3164,6 +3314,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" @@ -3816,7 +4010,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3836,6 +4030,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.104", + "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.104", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -4160,6 +4420,25 @@ dependencies = [ "syn 2.0.104", ] +[[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.20" @@ -4169,6 +4448,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" @@ -5042,7 +5327,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.16", "http 1.3.1", @@ -5154,6 +5439,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[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.73" @@ -5273,7 +5564,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", @@ -5420,6 +5711,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" @@ -5530,7 +5831,7 @@ version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ - "base64", + "base64 0.22.1", "indexmap 2.12.0", "quick-xml 0.38.0", "serde", @@ -5564,6 +5865,17 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -5681,6 +5993,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.104", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -5820,7 +6154,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.6.1", "thiserror 2.0.17", @@ -5840,7 +6174,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -5986,7 +6320,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", @@ -6066,7 +6400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" dependencies = [ "ahash", - "base64", + "base64 0.22.1", "chrono", "const_format", "httpdate", @@ -6461,7 +6795,7 @@ version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -6531,7 +6865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -6592,12 +6926,52 @@ dependencies = [ "zeroize", ] +[[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.104", + "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.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[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" @@ -6721,6 +7095,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[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" @@ -6864,6 +7247,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" @@ -6879,6 +7273,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" @@ -6934,6 +7337,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" @@ -7178,7 +7596,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", @@ -7464,7 +7882,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", @@ -7542,7 +7960,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", @@ -7587,7 +8005,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", @@ -8406,7 +8824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "http 1.3.1", "http-body", @@ -8713,6 +9131,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.18.0" @@ -8745,6 +9172,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.8.1" @@ -8819,6 +9265,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" @@ -8837,7 +9293,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", @@ -8854,7 +9310,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.3.1", "httparse", "log", @@ -9837,7 +10293,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.3.1", @@ -9904,6 +10360,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9b0ce2e16d..68b8df77a0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -110,6 +110,7 @@ mcp-types = { path = "mcp-types" } 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" diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index faa6cf2fce..43213a14ed 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -32,10 +32,12 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; +mod secrets_cmd; #[cfg(not(windows))] mod wsl_paths; use crate::mcp_cmd::McpCli; +use crate::secrets_cmd::SecretsCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -86,6 +88,9 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), + /// Manage secrets. + Secrets(SecretsCli), + /// [experimental] Run Codex as an MCP server and manage MCP servers. Mcp(McpCli), @@ -513,6 +518,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; } + Some(Subcommand::Secrets(mut secrets_cli)) => { + prepend_config_flags( + &mut secrets_cli.config_overrides, + root_config_overrides.clone(), + ); + secrets_cli.run().await?; + } Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand { None => { codex_app_server::run_main( diff --git a/codex-rs/cli/src/secrets_cmd.rs b/codex-rs/cli/src/secrets_cmd.rs new file mode 100644 index 0000000000..610a8878eb --- /dev/null +++ b/codex-rs/cli/src/secrets_cmd.rs @@ -0,0 +1,185 @@ +use std::io::IsTerminal; +use std::io::Read; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::Parser; +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::secrets::SecretName; +use codex_core::secrets::SecretScope; +use codex_core::secrets::SecretsManager; +use codex_core::secrets::environment_id_from_cwd; + +#[derive(Debug, Parser)] +pub struct SecretsCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub subcommand: SecretsSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum SecretsSubcommand { + /// Store or update a secret value. + Set(SecretsSetArgs), + /// List secret names (values are never displayed). + List(SecretsListArgs), + /// Delete a secret value. + Delete(SecretsDeleteArgs), +} + +#[derive(Debug, Parser)] +pub struct SecretsScopeArgs { + /// Use the global scope instead of the current environment scope. + #[arg(long, default_value_t = false, conflicts_with = "environment_id")] + pub global: bool, + + /// Explicit environment identifier for scoping the secret. + #[arg(long = "env")] + pub environment_id: Option, +} + +#[derive(Debug, Parser)] +pub struct SecretsSetArgs { + /// Secret name (A-Z, 0-9, underscore only). + pub name: String, + + /// Secret value. Prefer piping via stdin to avoid shell history. + #[arg(long)] + pub value: Option, + + #[clap(flatten)] + pub scope: SecretsScopeArgs, +} + +#[derive(Debug, Parser)] +pub struct SecretsListArgs { + #[clap(flatten)] + pub scope: SecretsScopeArgs, +} + +#[derive(Debug, Parser)] +pub struct SecretsDeleteArgs { + /// Secret name (A-Z, 0-9, underscore only). + pub name: String, + + #[clap(flatten)] + pub scope: SecretsScopeArgs, +} + +impl SecretsCli { + pub async fn run(self) -> Result<()> { + let config = load_config(self.config_overrides).await?; + let manager = SecretsManager::new(config.codex_home.clone(), config.secrets_backend); + match self.subcommand { + SecretsSubcommand::Set(args) => run_set(&config, &manager, args), + SecretsSubcommand::List(args) => run_list(&config, &manager, args), + SecretsSubcommand::Delete(args) => run_delete(&config, &manager, args), + } + } +} + +async fn load_config(cli_config_overrides: CliConfigOverrides) -> Result { + let cli_overrides = cli_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + Config::load_with_cli_overrides(cli_overrides) + .await + .context("failed to load configuration") +} + +fn run_set(config: &Config, manager: &SecretsManager, args: SecretsSetArgs) -> Result<()> { + let name = SecretName::new(&args.name)?; + let scope = resolve_scope(config, &args.scope)?; + let value = resolve_value(args.value)?; + manager.set(&scope, &name, &value)?; + println!("Stored {name} in {}.", scope_label(&scope)); + Ok(()) +} + +fn run_list(config: &Config, manager: &SecretsManager, args: SecretsListArgs) -> Result<()> { + let default_scope = default_environment_scope(config)?; + let scope_filter = match (args.scope.global, args.scope.environment_id.as_deref()) { + (true, _) => Some(SecretScope::Global), + (false, Some(env_id)) => Some(SecretScope::environment(env_id.to_string())?), + (false, None) => None, + }; + + let mut entries = manager.list(None)?; + entries.retain(|entry| match scope_filter.as_ref() { + Some(scope) => &entry.scope == scope, + None => entry.scope == SecretScope::Global || entry.scope == default_scope, + }); + + entries.sort_by(|a, b| { + scope_label(&a.scope) + .cmp(&scope_label(&b.scope)) + .then(a.name.cmp(&b.name)) + }); + + if entries.is_empty() { + println!("No secrets found."); + return Ok(()); + } + + for entry in entries { + println!("{} {}", scope_label(&entry.scope), entry.name); + } + + Ok(()) +} + +fn run_delete(config: &Config, manager: &SecretsManager, args: SecretsDeleteArgs) -> Result<()> { + let name = SecretName::new(&args.name)?; + let scope = resolve_scope(config, &args.scope)?; + let removed = manager.delete(&scope, &name)?; + if removed { + println!("Deleted {name} from {}.", scope_label(&scope)); + } else { + println!("No secret named {name} found in {}.", scope_label(&scope)); + } + Ok(()) +} + +fn resolve_scope(config: &Config, scope_args: &SecretsScopeArgs) -> Result { + if scope_args.global { + return Ok(SecretScope::Global); + } + if let Some(env_id) = scope_args.environment_id.as_deref() { + return SecretScope::environment(env_id.to_string()); + } + default_environment_scope(config) +} + +fn default_environment_scope(config: &Config) -> Result { + let environment_id = environment_id_from_cwd(&config.cwd); + SecretScope::environment(environment_id) +} + +fn resolve_value(explicit: Option) -> Result { + if let Some(value) = explicit { + return Ok(value); + } + + if std::io::stdin().is_terminal() { + bail!("secret value must be provided via --value or piped stdin"); + } + + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("failed to read secret value from stdin")?; + let trimmed = buf.trim_end_matches(['\n', '\r']).to_string(); + anyhow::ensure!(!trimmed.is_empty(), "secret value must not be empty"); + Ok(trimmed) +} + +fn scope_label(scope: &SecretScope) -> String { + match scope { + SecretScope::Global => "global".to_string(), + SecretScope::Environment(env_id) => format!("env/{env_id}"), + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b336b296c6..4b6110fba0 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -17,6 +17,7 @@ path = "src/bin/config_schema.rs" workspace = true [dependencies] +age = { workspace = true } anyhow = { workspace = true } arc-swap = "1.8.0" async-channel = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 03e27dceda..cd99616fe2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -860,6 +860,26 @@ }, "type": "object" }, + "SecretsBackendKind": { + "enum": [ + "local" + ], + "type": "string" + }, + "SecretsConfigToml": { + "additionalProperties": false, + "properties": { + "backend": { + "allOf": [ + { + "$ref": "#/definitions/SecretsBackendKind" + } + ], + "default": null + } + }, + "type": "object" + }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { @@ -1472,6 +1492,15 @@ ], "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." }, + "secrets": { + "allOf": [ + { + "$ref": "#/definitions/SecretsConfigToml" + } + ], + "default": null, + "description": "Secrets configuration. Defaults to a local encrypted file backend." + }, "shell_environment_policy": { "allOf": [ { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c251805272..2b7bbb1920 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -155,6 +155,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::rollout::metadata; +use crate::secrets::resolve_skill_env_dependencies; use crate::shell; use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillError; @@ -1935,6 +1936,41 @@ impl Session { state.record_mcp_dependency_prompted(names); } + pub(crate) async fn skill_secret_prompted(&self) -> HashSet { + let state = self.state.lock().await; + state.skill_secret_prompted() + } + + pub(crate) async fn record_skill_secret_prompted(&self, keys: I) + where + I: IntoIterator, + { + let mut state = self.state.lock().await; + state.record_skill_secret_prompted(keys); + } + + pub(crate) async fn set_skill_env_overrides( + &self, + sub_id: String, + overrides: HashMap, + ) { + let mut state = self.state.lock().await; + state.set_skill_env_overrides(sub_id, overrides); + } + + pub(crate) async fn skill_env_overrides( + &self, + sub_id: &str, + ) -> Option> { + let state = self.state.lock().await; + state.skill_env_overrides(sub_id) + } + + pub(crate) async fn clear_skill_env_overrides(&self, sub_id: &str) { + let mut state = self.state.lock().await; + state.clear_skill_env_overrides(sub_id); + } + pub(crate) async fn set_server_reasoning_included(&self, included: bool) { let mut state = self.state.lock().await; state.set_server_reasoning_included(included); @@ -3178,6 +3214,20 @@ pub(crate) async fn run_turn( ) .await; + let skill_secrets = resolve_skill_env_dependencies( + sess.as_ref(), + turn_context.as_ref(), + &cancellation_token, + &mentioned_skills, + ) + .await; + sess.set_skill_env_overrides(turn_context.sub_id.clone(), skill_secrets.overrides) + .await; + for message in skill_secrets.warnings { + sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + let otel_manager = turn_context.client.get_otel_manager(); let SkillInjections { items: skill_items, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 895e54c040..d0f1fe9732 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -13,6 +13,7 @@ use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; use crate::config::types::SandboxWorkspaceWrite; +use crate::config::types::SecretsConfigToml; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; @@ -39,6 +40,7 @@ use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use crate::secrets::SecretsBackendKind; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; @@ -228,6 +230,9 @@ pub struct Config { /// auto: Use the OS-specific keyring service if available, otherwise use a file. pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + /// Active secrets backend. Defaults to the local encrypted file backend. + pub secrets_backend: SecretsBackendKind, + /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: Constrained>, @@ -823,6 +828,10 @@ pub struct ConfigToml { #[serde(default)] pub cli_auth_credentials_store: Option, + /// Secrets configuration. Defaults to a local encrypted file backend. + #[serde(default)] + pub secrets: Option, + /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. @@ -1452,6 +1461,11 @@ impl Config { }); let forced_login_method = cfg.forced_login_method; + let secrets_backend = cfg + .secrets + .as_ref() + .and_then(|secrets| secrets.backend) + .unwrap_or_default(); let model = model.or(config_profile.model).or(cfg.model); @@ -1533,6 +1547,7 @@ impl Config { // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), + secrets_backend, mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. @@ -3760,6 +3775,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), + secrets_backend: SecretsBackendKind::Local, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, @@ -3844,6 +3860,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), + secrets_backend: SecretsBackendKind::Local, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, @@ -3943,6 +3960,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), + secrets_backend: SecretsBackendKind::Local, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, @@ -4028,6 +4046,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), + secrets_backend: SecretsBackendKind::Local, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index e949d869a6..6ac7d63769 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -4,6 +4,7 @@ // definitions that do not contain business logic. use crate::config_loader::RequirementSource; +use crate::secrets::SecretsBackendKind; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ModeKind; pub use codex_protocol::config_types::Personality; @@ -24,6 +25,13 @@ use serde::de::Error as SerdeError; pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SecretsConfigToml { + #[serde(default)] + pub backend: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum McpServerDisabledReason { Unknown, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index eaf25d14e8..d6ffcdc46f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -87,6 +87,7 @@ pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; +pub mod secrets; pub mod shell; pub mod shell_snapshot; pub mod skills; diff --git a/codex-rs/core/src/secrets/local.rs b/codex-rs/core/src/secrets/local.rs new file mode 100644 index 0000000000..fc087b901b --- /dev/null +++ b/codex-rs/core/src/secrets/local.rs @@ -0,0 +1,215 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +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::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, +} + +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, +} + +impl LocalSecretsBackend { + pub fn new(codex_home: PathBuf, keyring_store: Arc) -> 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> { + 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 { + 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> { + 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 { + 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; + } + 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(); + fs::write(&path, ciphertext) + .with_context(|| format!("failed to write secrets file at {}", path.display()))?; + Ok(()) + } + + fn load_or_create_passphrase(&self) -> Result { + let account = compute_keyring_account(&self.codex_home); + match self + .keyring_store + .load(keyring_service(), &account) + .map_err(|err| anyhow::anyhow!(err.message()))? + { + Some(existing) => Ok(SecretString::from(existing)), + None => { + 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) + } + } + } +} + +fn generate_passphrase() -> Result { + let mut bytes = [0_u8; 32]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut bytes) + .context("failed to generate random secrets key")?; + let encoded = BASE64_STANDARD.encode(bytes); + Ok(SecretString::from(encoded)) +} + +fn encrypt_with_passphrase(plaintext: &[u8], passphrase: &SecretString) -> Result> { + let recipient = ScryptRecipient::new(passphrase.clone()); + encrypt(&recipient, plaintext).context("failed to encrypt secrets file") +} + +fn decrypt_with_passphrase(ciphertext: &[u8], passphrase: &SecretString) -> Result> { + let identity = ScryptIdentity::new(passphrase.clone()); + decrypt(&identity, ciphertext).context("failed to decrypt secrets file") +} + +fn parse_canonical_key(canonical_key: &str) -> Option { + 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()?; + Some(SecretListEntry { + scope: SecretScope::Environment(environment_id.to_string()), + name, + }) + } + _ => None, + } +} diff --git a/codex-rs/core/src/secrets/mod.rs b/codex-rs/core/src/secrets/mod.rs new file mode 100644 index 0000000000..c18bc41e3b --- /dev/null +++ b/codex-rs/core/src/secrets/mod.rs @@ -0,0 +1,211 @@ +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; + +use crate::git_info::get_git_repo_root; + +mod local; +mod skill_dependencies; + +pub use local::LocalSecretsBackend; +pub(crate) use skill_dependencies::resolve_skill_env_dependencies; + +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 { + 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) -> Result { + 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 { + 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)] +#[serde(rename_all = "lowercase")] +pub enum SecretsBackendKind { + Local, +} + +impl Default for SecretsBackendKind { + fn default() -> Self { + Self::Local + } +} + +#[derive(Debug, Clone)] +pub struct SecretsManager { + backend: Arc, +} + +impl SecretsManager { + pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { + let keyring_store: Arc = Arc::new(DefaultKeyringStore); + Self::new_with_keyring_store(codex_home, backend_kind, keyring_store) + } + + pub fn new_with_keyring_store( + codex_home: PathBuf, + backend_kind: SecretsBackendKind, + keyring_store: Arc, + ) -> Self { + match backend_kind { + SecretsBackendKind::Local => Self { + backend: Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)), + }, + } + } + + 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> { + self.backend.get(scope, name) + } + + pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + self.backend.delete(scope, name) + } + + pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + 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}") +} + +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()); + assert!(env_id.starts_with("cwd-")); + } + + #[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(()) + } +} diff --git a/codex-rs/core/src/secrets/skill_dependencies.rs b/codex-rs/core/src/secrets/skill_dependencies.rs new file mode 100644 index 0000000000..7928aaddfb --- /dev/null +++ b/codex-rs/core/src/secrets/skill_dependencies.rs @@ -0,0 +1,382 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; + +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputResponse; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::secrets::SecretName; +use crate::secrets::SecretScope; +use crate::secrets::SecretsManager; +use crate::secrets::environment_id_from_cwd; +use crate::skills::SkillMetadata; +use crate::skills::model::SkillToolDependency; + +const SKILL_SECRET_PROMPT_PREFIX: &str = "skill-secret"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SkillSecretsOutcome { + pub overrides: HashMap, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +struct SecretDependencyContext { + name: SecretName, + skills: Vec, + description: Option, +} + +#[derive(Debug, Clone)] +struct MissingSecret { + context: SecretDependencyContext, + canonical_key: String, +} + +pub(crate) async fn resolve_skill_env_dependencies( + sess: &Session, + turn_context: &TurnContext, + cancellation_token: &CancellationToken, + mentioned_skills: &[SkillMetadata], +) -> SkillSecretsOutcome { + if mentioned_skills.is_empty() { + return SkillSecretsOutcome::default(); + } + + let config = turn_context.client.config(); + let manager = SecretsManager::new(config.codex_home.clone(), config.secrets_backend); + + let env_scope = match SecretScope::environment(environment_id_from_cwd(&turn_context.cwd)) { + Ok(scope) => scope, + Err(err) => { + return SkillSecretsOutcome { + overrides: HashMap::new(), + warnings: vec![format!("failed to resolve environment scope: {err}")], + }; + } + }; + let global_scope = SecretScope::Global; + + let mut outcome = SkillSecretsOutcome::default(); + let dependencies = collect_env_var_dependencies(mentioned_skills, &mut outcome.warnings); + if dependencies.is_empty() { + return outcome; + } + + let mut missing = Vec::new(); + + for context in dependencies { + match resolve_secret_value( + &manager, + &env_scope, + &global_scope, + &context, + &mut outcome.warnings, + ) { + Some(value) => { + outcome + .overrides + .insert(context.name.as_str().to_string(), value); + } + None => { + missing.push(MissingSecret { + canonical_key: env_scope.canonical_key(&context.name), + context, + }); + } + } + } + + if missing.is_empty() { + return outcome; + } + + let prompted = sess.skill_secret_prompted().await; + let mut already_prompted = Vec::new(); + let mut unprompted_missing = Vec::new(); + for entry in missing { + if prompted.contains(&entry.canonical_key) { + already_prompted.push(entry); + } else { + unprompted_missing.push(entry); + } + } + + for entry in already_prompted { + outcome + .warnings + .push(missing_secret_warning(&entry.context)); + } + + if unprompted_missing.is_empty() { + return outcome; + } + + let responses = prompt_for_missing_secrets( + sess, + turn_context, + cancellation_token, + &env_scope, + &unprompted_missing, + ) + .await; + + let prompted_keys = unprompted_missing + .iter() + .map(|entry| entry.canonical_key.clone()); + sess.record_skill_secret_prompted(prompted_keys).await; + + let mut resolved_from_prompt = HashMap::new(); + for entry in &unprompted_missing { + let Some(answer) = responses.answers.get(&entry.canonical_key) else { + outcome + .warnings + .push(missing_secret_warning(&entry.context)); + continue; + }; + let Some(value) = first_non_empty_answer(answer) else { + outcome + .warnings + .push(missing_secret_warning(&entry.context)); + continue; + }; + + if let Err(err) = manager.set(&env_scope, &entry.context.name, &value) { + outcome.warnings.push(format!( + "failed to persist secret {}: {err}", + entry.context.name + )); + } + + resolved_from_prompt.insert(entry.context.name.as_str().to_string(), value); + } + + outcome.overrides.extend(resolved_from_prompt); + outcome +} + +fn collect_env_var_dependencies( + mentioned_skills: &[SkillMetadata], + warnings: &mut Vec, +) -> Vec { + let mut contexts: BTreeMap = BTreeMap::new(); + + for skill in mentioned_skills { + let Some(dependencies) = skill.dependencies.as_ref() else { + continue; + }; + + for tool in &dependencies.tools { + if !tool.r#type.eq_ignore_ascii_case("env_var") { + continue; + } + add_dependency_context(&mut contexts, skill, tool, warnings); + } + } + + contexts + .into_values() + .map(|mut context| { + context.skills.sort(); + context.skills.dedup(); + context + }) + .collect() +} + +fn add_dependency_context( + contexts: &mut BTreeMap, + skill: &SkillMetadata, + tool: &SkillToolDependency, + warnings: &mut Vec, +) { + let name = match SecretName::new(&tool.value) { + Ok(name) => name, + Err(err) => { + warnings.push(format!( + "skill {} declares invalid env_var dependency {}: {err}", + skill.name, tool.value + )); + return; + } + }; + + let key = name.as_str().to_string(); + let description = tool.description.clone(); + contexts + .entry(key) + .and_modify(|context| { + context.skills.push(skill.name.clone()); + if context.description.is_none() { + context.description = description.clone(); + } + }) + .or_insert_with(|| SecretDependencyContext { + name, + skills: vec![skill.name.clone()], + description, + }); +} + +fn resolve_secret_value( + manager: &SecretsManager, + env_scope: &SecretScope, + global_scope: &SecretScope, + context: &SecretDependencyContext, + warnings: &mut Vec, +) -> Option { + if let Some(env_value) = read_non_empty_env(context.name.as_str()) { + return Some(env_value); + } + + match manager.get(env_scope, &context.name) { + Ok(Some(value)) if !value.trim().is_empty() => return Some(value), + Ok(Some(_)) | Ok(None) => {} + Err(err) => warnings.push(format!( + "failed to read secret {} from env scope: {err}", + context.name + )), + } + + match manager.get(global_scope, &context.name) { + Ok(Some(value)) if !value.trim().is_empty() => Some(value), + Ok(Some(_)) | Ok(None) => None, + Err(err) => { + warnings.push(format!( + "failed to read secret {} from global scope: {err}", + context.name + )); + None + } + } +} + +fn read_non_empty_env(name: &str) -> Option { + match std::env::var(name) { + Ok(value) if !value.trim().is_empty() => Some(value), + Ok(_) => None, + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + warn!("environment variable {name} contains invalid unicode; treating as missing"); + None + } + } +} + +async fn prompt_for_missing_secrets( + sess: &Session, + turn_context: &TurnContext, + cancellation_token: &CancellationToken, + env_scope: &SecretScope, + missing: &[MissingSecret], +) -> RequestUserInputResponse { + let questions = missing + .iter() + .map(|entry| build_secret_question(env_scope, entry)) + .collect(); + let args = RequestUserInputArgs { questions }; + let call_id = format!("{SKILL_SECRET_PROMPT_PREFIX}-{}", turn_context.sub_id); + let response_fut = sess.request_user_input(turn_context, call_id, args); + let sub_id = turn_context.sub_id.as_str(); + + tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + let empty = RequestUserInputResponse { answers: HashMap::new() }; + sess.notify_user_input_response(sub_id, empty.clone()).await; + empty + } + response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { answers: HashMap::new() }), + } +} + +fn build_secret_question( + env_scope: &SecretScope, + missing: &MissingSecret, +) -> RequestUserInputQuestion { + let scope_label = match env_scope { + SecretScope::Global => "global".to_string(), + SecretScope::Environment(env_id) => format!("env/{env_id}"), + }; + let skills = missing.context.skills.join(", "); + let description = missing + .context + .description + .as_deref() + .map(|text| format!(" {text}")) + .unwrap_or_default(); + RequestUserInputQuestion { + id: missing.canonical_key.clone(), + header: format!("Missing secret: {}", missing.context.name), + question: format!( + "Skill(s) {skills} require {}.{description} Enter a value to store for {scope_label}.", + missing.context.name, + ), + is_other: false, + options: None, + } +} + +fn first_non_empty_answer(answer: &RequestUserInputAnswer) -> Option { + answer + .answers + .iter() + .find(|value| !value.trim().is_empty()) + .map(|value| value.to_string()) +} + +fn missing_secret_warning(context: &SecretDependencyContext) -> String { + let skills = context.skills.join(", "); + format!( + "Required secret {} is missing for skill(s) {skills}. Use `codex secrets set {}` to configure it.", + context.name, context.name + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn skill_with_env(name: &str, env_var: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: name.to_string(), + short_description: None, + interface: None, + dependencies: Some(crate::skills::model::SkillDependencies { + tools: vec![crate::skills::model::SkillToolDependency { + r#type: "env_var".to_string(), + value: env_var.to_string(), + description: Some("token".to_string()), + transport: None, + command: None, + url: None, + }], + }), + path: PathBuf::from(name), + scope: codex_protocol::protocol::SkillScope::User, + } + } + + #[tokio::test] + async fn resolves_from_environment_without_prompting() -> anyhow::Result<()> { + let (session, turn) = crate::codex::make_session_and_context().await; + let home = std::env::var("HOME").context("HOME must be set for tests")?; + let skills = vec![skill_with_env("skill", "HOME")]; + let outcome = + resolve_skill_env_dependencies(&session, &turn, &CancellationToken::new(), &skills) + .await; + + assert_eq!(outcome.warnings, Vec::::new()); + assert_eq!(outcome.overrides.get("HOME"), Some(&home)); + Ok(()) + } +} diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 9d3d96db99..b6b7647f6c 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,6 +1,7 @@ //! Session-wide mutable state. use codex_protocol::models::ResponseItem; +use std::collections::HashMap; use std::collections::HashSet; use crate::codex::SessionConfiguration; @@ -17,6 +18,8 @@ pub(crate) struct SessionState { pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, pub(crate) mcp_dependency_prompted: HashSet, + pub(crate) skill_secret_prompted: HashSet, + pub(crate) skill_env_overrides: HashMap>, /// Whether the session's initial context has been seeded into history. /// /// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at @@ -34,6 +37,8 @@ impl SessionState { latest_rate_limits: None, server_reasoning_included: false, mcp_dependency_prompted: HashSet::new(), + skill_secret_prompted: HashSet::new(), + skill_env_overrides: HashMap::new(), initial_context_seeded: false, } } @@ -112,6 +117,37 @@ impl SessionState { pub(crate) fn mcp_dependency_prompted(&self) -> HashSet { self.mcp_dependency_prompted.clone() } + + pub(crate) fn record_skill_secret_prompted(&mut self, keys: I) + where + I: IntoIterator, + { + self.skill_secret_prompted.extend(keys); + } + + pub(crate) fn skill_secret_prompted(&self) -> HashSet { + self.skill_secret_prompted.clone() + } + + pub(crate) fn set_skill_env_overrides( + &mut self, + sub_id: String, + overrides: HashMap, + ) { + if overrides.is_empty() { + self.skill_env_overrides.remove(&sub_id); + return; + } + self.skill_env_overrides.insert(sub_id, overrides); + } + + pub(crate) fn skill_env_overrides(&self, sub_id: &str) -> Option> { + self.skill_env_overrides.get(sub_id).cloned() + } + + pub(crate) fn clear_skill_env_overrides(&mut self, sub_id: &str) { + self.skill_env_overrides.remove(sub_id); + } } // Sometimes new snapshots don't include credits or plan information. diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 08d23f7987..981a96c5a8 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -191,6 +191,7 @@ impl Session { false }; drop(active); + self.clear_skill_env_overrides(&turn_context.sub_id).await; if should_close_processes { self.close_unified_exec_processes().await; } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 4305417a35..c4671c3d03 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -101,10 +101,14 @@ impl SessionTask for UserShellCommandTask { ) .await; + let mut env = create_env(&turn_context.shell_environment_policy); + if let Some(overrides) = session.skill_env_overrides(&turn_context.sub_id).await { + env.extend(overrides); + } let exec_env = ExecEnv { command: exec_command.clone(), cwd: cwd.clone(), - env: create_env(&turn_context.shell_environment_policy), + env, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index dbfb0364bf..75e1c38fb9 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -216,7 +216,7 @@ impl ShellHandler { async fn run_exec_like(args: RunExecLikeArgs) -> Result { let RunExecLikeArgs { tool_name, - exec_params, + mut exec_params, prefix_rule, session, turn, @@ -233,6 +233,10 @@ impl ShellHandler { None }; + if let Some(overrides) = session.skill_env_overrides(&turn.sub_id).await { + exec_params.env.extend(overrides); + } + // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params .sandbox_permissions diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5b3e83a5dc..d01bcff31c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -483,7 +483,14 @@ impl UnifiedExecProcessManager { cwd: PathBuf, context: &UnifiedExecContext, ) -> Result { - let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); + let mut env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); + if let Some(overrides) = context + .session + .skill_env_overrides(&context.turn.sub_id) + .await + { + env.extend(overrides); + } let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self);