Skip to main content

pitboard_core/
settings.rs

1//! What a session here would authenticate as, read from files rather than guessed from
2//! three environment variables.
3//!
4//! pitboard decided whether a switch would mean anything by reading `ANTHROPIC_API_KEY`,
5//! `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN` out of its own process
6//! environment. Two holes followed. Claude Code resolves authentication from a layered
7//! settings system, and any layer can set an `env` block, an `apiKeyHelper`, or one of the
8//! third-party provider switches; under any of those a session ignores the login pitboard
9//! moves, and pitboard reported a clean switch. And the app has no shell environment at
10//! all: launched from Finder it saw none of the three, so the one surface that could not
11//! warn was the one most likely to be used on a machine that needed the warning.
12//!
13//! Files are the one thing an app launched from Finder and a shell agree about. Measured in
14//! 2.1.278: managed settings live in `/Library/Application Support/ClaudeCode` on macOS and
15//! `/etc/claude-code` elsewhere, as `managed-settings.json` and a `managed-settings.d`
16//! drop-in directory beside it; a person's own are `<config dir>/settings.json`.
17//!
18//! Project settings are deliberately not read. `.claude/settings.json` is a fact about one
19//! directory, not about the machine, and an answer true in the directory pitboard happened
20//! to be run from is worse than no answer at all.
21
22use crate::context::Context;
23use crate::provider::claude::paths as claude;
24use serde_json::Value;
25use std::path::PathBuf;
26
27/// Set, any of these makes Claude Code authenticate with something other than the login in
28/// the credential store, so moving that login changes nothing a session would notice.
29pub(crate) const OVERRIDING_ENV: [&str; 9] = [
30    "ANTHROPIC_API_KEY",
31    "ANTHROPIC_AUTH_TOKEN",
32    "CLAUDE_CODE_OAUTH_TOKEN",
33    "CLAUDE_CODE_USE_BEDROCK",
34    "CLAUDE_CODE_USE_VERTEX",
35    "CLAUDE_CODE_USE_FOUNDRY",
36    "CLAUDE_CODE_USE_GATEWAY",
37    "CLAUDE_CODE_USE_ANTHROPIC_AWS",
38    "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD",
39];
40
41/// One reason a session would not use the login pitboard moves.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Override {
44    /// What said so: a path, or "the environment".
45    pub layer: String,
46    /// The setting or variable that did it.
47    pub key: String,
48}
49
50impl std::fmt::Display for Override {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "{} in {}", self.key, self.layer)
53    }
54}
55
56/// Where managed settings are, which is a fact about the machine and not about the person.
57fn managed_dir() -> PathBuf {
58    if cfg!(target_os = "macos") {
59        PathBuf::from("/Library/Application Support/ClaudeCode")
60    } else {
61        PathBuf::from("/etc/claude-code")
62    }
63}
64
65fn managed_files() -> Vec<PathBuf> {
66    let dir = managed_dir();
67    let mut files = vec![dir.join("managed-settings.json")];
68    if let Ok(entries) = std::fs::read_dir(dir.join("managed-settings.d")) {
69        let mut drop_ins: Vec<PathBuf> = entries
70            .flatten()
71            .map(|e| e.path())
72            .filter(|p| p.extension().is_some_and(|e| e == "json"))
73            .collect();
74        drop_ins.sort();
75        files.extend(drop_ins);
76    }
77    files
78}
79
80/// A value Claude Code would read as set. An empty string, `0` and `false` are not.
81fn is_set(value: &str) -> bool {
82    !matches!(value.trim(), "" | "0" | "false")
83}
84
85fn overrides_in(path: &std::path::Path) -> Vec<Override> {
86    let Ok(raw) = std::fs::read_to_string(path) else {
87        return Vec::new();
88    };
89    let Ok(json) = serde_json::from_str::<Value>(&raw) else {
90        return Vec::new();
91    };
92    let layer = path.display().to_string();
93    let mut found = Vec::new();
94    // A helper that prints an API key is an override whatever it prints.
95    if json
96        .get("apiKeyHelper")
97        .and_then(Value::as_str)
98        .is_some_and(|h| !h.trim().is_empty())
99    {
100        found.push(Override {
101            layer: layer.clone(),
102            key: "apiKeyHelper".into(),
103        });
104    }
105    if let Some(env) = json.get("env").and_then(Value::as_object) {
106        for name in OVERRIDING_ENV {
107            let set = match env.get(name) {
108                Some(Value::String(v)) => is_set(v),
109                Some(Value::Bool(b)) => *b,
110                Some(Value::Number(n)) => n.as_i64() != Some(0),
111                _ => false,
112            };
113            if set {
114                found.push(Override {
115                    layer: layer.clone(),
116                    key: format!("env.{name}"),
117                });
118            }
119        }
120    }
121    found
122}
123
124/// Whether a custom OAuth endpoint is configured. Set, it renames both the keychain item
125/// and the config file Claude Code uses, so pitboard would be reading and writing the wrong
126/// ones: a refusal rather than a warning.
127///
128/// Read from files as well as from this process's environment, because a `CLAUDE_CODE_CUSTOM_OAUTH_URL`
129/// in a settings file is just as real and the app could not see the environment one.
130pub fn custom_oauth(ctx: &Context) -> bool {
131    if ctx.custom_oauth() {
132        return true;
133    }
134    managed_files()
135        .iter()
136        .chain(std::iter::once(
137            &claude::config_dir(ctx).join("settings.json"),
138        ))
139        .any(|path| {
140            std::fs::read_to_string(path)
141                .ok()
142                .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
143                .and_then(|json| {
144                    json.get("env")?
145                        .get("CLAUDE_CODE_CUSTOM_OAUTH_URL")?
146                        .as_str()
147                        .map(is_set)
148                })
149                .unwrap_or(false)
150        })
151}
152
153/// Every reason a session started here would authenticate as something other than the login
154/// pitboard moves. Empty means a switch changes what a session sees, which is the answer
155/// pitboard needs before it moves anything.
156pub fn overrides(ctx: &Context) -> Vec<Override> {
157    let mut found: Vec<Override> = managed_files()
158        .iter()
159        .chain(std::iter::once(
160            &claude::config_dir(ctx).join("settings.json"),
161        ))
162        .flat_map(|path| overrides_in(path))
163        .collect();
164    // What this process was started with, which a command line sees and an app does not.
165    found.extend(ctx.overriding_auth().iter().map(|key| Override {
166        layer: "the environment".into(),
167        key: key.clone(),
168    }));
169    found
170}
171
172#[cfg(test)]
173pub(crate) mod tests_support {
174    use super::*;
175
176    pub(crate) struct Scratch(pub(crate) PathBuf);
177    impl Drop for Scratch {
178        fn drop(&mut self) {
179            let _ = std::fs::remove_dir_all(&self.0);
180        }
181    }
182
183    pub(crate) fn home(name: &str) -> (Context, PathBuf, Scratch) {
184        let root = std::env::temp_dir().join(format!(
185            "pitboard-settings-{name}-{}-{:?}",
186            std::process::id(),
187            std::thread::current().id()
188        ));
189        let _ = std::fs::remove_dir_all(&root);
190        let config = root.join(".claude");
191        std::fs::create_dir_all(&config).expect("a config dir");
192        let ctx = Context::new(root.clone())
193            .with_claude_config_dir(config.to_string_lossy().into_owned());
194        (ctx, config, Scratch(root))
195    }
196
197    pub(crate) fn write(path: &std::path::Path, value: Value) {
198        std::fs::write(path, value.to_string()).expect("written");
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::tests_support::*;
205    use super::*;
206    use serde_json::json;
207
208    #[test]
209    fn an_ordinary_machine_has_nothing_in_the_way() {
210        let (ctx, config, _s) = home("clean");
211        write(&config.join("settings.json"), json!({"theme": "dark"}));
212        assert_eq!(overrides(&ctx), Vec::new());
213    }
214
215    #[test]
216    fn a_settings_file_that_sets_a_key_is_found_where_the_environment_would_not_be() {
217        let (ctx, config, _s) = home("env-block");
218        write(
219            &config.join("settings.json"),
220            json!({"env": {"ANTHROPIC_API_KEY": "sk-ant-something"}}),
221        );
222        let found = overrides(&ctx);
223        assert_eq!(found.len(), 1, "{found:?}");
224        assert_eq!(found[0].key, "env.ANTHROPIC_API_KEY");
225        assert!(found[0].layer.ends_with("settings.json"));
226    }
227
228    #[test]
229    fn a_helper_that_prints_a_key_is_an_override_whatever_it_prints() {
230        let (ctx, config, _s) = home("helper");
231        write(
232            &config.join("settings.json"),
233            json!({"apiKeyHelper": "/usr/local/bin/get-key"}),
234        );
235        assert_eq!(overrides(&ctx)[0].key, "apiKeyHelper");
236    }
237
238    #[test]
239    fn a_third_party_provider_is_an_override_too() {
240        let (ctx, config, _s) = home("bedrock");
241        write(
242            &config.join("settings.json"),
243            json!({"env": {"CLAUDE_CODE_USE_BEDROCK": "1"}}),
244        );
245        assert_eq!(overrides(&ctx)[0].key, "env.CLAUDE_CODE_USE_BEDROCK");
246    }
247
248    /// Claude Code reads an empty string, `0` and `false` as not set, and so does this.
249    #[test]
250    fn a_key_that_is_present_but_empty_sets_nothing() {
251        let (ctx, config, _s) = home("empty");
252        write(
253            &config.join("settings.json"),
254            json!({
255                "apiKeyHelper": "  ",
256                "env": {
257                    "ANTHROPIC_API_KEY": "",
258                    "CLAUDE_CODE_USE_BEDROCK": "0",
259                    "CLAUDE_CODE_USE_VERTEX": "false",
260                },
261            }),
262        );
263        assert_eq!(overrides(&ctx), Vec::new());
264    }
265
266    #[test]
267    fn a_settings_file_that_is_not_json_is_not_a_reason_to_refuse_anything() {
268        let (ctx, config, _s) = home("broken");
269        std::fs::write(config.join("settings.json"), "{ not json").expect("written");
270        assert_eq!(overrides(&ctx), Vec::new());
271    }
272}
273
274#[cfg(test)]
275mod custom_oauth_tests {
276    use super::tests_support::*;
277    use super::*;
278    use serde_json::json;
279
280    #[test]
281    fn a_custom_endpoint_in_a_settings_file_is_found_the_way_the_environment_one_is() {
282        let (ctx, config, _s) = home("custom-oauth");
283        assert!(!custom_oauth(&ctx));
284        write(
285            &config.join("settings.json"),
286            json!({"env": {"CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example"}}),
287        );
288        assert!(custom_oauth(&ctx));
289    }
290}