From 9257d8451cf6b2deba871ed560ca7fb28ce069ef Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 3 Feb 2026 00:14:39 -0800 Subject: [PATCH] feat(secrets): add codex-secrets crate (#10142) ## Summary This introduces the first working foundation for Codex managed secrets: a small Rust crate that can securely store and retrieve secrets locally. Concretely, it adds a `codex-secrets` crate that: - encrypts a local secrets file using `age` - generates a high-entropy encryption key - stores that key in the OS keyring ## What this enables - A secure local persistence model for secrets - A clean, isolated place for future provider backends - A clear boundary: Codex can become a credential broker without putting plaintext secrets in config files ## Implementation details - New crate: `codex-rs/secrets/` - Encryption: `age` with scrypt recipient/identity - Key generation: `OsRng` (32 random bytes) - Key storage: OS keyring via `codex-keyring-store` ## Testing - `cd codex-rs && just fmt` - `cd codex-rs && cargo test -p codex-secrets` --- codex-rs/Cargo.lock | 542 ++++++++++++++++++++++++++++++++-- codex-rs/Cargo.toml | 5 +- codex-rs/secrets/Cargo.toml | 25 ++ codex-rs/secrets/src/lib.rs | 243 +++++++++++++++ codex-rs/secrets/src/local.rs | 411 ++++++++++++++++++++++++++ 5 files changed, 1197 insertions(+), 29 deletions(-) create mode 100644 codex-rs/secrets/Cargo.toml create mode 100644 codex-rs/secrets/src/lib.rs create mode 100644 codex-rs/secrets/src/local.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 40a95cc33..5fc804f2f 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", @@ -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", @@ -1689,7 +1788,7 @@ name = "codex-login" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-core", @@ -1892,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" @@ -1933,7 +2051,7 @@ dependencies = [ "anyhow", "arboard", "assert_matches", - "base64", + "base64 0.22.1", "chrono", "clap", "codex-ansi-escape", @@ -2049,7 +2167,7 @@ dependencies = [ name = "codex-utils-image" version = "0.0.0" dependencies = [ - "base64", + "base64 0.22.1", "codex-utils-cache", "image", "tempfile", @@ -2102,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", @@ -2253,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" @@ -2285,7 +2412,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "base64", + "base64 0.22.1", "codex-core", "codex-protocol", "codex-utils-absolute-path", @@ -2475,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" @@ -3124,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" @@ -3135,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" @@ -3195,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" @@ -3847,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", @@ -3867,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" @@ -4194,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" @@ -4203,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" @@ -5068,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", @@ -5278,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" @@ -5403,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", @@ -5554,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" @@ -5696,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" @@ -5813,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" @@ -5943,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", @@ -5963,7 +6315,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -6109,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", @@ -6189,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", @@ -6593,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", @@ -6663,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", @@ -6729,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" @@ -6858,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" @@ -7001,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" @@ -7016,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" @@ -7071,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" @@ -7315,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", @@ -7603,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", @@ -7681,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", @@ -7726,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", @@ -8546,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", @@ -8849,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" @@ -8881,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" @@ -8955,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" @@ -8973,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", @@ -8990,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", @@ -9956,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", @@ -10019,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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2ea3624d3..22f53644f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml new file mode 100644 index 000000000..de45af50a --- /dev/null +++ b/codex-rs/secrets/Cargo.toml @@ -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 } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs new file mode 100644 index 000000000..a45860d8b --- /dev/null +++ b/codex-rs/secrets/src/lib.rs @@ -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 { + 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 { + // 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>; + fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result; + fn list(&self, scope_filter: Option<&SecretScope>) -> Result>; +} + +#[derive(Clone)] +pub struct SecretsManager { + backend: Arc, +} + +impl SecretsManager { + pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { + let backend: Arc = match backend_kind { + SecretsBackendKind::Local => { + let keyring_store: Arc = 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, + ) -> Self { + let backend: Arc = 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> { + 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}") +} + +fn get_git_repo_root(base_dir: &Path) -> Option { + 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(()) + } +} diff --git a/codex-rs/secrets/src/local.rs b/codex-rs/secrets/src/local.rs new file mode 100644 index 000000000..127fc84c5 --- /dev/null +++ b/codex-rs/secrets/src/local.rs @@ -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, +} + +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; + } + 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 { + 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> { + LocalSecretsBackend::get(self, scope, name) + } + + fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + LocalSecretsBackend::delete(self, scope, name) + } + + fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + 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 { + 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> { + 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()?; + 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::>>() + .with_context(|| format!("failed to enumerate {}", secrets_dir.display()))?; + + let filenames: Vec = 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(()) + } +}