Merge branch 'feat/v070-keyring' (#134 OS keyring credential store)

- crates/secrets/ (NEW crate) — KeyringStore trait + Default/InMemory/File backends
- crates/config/src/lib.rs — api_key resolution via Secrets::auto_detect (CLI → keyring → env → config-file)
- crates/cli/src/main.rs — auth set/get/clear/migrate/list subcommands
- crates/tui/src/config.rs + main.rs — wire keyring resolver, doctor reports backend
- Lockfile updated for keyring 3.6 (apple/windows/linux native features)
- One-time deprecation warning when api_key is read from config.toml
This commit is contained in:
Hunter Bown
2026-04-28 00:06:09 -05:00
11 changed files with 1989 additions and 154 deletions
Generated
+679 -6
View File
@@ -27,6 +27,17 @@ dependencies = [
"pom",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -197,6 +208,106 @@ dependencies = [
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.3",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.3",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.3",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -219,6 +330,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -364,6 +481,28 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -419,6 +558,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.52"
@@ -475,6 +623,16 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.5.54"
@@ -587,6 +745,15 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.2"
@@ -635,6 +802,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -775,6 +951,35 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dbus"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -840,10 +1045,12 @@ name = "deepseek-config"
version = "0.6.7"
dependencies = [
"anyhow",
"deepseek-secrets",
"dirs",
"serde",
"serde_json",
"toml",
"tracing",
]
[[package]]
@@ -906,6 +1113,19 @@ dependencies = [
"serde_json",
]
[[package]]
name = "deepseek-secrets"
version = "0.6.7"
dependencies = [
"dirs",
"keyring",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tracing",
]
[[package]]
name = "deepseek-state"
version = "0.6.7"
@@ -948,6 +1168,7 @@ dependencies = [
"colored",
"crossterm",
"csv",
"deepseek-secrets",
"dirs",
"dotenvy",
"futures-util",
@@ -999,6 +1220,7 @@ dependencies = [
"deepseek-config",
"deepseek-execpolicy",
"deepseek-mcp",
"deepseek-secrets",
"deepseek-state",
"dirs",
"serde",
@@ -1068,6 +1290,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1211,12 +1434,39 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1257,6 +1507,27 @@ dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1438,6 +1709,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@@ -1626,6 +1910,30 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
@@ -1959,6 +2267,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.11"
@@ -2089,6 +2407,23 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"linux-keyutils",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.5.1",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "lalrpop"
version = "0.19.12"
@@ -2132,6 +2467,15 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libredox"
version = "0.1.12"
@@ -2153,6 +2497,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-keyutils"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.10.0",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2292,6 +2646,15 @@ dependencies = [
"autocfg",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2397,7 +2760,7 @@ dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset",
"memoffset 0.6.5",
"pin-utils",
]
@@ -2423,6 +2786,7 @@ dependencies = [
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset 0.9.1",
]
[[package]]
@@ -2435,6 +2799,20 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2445,6 +2823,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -2460,6 +2847,28 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2620,6 +3029,22 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2701,6 +3126,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2720,6 +3156,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.3",
"windows-sys 0.61.2",
]
[[package]]
name = "pom"
version = "1.1.0"
@@ -2799,6 +3249,15 @@ dependencies = [
"yansi",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.105"
@@ -2853,7 +3312,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -2904,14 +3363,35 @@ dependencies = [
"nibble_vec",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
@@ -2921,7 +3401,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.16",
]
[[package]]
@@ -3349,6 +3838,25 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand 0.8.6",
"serde",
"sha2",
"zbus",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -3513,6 +4021,28 @@ dependencies = [
"serial-core",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@@ -3636,7 +4166,7 @@ dependencies = [
"inventory",
"itertools 0.13.0",
"maplit",
"memoffset",
"memoffset 0.6.5",
"num-bigint",
"num-traits",
"once_cell",
@@ -4074,6 +4604,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
@@ -4188,6 +4730,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -4869,6 +5422,9 @@ name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
@@ -4931,6 +5487,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "yansi"
version = "1.0.1"
@@ -4960,6 +5526,62 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-process",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix 0.29.0",
"ordered-stream",
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.33"
@@ -5006,6 +5628,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "zerotrie"
@@ -5060,3 +5696,40 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.114",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
+1
View File
@@ -9,6 +9,7 @@ members = [
"crates/hooks",
"crates/mcp",
"crates/protocol",
"crates/secrets",
"crates/state",
"crates/tools",
"crates/tui",
+1
View File
@@ -19,6 +19,7 @@ deepseek-app-server = { path = "../app-server", version = "0.6.0" }
deepseek-config = { path = "../config", version = "0.6.0" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.6.0" }
deepseek-mcp = { path = "../mcp", version = "0.6.0" }
deepseek-secrets = { path = "../secrets", version = "0.6.0" }
deepseek-state = { path = "../state", version = "0.6.0" }
chrono.workspace = true
dirs.workspace = true
+480 -56
View File
@@ -15,6 +15,7 @@ use deepseek_app_server::{
use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};
use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use deepseek_mcp::{McpServerDefinition, run_stdio_server};
use deepseek_secrets::Secrets;
use deepseek_state::{StateStore, ThreadListFilters};
#[derive(Debug, Clone, Copy, ValueEnum)]
@@ -189,17 +190,43 @@ struct AuthArgs {
#[derive(Debug, Subcommand)]
enum AuthCommand {
/// Show current provider, env vars, and config-file presence.
Status,
/// Save an API key to the OS keyring (never written to disk in
/// plaintext). Reads from `--api-key`, `--api-key-stdin`, or
/// prompts on stdin when neither is given. Does not echo the key.
Set {
#[arg(long, value_enum)]
provider: ProviderArg,
/// Inline value (discouraged — appears in shell history).
#[arg(long)]
api_key: Option<String>,
/// Read the key from stdin instead of prompting.
#[arg(long = "api-key-stdin", default_value_t = false)]
api_key_stdin: bool,
},
/// Report whether a provider has a key configured. Never prints
/// the value; just `set` / `not set` plus the source layer.
Get {
#[arg(long, value_enum)]
provider: ProviderArg,
},
/// Delete a provider's key from the OS keyring (and from the
/// plaintext config slot, if present, for parity).
Clear {
#[arg(long, value_enum)]
provider: ProviderArg,
},
/// List all known providers with their auth state, without
/// revealing keys.
List,
/// Migrate plaintext `api_key` values from `~/.deepseek/config.toml`
/// into the OS keyring, then strip them from the file.
Migrate {
/// Don't actually write anything; print what would change.
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
#[derive(Debug, Args)]
@@ -506,85 +533,254 @@ fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
Ok(())
}
/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg
/// in `security find-generic-password`).
fn keyring_slot(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => "deepseek",
ProviderKind::NvidiaNim => "nvidia-nim",
ProviderKind::Openai => "openai",
ProviderKind::Openrouter => "openrouter",
ProviderKind::Novita => "novita",
}
}
/// Provider order used by the `auth list` and `auth status` outputs.
const PROVIDER_LIST: [ProviderKind; 5] = [
ProviderKind::Deepseek,
ProviderKind::NvidiaNim,
ProviderKind::Openrouter,
ProviderKind::Novita,
ProviderKind::Openai,
];
fn provider_env_set(provider: ProviderKind) -> bool {
deepseek_secrets::env_for(keyring_slot(provider)).is_some()
}
fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
let slot = store
.config
.providers
.for_provider(provider)
.api_key
.as_ref();
let root = (provider == ProviderKind::Deepseek)
.then_some(store.config.api_key.as_ref())
.flatten();
slot.or(root).is_some_and(|v| !v.trim().is_empty())
}
fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
}
fn run_auth_command_with_secrets(
store: &mut ConfigStore,
command: AuthCommand,
secrets: &Secrets,
) -> Result<()> {
match command {
AuthCommand::Status => {
let deepseek_env = std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let openai_env = std::env::var("OPENAI_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let nvidia_env = std::env::var("NVIDIA_API_KEY")
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let deepseek_file = store
.config
.providers
.deepseek
.api_key
.as_ref()
.or(store.config.api_key.as_ref())
.is_some_and(|v| !v.trim().is_empty());
let openai_file = store
.config
.providers
.openai
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
let nvidia_file = store
.config
.providers
.nvidia_nim
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
println!("provider: {}", store.config.provider.as_str());
println!(
"deepseek auth: env={}, config={}",
deepseek_env, deepseek_file
);
println!(
"nvidia-nim auth: env={}, config={}",
nvidia_env, nvidia_file
);
println!("openai auth: env={}, config={}", openai_env, openai_file);
println!("keyring backend: {}", secrets.backend_name());
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let keyring_set = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let env_set = provider_env_set(provider);
let file_set = provider_config_set(store, provider);
println!(
"{slot} auth: keyring={}, env={}, config={}",
keyring_set, env_set, file_set
);
}
Ok(())
}
AuthCommand::Set { provider, api_key } => {
AuthCommand::Set {
provider,
api_key,
api_key_stdin,
} => {
let provider: ProviderKind = provider.into();
let api_key = match api_key {
Some(v) => v,
None => read_api_key_from_stdin()?,
let slot = keyring_slot(provider);
let api_key = match (api_key, api_key_stdin) {
(Some(v), _) => v,
(None, true) => read_api_key_from_stdin()?,
(None, false) => prompt_api_key(slot)?,
};
store.config.provider = provider;
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
if provider == ProviderKind::Deepseek {
store.config.api_key = store.config.providers.deepseek.api_key.clone();
secrets
.set(slot, &api_key)
.with_context(|| format!("failed to write {slot} key to keyring"))?;
// Don't print the key. Don't echo length.
println!("saved API key for {slot} to {}", secrets.backend_name());
Ok(())
}
AuthCommand::Get { provider } => {
let provider: ProviderKind = provider.into();
let slot = keyring_slot(provider);
let in_keyring = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let in_env = provider_env_set(provider);
let in_file = provider_config_set(store, provider);
// Report the highest-priority source that has it.
let resolved = secrets.resolve(slot).is_some() || in_file;
if resolved {
let source = if in_keyring {
"keyring"
} else if in_env {
"env"
} else {
"config-file"
};
println!("{slot}: set (source: {source})");
} else {
println!("{slot}: not set");
}
store.save()?;
println!("saved API key for {}", provider.as_str());
Ok(())
}
AuthCommand::Clear { provider } => {
let provider: ProviderKind = provider.into();
let slot = keyring_slot(provider);
secrets
.delete(slot)
.with_context(|| format!("failed to delete {slot} key from keyring"))?;
// Also clear the plaintext slot in config.toml for parity.
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
store.config.api_key = None;
}
store.save()?;
println!("cleared API key for {}", provider.as_str());
println!("cleared API key for {slot}");
Ok(())
}
AuthCommand::List => {
println!("keyring backend: {}", secrets.backend_name());
println!("provider keyring env config");
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let kr = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let env = provider_env_set(provider);
let file = provider_config_set(store, provider);
println!(
"{slot:<12} {} {} {}",
yes_no(kr),
yes_no(env),
yes_no(file)
);
}
Ok(())
}
AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
}
}
fn yes_no(b: bool) -> &'static str {
if b { "yes" } else { "no " }
}
fn prompt_api_key(slot: &str) -> Result<String> {
use std::io::{IsTerminal, Write};
eprint!("Enter API key for {slot}: ");
io::stderr().flush().ok();
if !io::stdin().is_terminal() {
// Non-interactive: read directly without prompting twice.
return read_api_key_from_stdin();
}
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.context("failed to read API key from stdin")?;
let key = buf.trim().to_string();
if key.is_empty() {
bail!("empty API key provided");
}
Ok(key)
}
/// Move plaintext keys from config.toml into the keyring. Stays
/// idempotent: rerunning is a no-op once the file is clean.
fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let from_provider_block = store
.config
.providers
.for_provider(provider)
.api_key
.clone()
.filter(|v| !v.trim().is_empty());
let from_root = (provider == ProviderKind::Deepseek)
.then(|| store.config.api_key.clone())
.flatten()
.filter(|v| !v.trim().is_empty());
let value = from_provider_block.or(from_root);
let Some(value) = value else { continue };
if let Ok(Some(existing)) = secrets.get(slot)
&& existing == value
{
// Already migrated; safe to strip the file slot.
} else if dry_run {
migrated.push((provider, slot));
continue;
} else if let Err(err) = secrets.set(slot, &value) {
warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
continue;
}
if !dry_run {
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
store.config.api_key = None;
}
}
migrated.push((provider, slot));
}
if !dry_run && !migrated.is_empty() {
store
.save()
.context("failed to write updated config.toml")?;
}
println!("keyring backend: {}", secrets.backend_name());
if migrated.is_empty() {
println!("nothing to migrate (config.toml has no plaintext api_key entries)");
} else {
println!(
"{} {} provider key(s):",
if dry_run { "would migrate" } else { "migrated" },
migrated.len()
);
for (_, slot) in &migrated {
println!(" - {slot}");
}
if !dry_run {
println!(
"config.toml at {} no longer contains api_key entries for migrated providers.",
store.path().display()
);
}
}
for w in warnings {
eprintln!("warning: {w}");
}
Ok(())
}
fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
match command {
ConfigCommand::Get { key } => {
@@ -1199,6 +1395,234 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn parses_auth_subcommand_matrix() {
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Deepseek,
api_key: None,
api_key_stdin: false,
}
}))
));
let cli = parse_ok(&[
"deepseek",
"auth",
"set",
"--provider",
"openrouter",
"--api-key-stdin",
]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Openrouter,
api_key: None,
api_key_stdin: true,
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Novita
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Clear {
provider: ProviderArg::NvidiaNim
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "list"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::List
}))
));
let cli = parse_ok(&["deepseek", "auth", "migrate"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Migrate { dry_run: false }
}))
));
let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Migrate { dry_run: true }
}))
));
}
#[test]
fn auth_set_writes_to_keyring_and_not_to_config_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-set-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Deepseek,
api_key: Some("sk-keyring".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect("set should succeed");
assert_eq!(
inner.get("deepseek").unwrap(),
Some("sk-keyring".to_string())
);
// Plaintext config slot must not be written.
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(
!saved.contains("sk-keyring"),
"plaintext key leaked into config: {saved}"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_clear_removes_from_keyring_and_config() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-clear-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.api_key = Some("sk-stale".to_string());
store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
inner.set("deepseek", "sk-keyring").unwrap();
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Clear {
provider: ProviderArg::Deepseek,
},
&secrets,
)
.expect("clear should succeed");
assert_eq!(inner.get("deepseek").unwrap(), None);
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.api_key = Some("sk-deep".to_string());
store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
store.config.providers.openrouter.api_key = Some("or-key".to_string());
store.config.providers.novita.api_key = Some("nv-key".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Migrate { dry_run: false },
&secrets,
)
.expect("migrate should succeed");
assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
// Config file must no longer contain the api keys.
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
assert!(store.config.providers.openrouter.api_key.is_none());
assert!(store.config.providers.novita.api_key.is_none());
let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_migrate_dry_run_does_not_modify_anything() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.providers.openrouter.api_key = Some("or-stay".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
.expect("dry-run should succeed");
assert_eq!(inner.get("openrouter").unwrap(), None);
assert_eq!(
store.config.providers.openrouter.api_key.as_deref(),
Some("or-stay")
);
let _ = std::fs::remove_file(path);
}
#[test]
fn parses_global_override_flags() {
let cli = parse_ok(&[
+2
View File
@@ -8,7 +8,9 @@ description = "Config schema and precedence model for DeepSeek workspace archite
[dependencies]
anyhow.workspace = true
deepseek-secrets = { path = "../secrets", version = "0.6.0" }
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
+145 -37
View File
@@ -1,8 +1,10 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
pub use deepseek_secrets::Secrets;
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE_NAME: &str = "config.toml";
@@ -374,8 +376,25 @@ impl ConfigToml {
out
}
/// Resolve runtime options with the default secrets façade
/// ([`Secrets::auto_detect`]). For test injection or custom backends,
/// use [`Self::resolve_runtime_options_with_secrets`].
#[must_use]
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
self.resolve_runtime_options_with_secrets(cli, default_secrets())
}
/// Resolve runtime options using an explicit secrets façade.
///
/// API-key precedence is **CLI flag → keyring → env → config-file**.
/// (`Secrets::resolve` already collapses keyring → env, so we layer
/// CLI on top and TOML on the bottom.)
#[must_use]
pub fn resolve_runtime_options_with_secrets(
&self,
cli: &CliRuntimeOverrides,
secrets: &Secrets,
) -> ResolvedRuntimeOptions {
let env = EnvRuntimeOverrides::load();
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
@@ -389,12 +408,18 @@ impl ConfigToml {
let root_deepseek_model = (provider == ProviderKind::Deepseek)
.then(|| self.default_text_model.clone())
.flatten();
// CLI flag wins outright. Otherwise: keyring → env (via Secrets) → config-file.
let api_key = cli
.api_key
.clone()
.or_else(|| env.api_key_for(provider))
.or_else(|| provider_cfg.api_key.clone())
.or(root_deepseek_api_key);
.or_else(|| secrets.resolve(provider.as_str()))
.or_else(|| {
let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
if from_file.is_some() {
warn_legacy_api_key_in_toml_once();
}
from_file
});
let base_url = cli
.base_url
@@ -576,6 +601,45 @@ impl ConfigStore {
}
}
/// One-time deprecation warning emitted whenever a TOML `api_key`
/// value is read by the resolver. Callers should migrate to the
/// keyring via `deepseek auth set` / `deepseek auth migrate`.
fn warn_legacy_api_key_in_toml_once() {
static WARNED: OnceLock<()> = OnceLock::new();
let _ = WARNED.get_or_init(|| {
tracing::warn!(
"api_key in config.toml is deprecated; use 'deepseek auth set' or 'deepseek auth migrate' to move it to the OS keyring"
);
});
}
/// Process-wide default [`Secrets`] façade. The first caller wins; the
/// lock is exposed so test or CLI code can install an explicit
/// backend (e.g. an [`deepseek_secrets::InMemoryKeyringStore`]) before
/// any resolver runs.
pub fn default_secrets() -> &'static Secrets {
static SECRETS: OnceLock<Secrets> = OnceLock::new();
SECRETS.get_or_init(|| {
// Tests should never poke the real OS keyring — using
// auto_detect would surface stale macOS Keychain entries
// from the developer's session and break the precedence
// assertions. Cargo sets the `RUST_TEST_*` family of env
// vars (and `CARGO_PKG_NAME` is always populated), but the
// `cfg(test)` flag is the canonical signal here. See
// `install_test_secrets` for explicit installs.
#[cfg(test)]
{
Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
))
}
#[cfg(not(test))]
{
Secrets::auto_detect()
}
})
}
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
return Ok(path);
@@ -619,11 +683,6 @@ struct EnvRuntimeOverrides {
telemetry: Option<bool>,
approval_policy: Option<String>,
sandbox_mode: Option<String>,
deepseek_api_key: Option<String>,
openai_api_key: Option<String>,
nvidia_api_key: Option<String>,
openrouter_api_key: Option<String>,
novita_api_key: Option<String>,
deepseek_base_url: Option<String>,
nvidia_base_url: Option<String>,
openai_base_url: Option<String>,
@@ -646,16 +705,6 @@ impl EnvRuntimeOverrides {
.and_then(|v| parse_bool(&v).ok()),
approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
deepseek_api_key: std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty()),
openai_api_key: std::env::var("OPENAI_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_api_key: std::env::var("NVIDIA_API_KEY")
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
@@ -667,12 +716,6 @@ impl EnvRuntimeOverrides {
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_api_key: std::env::var("OPENROUTER_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty()),
novita_api_key: std::env::var("NOVITA_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
@@ -682,19 +725,6 @@ impl EnvRuntimeOverrides {
}
}
fn api_key_for(&self, provider: ProviderKind) -> Option<String> {
match provider {
ProviderKind::Deepseek => self.deepseek_api_key.clone(),
ProviderKind::NvidiaNim => self
.nvidia_api_key
.clone()
.or_else(|| self.deepseek_api_key.clone()),
ProviderKind::Openai => self.openai_api_key.clone(),
ProviderKind::Openrouter => self.openrouter_api_key.clone(),
ProviderKind::Novita => self.novita_api_key.clone(),
}
}
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
// Defaults belong in the resolver's final fallback so config-file
// values (`providers.<name>.base_url`) still win when env is unset.
@@ -1094,4 +1124,82 @@ mod tests {
assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
}
#[test]
fn keyring_resolves_above_env_and_toml() {
use deepseek_secrets::KeyringStore;
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
let store = std::sync::Arc::new(deepseek_secrets::InMemoryKeyringStore::new());
store.set("deepseek", "ring-key").unwrap();
let secrets = Secrets::new(store);
let mut config = ConfigToml::default();
config.providers.deepseek.api_key = Some("file-key".to_string());
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn env_resolves_when_keyring_empty_above_toml() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
let secrets = Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
));
let mut config = ConfigToml::default();
config.providers.deepseek.api_key = Some("file-key".to_string());
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn config_file_resolves_when_keyring_and_env_empty() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let secrets = Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
));
let mut config = ConfigToml::default();
config.providers.deepseek.api_key = Some("file-key".to_string());
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
}
#[test]
fn cli_flag_still_overrides_keyring() {
use deepseek_secrets::KeyringStore;
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let store = std::sync::Arc::new(deepseek_secrets::InMemoryKeyringStore::new());
store.set("deepseek", "ring-key").unwrap();
let secrets = Secrets::new(store);
let cli = CliRuntimeOverrides {
api_key: Some("cli-key".to_string()),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "deepseek-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Secret storage backends (OS keyring with file fallback) for DeepSeek workspace"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
dirs = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
[dev-dependencies]
tempfile = "3.16"
+578
View File
@@ -0,0 +1,578 @@
//! Secret storage for DeepSeek API keys.
//!
//! Provides a small abstraction (`KeyringStore`) plus a default
//! implementation backed by the OS keyring (`DefaultKeyringStore`),
//! a file-based fallback for headless Linux (`FileKeyringStore`), and
//! an in-memory store for tests (`InMemoryKeyringStore`).
//!
//! Higher-level lookup goes through [`Secrets::resolve`], which checks
//! the keyring first and falls back to environment variables. The
//! caller (typically the config crate) then falls back to plaintext
//! TOML if both are empty — that final layer lives outside this crate
//! so the precedence is explicit at the call site.
//!
//! Hard rule: **keyring → env → config-file**. Never swap.
#![deny(missing_docs)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Default OS keychain service name. macOS users can verify entries with
/// `security find-generic-password -s deepseek -a <provider>`.
pub const DEFAULT_SERVICE: &str = "deepseek";
/// Errors that may arise from a [`KeyringStore`] backend.
#[derive(Debug, Error)]
pub enum SecretsError {
/// Underlying OS keyring backend reported an error.
#[error("keyring backend error: {0}")]
Keyring(String),
/// File-backed fallback I/O error.
#[error("file-backed secret store I/O error: {0}")]
Io(#[from] std::io::Error),
/// File-backed fallback JSON (de)serialisation error.
#[error("file-backed secret store JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Caught when a stored secret on disk has unsafe permissions.
#[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
InsecurePermissions {
/// Absolute path to the secrets file.
path: PathBuf,
/// Observed unix permission mode.
mode: u32,
},
}
/// Abstract secret store; concrete implementations may use the OS
/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
/// map (tests).
pub trait KeyringStore: Send + Sync {
/// Read a secret. Returns `Ok(None)` if no entry exists.
fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
/// Write a secret, replacing any existing value.
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
/// Remove a secret. Should not error if the entry is absent.
fn delete(&self, key: &str) -> Result<(), SecretsError>;
/// Short, human-readable name of the backend (used by `doctor`).
fn backend_name(&self) -> &'static str;
}
/// OS keyring backend (macOS Keychain, Windows Credential Manager,
/// Linux Secret Service / kwallet).
#[derive(Debug, Clone)]
pub struct DefaultKeyringStore {
/// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
service: String,
}
impl Default for DefaultKeyringStore {
fn default() -> Self {
Self::new(DEFAULT_SERVICE)
}
}
impl DefaultKeyringStore {
/// Build a new store with the given service name.
#[must_use]
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
/// Probe the OS keyring without writing anything. Returns `Ok(())` if
/// a backend is reachable, otherwise an error describing why not.
pub fn probe(&self) -> Result<(), SecretsError> {
// `Entry::new` is enough to surface "no backend / no storage" on
// headless Linux; no actual read happens until `.get_password()`.
let entry = keyring::Entry::new(&self.service, "__probe__")
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(keyring::Error::PlatformFailure(err)) => {
Err(SecretsError::Keyring(format!("platform failure: {err}")))
}
Err(keyring::Error::NoStorageAccess(err)) => {
Err(SecretsError::Keyring(format!("no storage access: {err}")))
}
Err(other) => Err(SecretsError::Keyring(other.to_string())),
}
}
}
impl KeyringStore for DefaultKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
entry
.set_password(value)
.map_err(|err| SecretsError::Keyring(err.to_string()))
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
fn backend_name(&self) -> &'static str {
"system keyring"
}
}
/// In-memory keyring (tests only).
#[derive(Debug, Default)]
pub struct InMemoryKeyringStore {
entries: Mutex<HashMap<String, String>>,
}
impl InMemoryKeyringStore {
/// Create an empty store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl KeyringStore for InMemoryKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
Ok(self.entries.lock().unwrap().get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
self.entries
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
fn backend_name(&self) -> &'static str {
"in-memory (test)"
}
}
/// JSON-on-disk fallback for headless environments without a Secret
/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
/// with mode `0600`.
#[derive(Debug, Clone)]
pub struct FileKeyringStore {
/// Absolute path to the JSON file.
path: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct FileSecretsBlob {
#[serde(default)]
entries: HashMap<String, String>,
}
impl FileKeyringStore {
/// Build a store backed by the given JSON file path.
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
/// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
/// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
pub fn default_path() -> Result<PathBuf, SecretsError> {
let home = dirs::home_dir().ok_or_else(|| {
SecretsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve home directory for FileKeyringStore",
))
})?;
Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
}
/// Path used for storage.
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
if !self.path.exists() {
return Ok(FileSecretsBlob::default());
}
// Reject files with unsafe permissions on unix. On Windows the
// ACL model is too different to enforce here; the caller is
// responsible for placing the file in a per-user directory.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&self.path)?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SecretsError::InsecurePermissions {
path: self.path.clone(),
mode,
});
}
}
let raw = fs::read_to_string(&self.path)?;
if raw.trim().is_empty() {
return Ok(FileSecretsBlob::default());
}
let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
Ok(blob)
}
fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let body = serde_json::to_string_pretty(blob)?;
fs::write(&self.path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&self.path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&self.path, perms)?;
}
Ok(())
}
}
impl KeyringStore for FileKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let blob = self.load_unlocked()?;
Ok(blob.entries.get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked().unwrap_or_default();
blob.entries.insert(key.to_string(), value.to_string());
self.store_unlocked(&blob)
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked().unwrap_or_default();
blob.entries.remove(key);
self.store_unlocked(&blob)
}
fn backend_name(&self) -> &'static str {
"file-based (~/.deepseek/secrets/)"
}
}
/// High-level façade combining a [`KeyringStore`] with environment
/// variable fallbacks.
///
/// Lookup precedence: **keyring → env → none**. Callers that also have
/// a TOML config layer must wire that themselves at the very end of
/// the chain.
#[derive(Clone)]
pub struct Secrets {
/// Underlying secret store.
pub store: Arc<dyn KeyringStore>,
/// Owner identifier within the keyring (typically "deepseek"); the
/// `key` parameter passed to `resolve` is mapped to a slot in the
/// store as-is, while envs are looked up by canonical name.
service: String,
}
impl std::fmt::Debug for Secrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secrets")
.field("backend", &self.store.backend_name())
.field("service", &self.service)
.finish()
}
}
impl Secrets {
/// Build a new façade around a store.
#[must_use]
pub fn new(store: Arc<dyn KeyringStore>) -> Self {
Self {
store,
service: DEFAULT_SERVICE.to_string(),
}
}
/// Construct the platform-appropriate default backend. On platforms
/// where an OS keyring backend is reachable this returns
/// [`DefaultKeyringStore`]; otherwise it falls back to
/// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
pub fn auto_detect() -> Self {
let default_store = DefaultKeyringStore::default();
match default_store.probe() {
Ok(()) => Self::new(Arc::new(default_store)),
Err(err) => {
tracing::warn!(
"OS keyring unavailable ({err}); falling back to file-backed secret store"
);
let path = FileKeyringStore::default_path()
.unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
Self::new(Arc::new(FileKeyringStore::new(path)))
}
}
}
/// Backend label, suitable for `doctor` output.
#[must_use]
pub fn backend_name(&self) -> &'static str {
self.store.backend_name()
}
/// Resolve a secret with `keyring → env → none` precedence.
///
/// `name` is the canonical provider name (`"deepseek"`,
/// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
/// Empty strings on either layer are treated as "not set".
#[must_use]
pub fn resolve(&self, name: &str) -> Option<String> {
if let Ok(Some(v)) = self.store.get(name)
&& !v.trim().is_empty()
{
return Some(v);
}
env_for(name)
}
/// Convenience: write a secret through the underlying store.
pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
self.store.set(name, value)
}
/// Convenience: delete a secret through the underlying store.
pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
self.store.delete(name)
}
/// Convenience: read a secret directly (no env fallback).
pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
self.store.get(name)
}
}
/// Map a canonical provider name to its environment variable, returning
/// the value if non-empty.
#[must_use]
pub fn env_for(name: &str) -> Option<String> {
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
"deepseek" => &["DEEPSEEK_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"novita" => &["NOVITA_API_KEY"],
// NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
// catalog endpoint accepts the same DeepSeek-issued key when no
// dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
}
"openai" => &["OPENAI_API_KEY"],
_ => return None,
};
for var in candidates {
if let Ok(value) = std::env::var(var)
&& !value.trim().is_empty()
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
/// Serialise env-mutating tests: tests in this module poke
/// `DEEPSEEK_API_KEY` etc., which is process-global.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
fn clear_known_envs() {
for var in [
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
"NOVITA_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_NIM_API_KEY",
"OPENAI_API_KEY",
] {
// Safety: tests serialise on env_lock(); the broader
// workspace has the same pattern in `crates/config`.
unsafe { std::env::remove_var(var) };
}
}
#[test]
fn in_memory_store_round_trips() {
let store = InMemoryKeyringStore::new();
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-test").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
store.set("deepseek", "sk-replaced").unwrap();
assert_eq!(
store.get("deepseek").unwrap(),
Some("sk-replaced".to_string())
);
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
// Deleting an absent key is a no-op.
store.delete("missing").unwrap();
}
#[test]
fn resolve_prefers_keyring_over_env() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", "ring-key").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_falls_back_to_env_when_keyring_empty() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_returns_none_when_both_layers_empty() {
let _lock = env_lock();
clear_known_envs();
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek"), None);
}
#[test]
fn resolve_treats_blank_keyring_value_as_unset() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", " ").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn nvidia_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
}
#[cfg(unix)]
#[test]
fn file_store_round_trips_with_secure_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("secrets.json");
let store = FileKeyringStore::new(path.clone());
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-disk").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
store.set("openrouter", "or-disk").unwrap();
assert_eq!(
store.get("openrouter").unwrap(),
Some("or-disk".to_string())
);
// First entry must still be intact.
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn file_store_rejects_world_readable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms).unwrap();
let store = FileKeyringStore::new(path);
let err = store.get("deepseek").unwrap_err();
assert!(
matches!(err, SecretsError::InsecurePermissions { .. }),
"unexpected error: {err}"
);
}
#[test]
fn file_store_default_path_uses_home() {
// We don't override HOME here (other tests do); we just check the
// shape of the path is `<home>/.deepseek/secrets/secrets.json`.
let path = FileKeyringStore::default_path().unwrap();
assert!(
path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
"unexpected default path: {}",
path.display()
);
}
}
+1
View File
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.6.0" }
async-stream = "0.3.6"
async-trait = "0.1"
bytes = "1.11.0"
+37 -42
View File
@@ -636,77 +636,72 @@ impl Config {
normalize_base_url(&base)
}
/// Read the API key from config/environment.
/// Read the API key.
///
/// Precedence: **OS keyring → environment → config file**. The
/// keyring + env layers are collapsed by [`deepseek_secrets::Secrets::resolve`];
/// the config-file fallback is preserved here for users who haven't
/// run `deepseek auth migrate` yet.
pub fn deepseek_api_key(&self) -> Result<String> {
let provider = self.api_provider();
let slot = match provider {
ApiProvider::Deepseek => "deepseek",
ApiProvider::NvidiaNim => "nvidia-nim",
ApiProvider::Openrouter => "openrouter",
ApiProvider::Novita => "novita",
};
match provider {
ApiProvider::Deepseek => {
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
ApiProvider::NvidiaNim => {
for name in ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] {
if let Ok(key) = std::env::var(name)
&& !key.trim().is_empty()
{
return Ok(key);
}
}
}
ApiProvider::Openrouter => {
if let Ok(key) = std::env::var("OPENROUTER_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
ApiProvider::Novita => {
if let Ok(key) = std::env::var("NOVITA_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
// 1. OS keyring + 2. environment variables (handled by Secrets).
let secrets = deepseek_secrets::Secrets::auto_detect();
if let Some(value) = secrets.resolve(slot)
&& !value.trim().is_empty()
{
return Ok(value);
}
// Then check config file
// 3. config file (provider-scoped slot).
if let Some(configured) = self
.provider_config_for(provider)
.and_then(|provider| provider.api_key.clone())
&& !configured.trim().is_empty()
{
tracing::warn!(
"[providers.{slot}] api_key in config.toml is deprecated; \
run 'deepseek auth set --provider {slot}' to move it to the OS keyring"
);
return Ok(configured);
}
// 4. legacy root `api_key` (deepseek only).
if let Some(configured) = self.api_key.clone()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
{
tracing::warn!(
"api_key in config.toml is deprecated; run 'deepseek auth migrate' to move it to the OS keyring"
);
return Ok(configured);
}
match provider {
ApiProvider::Deepseek => anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
1. Run 'deepseek auth set --provider deepseek' to save it in the OS keyring (recommended)\n\
2. Set DEEPSEEK_API_KEY environment variable\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml (deprecated)"
),
ApiProvider::NvidiaNim => anyhow::bail!(
"NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \
or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"."
"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \
set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \
with provider = \"nvidia-nim\"."
),
ApiProvider::Openrouter => anyhow::bail!(
"OpenRouter API key not found. Set OPENROUTER_API_KEY \
or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \
set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
),
ApiProvider::Novita => anyhow::bail!(
"Novita API key not found. Set NOVITA_API_KEY \
or add [providers.novita] api_key in ~/.deepseek/config.toml."
"Novita API key not found. Run 'deepseek auth set --provider novita', \
set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml."
),
}
}
+39 -13
View File
@@ -1347,28 +1347,54 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
// Check API keys
println!();
println!("{}", "API Keys:".bold());
let has_api_key = if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
println!(
" {} DEEPSEEK_API_KEY is set",
// Report the active keyring backend (system / file-based / unavailable).
let secrets = deepseek_secrets::Secrets::auto_detect();
println!(" · keyring backend: {}", secrets.backend_name());
// Per-provider state: keyring, env, config file (no values printed).
for (slot, env_names) in [
("deepseek", &["DEEPSEEK_API_KEY"][..]),
("nvidia-nim", &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..]),
("openrouter", &["OPENROUTER_API_KEY"][..]),
("novita", &["NOVITA_API_KEY"][..]),
] {
let in_keyring = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let in_env = env_names.iter().any(|n| {
std::env::var(n)
.ok()
.filter(|v| !v.trim().is_empty())
.is_some()
});
let icon = if in_keyring || in_env {
"".truecolor(aqua_r, aqua_g, aqua_b)
);
true
} else if config.deepseek_api_key().is_ok() {
} else {
"·".dimmed()
};
println!(
" {} DeepSeek API key found in effective config",
" {} {slot}: keyring={}, env={}",
icon,
if in_keyring { "yes" } else { "no" },
if in_env { "yes" } else { "no" }
);
}
let has_api_key = if config.deepseek_api_key().is_ok() {
println!(
" {} active provider key resolved",
"".truecolor(aqua_r, aqua_g, aqua_b)
);
true
} else {
println!(
" {} DeepSeek API key not configured",
" {} active provider key not configured",
"".truecolor(red_r, red_g, red_b)
);
println!(" Run 'deepseek' to configure interactively, or set DEEPSEEK_API_KEY");
println!(" Run 'deepseek auth set --provider <name>' to save a key to the OS keyring.");
false
};