Skip to main content

pitboard_core/
context.rs

1//! Everything Pitboard takes from its environment, read in one place. Every front end builds
2//! its `Context` from the variables it was started with, by the same code: the command line
3//! from its own, once. An app passes the ones it was started with, which are not a shell's,
4//! so it also looks for each tool's program itself, in [`crate::app`].
5//!
6//! This module is where the process's environment is read. `clippy.toml` refuses
7//! `std::env::var` and its kind anywhere else, unless an `#[allow]` there says why.
8#![allow(
9    clippy::disallowed_methods,
10    reason = "the one module that reads this process's environment"
11)]
12
13use crate::host::{Host, Os};
14use crate::provider::ProviderId;
15use crate::provider::claude::api::{Anthropic, Api};
16use crate::provider::codex::api::{Network as OpenAiNetwork, OpenAi};
17use crate::time::{Clock, SystemClock};
18use std::collections::HashMap;
19use std::ffi::{OsStr, OsString};
20use std::path::PathBuf;
21use std::sync::Arc;
22
23/// Every variable Pitboard reads from the environment it was started with, besides
24/// [`crate::settings::OVERRIDING_ENV`] and the proxy variables, which [`crate::proxy`] lists
25/// in the order ureq tries them: what [`Context::read`] consults, the shell an app
26/// asks for its `PATH`, and the three read straight from the process: `PATH` where a context
27/// was given no search path, and where Linux's host finds the path Pitboard was started by,
28/// `NO_COLOR` by the command line's status line, and `XPC_SERVICE_NAME` by launchd's host.
29/// Each of those reads carries an `#[allow]` saying why it is not made through
30/// [`Environment`].
31///
32/// [`Environment`] refuses, in a build with debug assertions, to read a name missing here,
33/// so every test that reads a context fails until a new variable is added. The tests take
34/// nothing on this list from whoever runs them, except what they pass on by name.
35const READ: &[&str] = &[
36    "HOME",
37    "USER",
38    "PATH",
39    "SHELL",
40    "PITBOARD_HOME",
41    "PITBOARD_NO_ARGV",
42    "PITBOARD_API_BASE",
43    "PITBOARD_CLAUDE",
44    "PITBOARD_CODEX",
45    "CLAUDE_CONFIG_DIR",
46    "CLAUDE_SECURESTORAGE_CONFIG_DIR",
47    "CLAUDE_CODE_CUSTOM_OAUTH_URL",
48    "CLAUDE_CODE_HOVER_REST",
49    "CLAUDE_CODE_FORCE_WINDOWS_CREDMAN",
50    "CLAUDE_CODE_GIT_BASH_PATH",
51    "WSL_DISTRO_NAME",
52    "CODEX_HOME",
53    "CODEX_INSTALL_DIR",
54    "NO_COLOR",
55    "XPC_SERVICE_NAME",
56    "SUDO_UID",
57    "USERPROFILE",
58    "USERNAME",
59    "APPDATA",
60    "LOCALAPPDATA",
61    "ProgramData",
62    "ProgramFiles",
63    "SystemRoot",
64    "PATHEXT",
65    "TEMP",
66    "TMP",
67    "OneDrive",
68    "OneDriveCommercial",
69    "OneDriveConsumer",
70];
71
72// Read only by a build for tests: a release lists none and reads none.
73#[cfg(any(test, feature = "test-support"))]
74const READ_BY_BUILDS_FOR_TESTS: &[&str] = &[TEST_CLAUDE_DESKTOP];
75#[cfg(not(any(test, feature = "test-support")))]
76const READ_BY_BUILDS_FOR_TESTS: &[&str] = &[];
77
78#[cfg(any(test, feature = "test-support"))]
79const TEST_CLAUDE_DESKTOP: &str = "PITBOARD_TEST_CLAUDE_DESKTOP";
80
81/// Every variable Pitboard reads from its environment, by name.
82pub(crate) fn variables() -> impl Iterator<Item = &'static str> {
83    READ.iter()
84        .chain(READ_BY_BUILDS_FOR_TESTS.iter())
85        .chain(crate::settings::OVERRIDING_ENV.iter())
86        .chain(crate::proxy::NAMING.iter())
87        .chain(crate::proxy::EXEMPTING.iter())
88        .copied()
89}
90
91/// The variables a process was started with, by name.
92///
93/// The command line has a shell's. An app the system started has launchd's, which name the
94/// home and the login and little else, unless somebody set more with `launchctl setenv`;
95/// whatever is there is read the way the command line reads it.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct Environment {
98    os: Os,
99    variables: HashMap<OsString, (OsString, OsString)>,
100}
101
102impl Default for Environment {
103    fn default() -> Environment {
104        Environment::on(crate::host::OS, std::iter::empty::<(OsString, OsString)>())
105    }
106}
107
108impl Environment {
109    /// This process's own.
110    pub fn of_this_process() -> Environment {
111        std::env::vars_os().collect()
112    }
113
114    pub(crate) fn on<K: Into<OsString>, V: Into<OsString>>(
115        os: Os,
116        pairs: impl IntoIterator<Item = (K, V)>,
117    ) -> Environment {
118        let mut variables = HashMap::new();
119        for (name, value) in pairs {
120            let (name, value) = (name.into(), value.into());
121            let key = os.variable_key(&name);
122            match os {
123                Os::MacOs | Os::Linux => {
124                    variables.insert(key, (name, value));
125                }
126                // RF-B4: Windows hands a program the first of two spellings of a name.
127                Os::Windows => {
128                    variables.entry(key).or_insert((name, value));
129                }
130            }
131        }
132        Environment { os, variables }
133    }
134
135    pub(crate) fn os(&self) -> Os {
136        self.os
137    }
138
139    /// `name`'s value, whatever bytes it holds, as a path may hold any.
140    pub(crate) fn path(&self, name: &str) -> Option<&OsStr> {
141        debug_assert!(
142            variables().any(|read| read == name),
143            "{name} is read but not listed in context::READ, so the tests would take it \
144             from whoever runs them"
145        );
146        self.variables
147            .get(&self.os.variable_key(OsStr::new(name)))
148            .map(|(_, value)| value.as_os_str())
149    }
150
151    /// `name`'s value as text. One that is not UTF-8 reads as unset, as `std::env::var`
152    /// reads it.
153    pub(crate) fn text(&self, name: &str) -> Option<&str> {
154        self.path(name).and_then(OsStr::to_str)
155    }
156
157    // On Windows `all_proxy` may be how `ALL_PROXY` was set.
158    pub(crate) fn spelling(&self, name: &str) -> Option<&OsStr> {
159        self.path(name)?;
160        self.variables
161            .get(&self.os.variable_key(OsStr::new(name)))
162            .map(|(spelled, _)| spelled.as_os_str())
163    }
164
165    /// Whether `name` holds anything: empty reads as unset.
166    pub(crate) fn set(&self, name: &str) -> bool {
167        self.text(name).is_some_and(|v| !v.is_empty())
168    }
169
170    // The folder `name` names, whatever bytes it holds; none where it is unset or empty.
171    pub(crate) fn named_folder(&self, name: &str) -> Option<PathBuf> {
172        self.path(name)
173            .filter(|folder| !folder.is_empty())
174            .map(PathBuf::from)
175    }
176
177    // Set, even empty, it is the person's: `check_absolute` refuses an empty or relative one.
178    pub(crate) fn home(&self) -> PathBuf {
179        self.path(self.os.home_variable())
180            .map(PathBuf::from)
181            .or_else(crate::host::user::home)
182            .unwrap_or_default()
183    }
184
185    // As the environment spells it, any `.`, `..` or trailing separator kept.
186    pub fn pitboard_home(&self) -> PathBuf {
187        self.path("PITBOARD_HOME")
188            .map(PathBuf::from)
189            .unwrap_or_else(|| self.os.default_pitboard_home(&self.home()))
190    }
191
192    /// Every variable, as a program started in this environment is given them.
193    #[cfg_attr(
194        windows,
195        allow(
196            dead_code,
197            reason = "what the login shell is given, and Windows has none (`host::login_path`)"
198        )
199    )]
200    pub(crate) fn iter(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
201        self.variables
202            .values()
203            .map(|(name, value)| (name.as_os_str(), value.as_os_str()))
204    }
205}
206
207/// The one folder every home of [`Context::for_unit_test`] names for the test running on
208/// this thread: one per test, since the test harness names each test's thread after the
209/// test, under the temporary directory, and never made by anything here.
210#[cfg(test)]
211fn unit_test_sentinel() -> PathBuf {
212    let thread = std::thread::current();
213    let test = thread.name().map_or_else(
214        || format!("{:?}", thread.id()),
215        |name| {
216            name.chars()
217                .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
218                .collect()
219        },
220    );
221    std::env::temp_dir()
222        .join(format!("pitboard-unit-test-{}", std::process::id()))
223        .join(test)
224}
225
226impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for Environment {
227    fn from_iter<I: IntoIterator<Item = (K, V)>>(pairs: I) -> Environment {
228        Environment::on(crate::host::OS, pairs)
229    }
230}
231
232// Declared in every build, so code behind `cfg!` compiles in a release too.
233#[derive(Debug, Clone)]
234pub struct ScratchClaude {
235    pub data: PathBuf,
236    pub app: PathBuf,
237}
238
239// Through every link, against the home the system names whatever the environment says.
240#[cfg(any(test, feature = "test-support"))]
241pub(crate) fn refuse_a_real_claude(path: &std::path::Path) {
242    use crate::host::user;
243    assert!(
244        !path
245            .components()
246            .any(|part| part == std::path::Component::ParentDir),
247        "a test named {} for Claude through `..`, which may lead anywhere once a folder is made: \
248         give it a folder of its own",
249        path.display()
250    );
251    #[cfg(test)]
252    let fake = testing::guarded_home();
253    #[cfg(not(test))]
254    let fake: Option<PathBuf> = None;
255    let real = match crate::host::OS {
256        Os::MacOs | Os::Linux => {
257            ["Library/Application Support", "Applications/Claude.app"]
258                .into_iter()
259                .any(|below| {
260                    user::lies_in_the_accounts_own(path, below)
261                        || fake
262                            .as_ref()
263                            .is_some_and(|home| crate::host::lies_in(path, &home.join(below)))
264                })
265                || crate::host::lies_in(path, std::path::Path::new("/Applications/Claude.app"))
266        }
267        Os::Windows => {
268            let (mut roaming, mut local) = user::app_data_folders(true);
269            if let Some(home) = &fake {
270                roaming.push(home.join("AppData").join("Roaming"));
271                local.push(home.join("AppData").join("Local"));
272            }
273            crate::host::claude_desktop_on_windows(&roaming, &local)
274                .iter()
275                .any(|place| crate::host::lies_in(path, place))
276        }
277    };
278    assert!(
279        !real,
280        "a test named {} for Claude, which is, holds or lies in where this account's own \
281         Claude Desktop keeps its data or its app: give it a folder of its own",
282        path.display()
283    );
284}
285
286#[cfg(test)]
287pub(crate) mod testing {
288    use std::cell::RefCell;
289    use std::path::{Path, PathBuf};
290
291    thread_local! {
292        static GUARDED: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
293    }
294
295    // While what this returns is kept, the guards on this thread's test refuse `home`'s places
296    // as well as the account's own, so a test of them need name no real place.
297    pub(crate) fn also_guarding(home: &Path) -> AlsoGuarding {
298        GUARDED.with(|guarded| *guarded.borrow_mut() = Some(home.to_path_buf()));
299        AlsoGuarding(())
300    }
301
302    pub(crate) struct AlsoGuarding(());
303
304    impl Drop for AlsoGuarding {
305        fn drop(&mut self) {
306            GUARDED.with(|guarded| guarded.borrow_mut().take());
307        }
308    }
309
310    pub(super) fn guarded_home() -> Option<PathBuf> {
311        GUARDED.with(|guarded| guarded.borrow().clone())
312    }
313}
314
315#[derive(Clone, Debug)]
316pub struct Context {
317    // This build's system, but where a test reads an environment as another's.
318    pub(crate) os: Os,
319    pub(crate) home: PathBuf,
320    pub(crate) pitboard_home: PathBuf,
321    /// `CLAUDE_CONFIG_DIR`, held as it is set, empty included. Claude Code reads an empty
322    /// value as unset for its config file and its credential slot's name, but takes it as
323    /// the empty path for its config dir, which then is whatever folder it runs in, as the
324    /// register's `config_file_location` says. So an empty one is a home that is not a full
325    /// path, and refused as one ([`crate::home::check_absolute`]).
326    pub(crate) claude_config_dir: Option<String>,
327    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`, which Claude Code reads with `!== undefined`:
328    /// empty is set, and pins the default credential slot.
329    pub(crate) secure_storage_dir: Option<String>,
330    /// `$USER`, which names Claude Code's keychain account once screened by `slot`.
331    pub(crate) user: Option<String>,
332    /// `CLAUDE_CODE_CUSTOM_OAUTH_URL`. Set, it renames both the keychain item and the config
333    /// file Claude Code uses, so Pitboard would be reading and writing the wrong ones.
334    pub(crate) custom_oauth: bool,
335    /// Environment variables this process was started with that make Claude Code use
336    /// something other than the login Pitboard moves. Only half the answer: the rest is in
337    /// files, which [`crate::settings::overrides`] reads and an app can see too.
338    pub(crate) overriding_auth: Vec<String>,
339    /// Whether a login too large for `security -i` may be written the way Claude Code
340    /// writes it: as a command argument, where `ps` can see it for the length of the call.
341    /// On by default, because there is no third way and Claude Code writes the same
342    /// document that way itself on every token refresh.
343    pub(crate) argv_fallback: bool,
344    /// Which front end asked, for the audit log. A change made from the menu bar and one
345    /// typed at a prompt read the same otherwise.
346    pub(crate) caller: String,
347    // each tool's program for a sign-in; a bare name is looked up on the search path
348    pub(crate) programs: std::collections::BTreeMap<ProviderId, PathBuf>,
349    /// Where Anthropic's endpoints are reached instead, for tests; `api` honours loopback only.
350    pub(crate) api_base: Option<String>,
351    /// The proxy the environment names for Pitboard's requests, read as ureq 3.4.2 reads one.
352    /// None in a context made without an environment.
353    pub(crate) proxy: crate::proxy::Proxies,
354    /// The agent this context's requests go out on, made with `proxy` for the first request
355    /// and shared by every copy of the context.
356    pub(crate) agent: crate::api::Agents,
357    /// `CLAUDE_CODE_HOVER_REST`, which switches on Claude Code's successor credential backend.
358    pub(crate) hover_rest: bool,
359    // `CLAUDE_CODE_FORCE_WINDOWS_CREDMAN` as exactly `1`, which turns Claude Code's Credential
360    // Manager store on (`windows_backend_choice`).
361    pub(crate) credential_manager_forced: bool,
362    /// `CODEX_HOME`, which moves everything Codex keeps, including its keyring account.
363    pub(crate) codex_home: Option<String>,
364    // Windows' `FOLDERID_Profile`, where Codex finds its home with `CODEX_HOME` unset.
365    pub(crate) profile: Option<PathBuf>,
366    // Windows' `LOCALAPPDATA`, as the environment gives it.
367    pub(crate) local_app_data: Option<PathBuf>,
368    // Windows' `ProgramFiles` and `SystemRoot`, where Claude Code looks for PowerShell.
369    pub(crate) program_files: Option<PathBuf>,
370    pub(crate) system_root: Option<PathBuf>,
371    // The person's Windows SID, which an `icacls` line names and a report hides.
372    pub(crate) sid: Option<String>,
373    // The folders OneDrive syncs on Windows, as its variables name them.
374    pub(crate) one_drive: Vec<PathBuf>,
375    /// The Pitboard the daily renewal schedule runs. `None` is this program, which is right
376    /// for the command line and wrong for an app: the schedule runs `pitboard renew`, so an
377    /// app names the command line it comes with.
378    pub(crate) schedule_program: Option<PathBuf>,
379    /// Where a tool's program is looked for, in `PATH`'s form, and what its sign-in is given
380    /// as `PATH`, behind the program's own directory where that is not on it. `None` is this
381    /// process's own `PATH`: an app opened from Finder has almost nothing on it, so it
382    /// passes the one the person's login shell would have.
383    pub(crate) search_path: Option<OsString>,
384    // Where each tool's installers put its program, which an app also looks in and a command
385    // line leaves to the shell's `PATH`.
386    pub(crate) installer_places: std::collections::BTreeMap<ProviderId, Vec<PathBuf>>,
387    // `PATHEXT`, which orders the names a Windows shell tries for a program.
388    pub(crate) path_ext: Option<OsString>,
389    // `CLAUDE_CODE_GIT_BASH_PATH`, the Git Bash Claude Code looks at first on Windows.
390    pub(crate) git_bash: Option<PathBuf>,
391    // `WSL_DISTRO_NAME` on Linux, set even empty, which WSL gives its programs.
392    pub(crate) wsl_distro: Option<String>,
393    /// The scheduler's job this process runs as, where a test says. `None` is whatever the
394    /// scheduler said when it started this process.
395    pub(crate) scheduled_job: Option<String>,
396    /// Whether this process was started with the marker the schedule's job carries where
397    /// what the system says of the job it started is not to be relied on
398    /// ([`crate::schedule::SCHEDULED_RUN`]), as the front end it was started as read it.
399    pub(crate) marked_as_scheduled: bool,
400    /// Whether this process was started under sudo, which sets `SUDO_UID`. Part of what the
401    /// host answers when the one gate every change passes asks whether this process runs as
402    /// the person themselves.
403    pub(crate) sudo: bool,
404    /// Where the time comes from. The machine's clock in every real context; a test puts
405    /// its own here to reach the judgements that only happen at a particular moment.
406    pub(crate) clock: Arc<dyn Clock>,
407    /// How Claude Code's locks are kept and waited for: as Claude Code does in every real
408    /// context.
409    pub(crate) locks: crate::lock::Locks,
410    /// The machine: its stores, its processes, its scheduler. This build's host in every
411    /// real context.
412    pub(crate) host: Arc<dyn Host>,
413    /// Who answers for Anthropic. The network in every real context.
414    pub(crate) api: Arc<dyn Api>,
415    /// Who answers for OpenAI. The network in every real context.
416    pub(crate) openai: Arc<dyn OpenAi>,
417    /// Who plays each tool's own sign-in in place of its program, where a test or a fixture
418    /// says. `None` in every real context, and in any build without `test-support` there is
419    /// nothing here at all.
420    #[cfg(any(test, feature = "test-support"))]
421    pub(crate) sign_in_script: Option<Arc<dyn crate::switch::SignInScript>>,
422    #[cfg(any(test, feature = "test-support"))]
423    scratch_claude: Option<ScratchClaude>,
424    #[cfg(any(test, feature = "test-support"))]
425    awaited_every: Option<std::time::Duration>,
426    #[cfg(test)]
427    unknown_machine: bool,
428    /// Who plays a warm-up's run in place of the tool's program, where a test or a fixture
429    /// says. `None` in every real context, and in any build without `test-support` there is
430    /// nothing here at all.
431    #[cfg(any(test, feature = "test-support"))]
432    pub(crate) warm_script: Option<Arc<dyn crate::duty::warm::run::WarmScript>>,
433}
434
435fn programs(
436    named: impl Fn(ProviderId, crate::provider::Cli) -> PathBuf,
437) -> std::collections::BTreeMap<ProviderId, PathBuf> {
438    ProviderId::ALL
439        .iter()
440        .filter_map(|&tool| Some((tool, named(tool, tool.cli()?))))
441        .collect()
442}
443
444impl Context {
445    /// Epoch seconds, from this context's clock.
446    pub(crate) fn now(&self) -> i64 {
447        self.clock.now()
448    }
449
450    /// Epoch milliseconds, from this context's clock.
451    pub(crate) fn now_millis(&self) -> i64 {
452        self.clock.now_millis()
453    }
454
455    /// The time zone of this context's clock: the machine's, unless a test says otherwise.
456    pub(crate) fn zone(&self) -> jiff::tz::TimeZone {
457        self.clock.zone()
458    }
459
460    /// The person's home directory, which is where a platform's own scheduler lives.
461    pub(crate) fn home(&self) -> &std::path::Path {
462        &self.home
463    }
464
465    // The one the schedule renews, whatever `PITBOARD_HOME` says.
466    pub(crate) fn default_pitboard_home(&self) -> PathBuf {
467        self.os.default_pitboard_home(&self.home)
468    }
469
470    /// The machine this context reaches.
471    pub(crate) fn host(&self) -> &dyn Host {
472        self.host.as_ref()
473    }
474
475    /// Who this context asks about a login.
476    pub(crate) fn openai(&self) -> &dyn OpenAi {
477        self.openai.as_ref()
478    }
479
480    pub(crate) fn api(&self) -> &dyn Api {
481        self.api.as_ref()
482    }
483
484    #[cfg(any(test, feature = "test-support"))]
485    #[doc(hidden)]
486    pub fn new(home: PathBuf) -> Context {
487        Context {
488            os: crate::host::OS,
489            pitboard_home: crate::host::OS.default_pitboard_home(&home),
490            home,
491            claude_config_dir: None,
492            secure_storage_dir: None,
493            user: None,
494            custom_oauth: false,
495            argv_fallback: true,
496            overriding_auth: Vec::new(),
497            caller: "unknown".into(),
498            programs: programs(|tool, _| PathBuf::from(tool.program())),
499            api_base: None,
500            proxy: crate::proxy::Proxies::default(),
501            agent: crate::api::Agents::default(),
502            hover_rest: false,
503            credential_manager_forced: false,
504            codex_home: None,
505            profile: None,
506            local_app_data: None,
507            program_files: None,
508            system_root: None,
509            sid: None,
510            one_drive: Vec::new(),
511            schedule_program: None,
512            search_path: None,
513            installer_places: std::collections::BTreeMap::new(),
514            path_ext: None,
515            git_bash: None,
516            wsl_distro: None,
517            scheduled_job: None,
518            marked_as_scheduled: false,
519            sudo: false,
520            clock: Arc::new(SystemClock),
521            locks: crate::lock::Locks::CLAUDE_CODE,
522            host: crate::host::current(),
523            api: Arc::new(Anthropic),
524            openai: Arc::new(OpenAiNetwork),
525            #[cfg(any(test, feature = "test-support"))]
526            sign_in_script: None,
527            #[cfg(any(test, feature = "test-support"))]
528            scratch_claude: None,
529            #[cfg(any(test, feature = "test-support"))]
530            awaited_every: None,
531            #[cfg(test)]
532            unknown_machine: false,
533            #[cfg(any(test, feature = "test-support"))]
534            warm_script: None,
535        }
536    }
537
538    /// Where Codex keeps its login. Empty means unset, as Codex reads it.
539    pub fn with_codex_home(mut self, dir: String) -> Context {
540        self.codex_home = Some(dir).filter(|d| !d.is_empty());
541        self
542    }
543
544    pub(crate) fn codex_home(&self) -> Option<&str> {
545        self.codex_home.as_deref()
546    }
547
548    #[cfg(test)]
549    pub(crate) fn with_profile(mut self, profile: PathBuf) -> Context {
550        self.profile = Some(profile);
551        self
552    }
553
554    pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
555        self.pitboard_home = dir;
556        self
557    }
558
559    /// Claude Code's config directory, as `CLAUDE_CONFIG_DIR` names it. Empty is kept, as
560    /// Claude Code keeps it: unset for its config file and its credential slot's name, but
561    /// the empty path for its config dir, as the register's `config_file_location` says,
562    /// which is refused as a home that is not a full path.
563    #[cfg(any(test, feature = "test-support"))]
564    #[doc(hidden)]
565    pub fn with_claude_config_dir(mut self, dir: String) -> Context {
566        self.claude_config_dir = Some(dir);
567        self
568    }
569
570    /// Where a sign-in Pitboard ran keeps Claude Code's config, as `CLAUDE_CONFIG_DIR` named
571    /// it for that sign-in, for reading what the sign-in recorded.
572    pub(crate) fn signed_in_at(&self, dir: &std::path::Path) -> Context {
573        let mut ctx = self.clone();
574        ctx.claude_config_dir = Some(dir.to_string_lossy().into_owned());
575        ctx
576    }
577
578    /// Empty is set, and pins the default slot, as Claude Code reads
579    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
580    #[cfg(test)]
581    pub(crate) fn with_secure_storage_dir(mut self, dir: String) -> Context {
582        self.secure_storage_dir = Some(dir);
583        self
584    }
585
586    /// Allows the argument-line write for a login too large for the stdin one.
587    #[cfg(any(test, feature = "test-support"))]
588    #[doc(hidden)]
589    pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
590        self.argv_fallback = allowed;
591        self
592    }
593
594    /// Whether a custom OAuth endpoint is configured, which moves Claude Code's login.
595    pub(crate) fn custom_oauth(&self) -> bool {
596        self.custom_oauth
597    }
598
599    /// The `claude` Pitboard would run to sign someone in.
600    pub fn claude_program(&self) -> &std::path::Path {
601        &self.programs[&ProviderId::Claude]
602    }
603
604    /// Whether the argument-line write is allowed for an oversized login.
605    pub(crate) fn argv_fallback(&self) -> bool {
606        self.argv_fallback
607    }
608
609    /// Environment variables that authenticate Claude Code some other way, if any.
610    pub(crate) fn overriding_auth(&self) -> &[String] {
611        &self.overriding_auth
612    }
613
614    /// Names the front end in the audit log.
615    #[cfg(any(test, feature = "test-support"))]
616    #[doc(hidden)]
617    pub fn with_caller(mut self, caller: String) -> Context {
618        self.caller = caller;
619        self
620    }
621
622    #[cfg(any(test, feature = "test-support"))]
623    #[doc(hidden)]
624    pub fn with_user(mut self, user: String) -> Context {
625        self.user = Some(user);
626        self
627    }
628
629    /// An app started from Finder does not see the shell's `PATH`, so it names `claude` itself.
630    #[cfg(any(test, feature = "test-support"))]
631    #[doc(hidden)]
632    pub fn with_claude_program(mut self, program: PathBuf) -> Context {
633        self.programs.insert(ProviderId::Claude, program);
634        self
635    }
636
637    /// The same for `codex`.
638    #[cfg(any(test, feature = "test-support"))]
639    #[doc(hidden)]
640    pub fn with_codex_program(mut self, program: PathBuf) -> Context {
641        self.programs.insert(ProviderId::Codex, program);
642        self
643    }
644
645    /// The `codex` Pitboard would run to sign someone in.
646    pub fn codex_program(&self) -> &std::path::Path {
647        &self.programs[&ProviderId::Codex]
648    }
649
650    /// An app is not a command line, so it names the one it comes with for the schedule to
651    /// run.
652    #[cfg(any(test, feature = "test-support"))]
653    #[doc(hidden)]
654    pub fn with_schedule_program(mut self, program: PathBuf) -> Context {
655        self.schedule_program = Some(program);
656        self
657    }
658
659    /// The Pitboard the daily renewal schedule is written to run, where one was named.
660    pub fn schedule_program(&self) -> Option<&std::path::Path> {
661        self.schedule_program.as_deref()
662    }
663
664    /// Look for a tool's program on `path`, in `PATH`'s form, rather than on this process's
665    /// own `PATH`. An app opened from Finder has only the system's directories there, so a
666    /// tool installed through a version manager or an npm prefix is found only on the `PATH`
667    /// the person's shell has, and a script it runs finds its interpreter only there.
668    #[cfg(any(test, feature = "test-support"))]
669    #[doc(hidden)]
670    pub fn with_search_path(mut self, path: String) -> Context {
671        self.search_path = Some(path.into());
672        self
673    }
674
675    // How a shell on this context's system spells a program it looks for.
676    pub(crate) fn lookup(&self) -> crate::host::program::Lookup {
677        crate::host::program::Lookup::new(self.os, self.path_ext.as_deref())
678    }
679
680    /// Where a tool's program is looked for: the path given, or this process's `PATH`.
681    pub(crate) fn search_path(&self) -> OsString {
682        self.search_path
683            .clone()
684            .or_else(|| std::env::var_os("PATH"))
685            .unwrap_or_default()
686    }
687
688    /// The scheduler's job this process runs as, where a test has said. The scheduler's own
689    /// word for it, where it has one, is its to read.
690    pub(crate) fn scheduled_job(&self) -> Option<&str> {
691        self.scheduled_job.as_deref()
692    }
693
694    /// Whether this process was started under sudo.
695    pub(crate) fn sudo(&self) -> bool {
696        self.sudo
697    }
698
699    /// Say this process runs as the scheduler's job `label`, which no test does.
700    #[cfg(test)]
701    pub(crate) fn with_scheduled_job(mut self, label: String) -> Context {
702        self.scheduled_job = Some(label);
703        self
704    }
705
706    /// Say this process is a run of the daily renewal schedule, as the marker its job is
707    /// started with says where what the system says of the job it started is not to be
708    /// relied on: the command line does this for `pitboard renew --scheduled`. Such a run
709    /// renews the default home whatever `PITBOARD_HOME` says
710    /// ([`crate::schedule::SCHEDULED_RUN`]).
711    pub fn started_by_the_schedule(mut self) -> Context {
712        self.marked_as_scheduled = true;
713        self
714    }
715
716    /// Whether this process was started with the schedule's marker.
717    pub(crate) fn marked_as_scheduled(&self) -> bool {
718        self.marked_as_scheduled
719    }
720
721    // `given` over this context's own proxy variables, as a job's are given over the system's.
722    pub(crate) fn with_proxy_variables_over(mut self, given: &[(String, String)]) -> Context {
723        let os = self.os;
724        let key = |name: &str| os.variable_key(OsStr::new(name));
725        let own = self
726            .proxy
727            .variables()
728            .iter()
729            .filter(|(name, _)| !given.iter().any(|(over, _)| key(over) == key(name)))
730            .map(|(name, value)| ((*name).to_string(), value.clone()));
731        let env = Environment::on(os, given.iter().cloned().chain(own));
732        self.proxy = crate::proxy::Proxies::read(&env);
733        self.agent = crate::api::Agents::default();
734        self
735    }
736
737    // The program named for this tool, found or not; None for a tool with no program to run.
738    pub fn program_for(&self, tool: ProviderId) -> Option<&std::path::Path> {
739        self.programs.get(&tool).map(PathBuf::as_path)
740    }
741
742    // The Claude a test named; None in a release.
743    pub(crate) fn scratch_claude(&self) -> Option<&ScratchClaude> {
744        #[cfg(any(test, feature = "test-support"))]
745        {
746            self.scratch_claude.as_ref()
747        }
748        #[cfg(not(any(test, feature = "test-support")))]
749        {
750            None
751        }
752    }
753
754    // Refuses before anything there is listed or opened.
755    #[cfg(any(test, feature = "test-support"))]
756    #[doc(hidden)]
757    pub fn with_scratch_claude(mut self, folder: PathBuf) -> Context {
758        assert!(
759            folder.is_absolute(),
760            "a test named its Claude folder {} relative to wherever it runs: name it in full",
761            folder.display()
762        );
763        let scratch = ScratchClaude {
764            data: folder.join("data"),
765            app: folder.join("Claude.app"),
766        };
767        for named in [&folder, &scratch.data, &scratch.app] {
768            refuse_a_real_claude(named);
769        }
770        self.scratch_claude = Some(scratch);
771        self
772    }
773
774    // How often a sign-in waited for asks the process list: a second, unless a test said.
775    pub(crate) fn awaited_every(&self) -> std::time::Duration {
776        #[cfg(any(test, feature = "test-support"))]
777        if let Some(every) = self.awaited_every {
778            return every;
779        }
780        std::time::Duration::from_secs(1)
781    }
782
783    #[cfg(any(test, feature = "test-support"))]
784    #[doc(hidden)]
785    pub fn with_awaited_every(mut self, every: std::time::Duration) -> Context {
786        self.awaited_every = Some(every);
787        self
788    }
789
790    // What every park's manifest names its Mac by; `unknown` where a unit test said so.
791    pub(crate) fn this_machine(&self) -> String {
792        #[cfg(test)]
793        if self.unknown_machine {
794            return crate::state::UNKNOWN_MACHINE.into();
795        }
796        crate::state::machine_id()
797    }
798
799    #[cfg(test)]
800    pub(crate) fn with_unknown_machine(mut self) -> Context {
801        self.unknown_machine = true;
802        self
803    }
804
805    /// The command line's context, from this process's own environment.
806    pub fn from_env() -> Context {
807        Context::for_command_line(&Environment::of_this_process())
808    }
809
810    /// The command line's context, from the environment it was started with: a shell's, so
811    /// each tool's program is looked for on its `PATH` when it runs.
812    pub fn for_command_line(env: &Environment) -> Context {
813        Context::read(env, "cli")
814    }
815
816    /// The command line's context for a unit test: this process's environment, less every
817    /// variable Pitboard reads, so what the person running the tests exported changes
818    /// nothing a test checks. `PITBOARD_NO_ARGV=1` would fail a keychain test, and
819    /// `PITBOARD_CLAUDE` would name the program a test runs.
820    ///
821    /// Every home is this test's sentinel ([`unit_test_sentinel`]): the person's home,
822    /// Pitboard's own directory, Claude Code's config directory and Codex's home. So a test
823    /// that forgets to make a scratch home of its own reads nothing real, and anything it
824    /// writes lands in a folder of its own. Taking the home from the environment, as this
825    /// did, gave every such test the real `~/.pitboard`, `~/.claude` and `~/.codex`.
826    #[cfg(test)]
827    pub(crate) fn for_unit_test() -> Context {
828        let sentinel = unit_test_sentinel().into_os_string();
829        let homes = [
830            "HOME",
831            "USERPROFILE",
832            "PITBOARD_HOME",
833            "CLAUDE_CONFIG_DIR",
834            "CODEX_HOME",
835        ];
836        let kept: Environment = std::env::vars_os()
837            .filter(|(name, _)| !variables().any(|read| name == read))
838            .chain(homes.map(|home| (home.into(), sentinel.clone())))
839            .collect();
840        Context::for_command_line(&kept)
841    }
842
843    /// Everything `env` says, read the same way for every front end; `caller` names the one
844    /// asking, for the audit log.
845    ///
846    /// Each tool's program is the one its own variable names, `PITBOARD_CLAUDE` or
847    /// `PITBOARD_CODEX`, or else its bare name, looked for on `PATH` when it runs. Where
848    /// to look is all a front end adds: an app has no shell's `PATH`, and finds the
849    /// programs before it runs anything.
850    pub(crate) fn read(env: &Environment, caller: &str) -> Context {
851        let home = env.home();
852        let owned = |name: &str| env.text(name).map(str::to_owned);
853        let program = |tool: ProviderId, cli: crate::provider::Cli| {
854            env.path(cli.program_variable)
855                .filter(|named| !named.is_empty())
856                .map_or_else(|| PathBuf::from(tool.program()), PathBuf::from)
857        };
858        let read = Context {
859            os: env.os(),
860            pitboard_home: env.pitboard_home(),
861            home,
862            claude_config_dir: owned("CLAUDE_CONFIG_DIR"),
863            secure_storage_dir: owned("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
864            user: owned(env.os().user_variable()),
865            custom_oauth: env.set("CLAUDE_CODE_CUSTOM_OAUTH_URL"),
866            argv_fallback: env.text("PITBOARD_NO_ARGV") != Some("1"),
867            overriding_auth: crate::settings::OVERRIDING_ENV
868                .iter()
869                .filter(|name| env.set(name))
870                .map(|name| (*name).to_string())
871                .collect(),
872            caller: caller.into(),
873            programs: programs(program),
874            api_base: owned("PITBOARD_API_BASE"),
875            proxy: crate::proxy::Proxies::read(env),
876            agent: crate::api::Agents::default(),
877            hover_rest: matches!(env.text("CLAUDE_CODE_HOVER_REST"), Some("1" | "true")),
878            credential_manager_forced: env.text("CLAUDE_CODE_FORCE_WINDOWS_CREDMAN") == Some("1"),
879            codex_home: owned("CODEX_HOME").filter(|v| !v.is_empty()),
880            profile: (env.os() == Os::Windows)
881                .then(crate::host::user::profile)
882                .flatten(),
883            local_app_data: env
884                .path("LOCALAPPDATA")
885                .filter(|_| env.os() == Os::Windows)
886                .map(PathBuf::from),
887            program_files: env
888                .named_folder("ProgramFiles")
889                .filter(|_| env.os() == Os::Windows),
890            system_root: env
891                .named_folder("SystemRoot")
892                .filter(|_| env.os() == Os::Windows),
893            sid: (env.os() == Os::Windows)
894                .then(crate::host::user::sid)
895                .flatten(),
896            one_drive: ["OneDrive", "OneDriveCommercial", "OneDriveConsumer"]
897                .into_iter()
898                .filter(|_| env.os() == Os::Windows)
899                .filter_map(|name| env.path(name).filter(|folder| !folder.is_empty()))
900                .map(PathBuf::from)
901                .collect(),
902            schedule_program: None,
903            // Unset is nowhere, as it is to the shell: nothing is found on an empty `PATH`.
904            search_path: Some(env.path("PATH").unwrap_or_default().to_os_string()),
905            installer_places: std::collections::BTreeMap::new(),
906            path_ext: env.path("PATHEXT").map(OsStr::to_os_string),
907            git_bash: env
908                .path("CLAUDE_CODE_GIT_BASH_PATH")
909                .filter(|named| env.os() == Os::Windows && !named.is_empty())
910                .map(PathBuf::from),
911            wsl_distro: env
912                .path("WSL_DISTRO_NAME")
913                .filter(|_| env.os() == Os::Linux)
914                .map(|named| named.to_string_lossy().into_owned()),
915            scheduled_job: None,
916            marked_as_scheduled: false,
917            sudo: env.set("SUDO_UID"),
918            clock: Arc::new(SystemClock),
919            locks: crate::lock::Locks::CLAUDE_CODE,
920            host: crate::host::current(),
921            api: Arc::new(Anthropic),
922            openai: Arc::new(OpenAiNetwork),
923            #[cfg(any(test, feature = "test-support"))]
924            sign_in_script: None,
925            #[cfg(any(test, feature = "test-support"))]
926            scratch_claude: None,
927            #[cfg(any(test, feature = "test-support"))]
928            awaited_every: None,
929            #[cfg(test)]
930            unknown_machine: false,
931            #[cfg(any(test, feature = "test-support"))]
932            warm_script: None,
933        };
934        #[cfg(any(test, feature = "test-support"))]
935        if let Some(folder) = env.path(TEST_CLAUDE_DESKTOP).filter(|f| !f.is_empty()) {
936            return read.with_scratch_claude(PathBuf::from(folder));
937        }
938        read
939    }
940
941    /// Answer for every service from a script, where a test can produce a 429 or a
942    /// refusal. One script for all of them, so a test that forgets to script a tool's
943    /// service gets a refusal rather than a request to the real one.
944    #[cfg(any(test, feature = "test-support"))]
945    #[doc(hidden)]
946    pub fn with_scripted_api(mut self, api: Arc<crate::api::scripted::ScriptedApi>) -> Context {
947        self.api = api.clone();
948        self.openai = api;
949        self
950    }
951
952    /// Put the credential stores in memory, where a test can make them fail. Only the
953    /// tests do this, which is why the trait behind it is not public.
954    #[cfg(any(test, feature = "test-support"))]
955    #[doc(hidden)]
956    pub fn with_memory_stores(mut self, memory: Arc<crate::host::memory::MemoryHost>) -> Context {
957        self.host = memory;
958        self
959    }
960
961    /// Read the time from somewhere else. Only the tests do this, which is why it is not
962    /// part of the builder a front end uses.
963    #[cfg(any(test, feature = "test-support"))]
964    #[doc(hidden)]
965    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Context {
966        self.clock = clock;
967        self
968    }
969
970    /// Ask for a lock of Claude Code's somebody holds `retries` times more, `every` apart,
971    /// rather than as Claude Code does. Only the tests do this: one that holds a lock lets go
972    /// of it at a moment of its own, not after a while.
973    #[cfg(any(test, feature = "test-support"))]
974    #[doc(hidden)]
975    pub fn with_lock_waiting(mut self, retries: u32, every: std::time::Duration) -> Context {
976        self.locks = crate::lock::Locks {
977            write: self.locks.write.asked(retries, every),
978            refresh: self.locks.refresh.asked(retries, every),
979        };
980        self
981    }
982
983    /// Play each tool's own sign-in with `script` rather than start its program: what the
984    /// tool says, what it reads typed back, and the login it stores where the tool would. A
985    /// test or a fixture that may start no program does this, and nothing else does, which is
986    /// why it is not part of the builder a front end uses.
987    #[cfg(any(test, feature = "test-support"))]
988    #[doc(hidden)]
989    pub fn with_sign_in_script(
990        mut self,
991        script: Arc<dyn crate::switch::enroll::SignInScript>,
992    ) -> Context {
993        self.sign_in_script = Some(script);
994        self
995    }
996
997    /// Who plays each tool's own sign-in, where somebody said.
998    #[cfg(any(test, feature = "test-support"))]
999    pub(crate) fn sign_in_script(&self) -> Option<&Arc<dyn crate::switch::enroll::SignInScript>> {
1000        self.sign_in_script.as_ref()
1001    }
1002
1003    /// Play each warm-up's run with `script` rather than start the tool's program. A test or a
1004    /// fixture that may start no program does this, and nothing else does.
1005    #[cfg(any(test, feature = "test-support"))]
1006    #[doc(hidden)]
1007    pub fn with_warm_script(
1008        mut self,
1009        script: Arc<dyn crate::duty::warm::run::WarmScript>,
1010    ) -> Context {
1011        self.warm_script = Some(script);
1012        self
1013    }
1014
1015    /// Who plays a warm-up's run, where somebody said.
1016    #[cfg(any(test, feature = "test-support"))]
1017    pub(crate) fn warm_script(&self) -> Option<&Arc<dyn crate::duty::warm::run::WarmScript>> {
1018        self.warm_script.as_ref()
1019    }
1020
1021    /// Whether this context reaches no machine but `memory` and no service but `api`. A
1022    /// fixture asks it of the context it made before it puts a login anywhere, so that an
1023    /// edit leaving either out of the builder fails there, rather than writing Claude Code's
1024    /// login to this machine's keychain under the real one's service name, or asking
1025    /// Anthropic.
1026    #[cfg(any(test, feature = "test-support"))]
1027    #[doc(hidden)]
1028    pub fn reaches_only(
1029        &self,
1030        memory: &Arc<crate::host::memory::MemoryHost>,
1031        api: &Arc<crate::api::scripted::ScriptedApi>,
1032    ) -> bool {
1033        std::ptr::addr_eq(Arc::as_ptr(&self.host), Arc::as_ptr(memory))
1034            && std::ptr::addr_eq(Arc::as_ptr(&self.api), Arc::as_ptr(api))
1035            && std::ptr::addr_eq(Arc::as_ptr(&self.openai), Arc::as_ptr(api))
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use std::path::Path;
1043
1044    #[test]
1045    fn the_test_variable_is_listed_only_in_a_build_for_tests() {
1046        assert!(variables().any(|name| name == "PITBOARD_TEST_CLAUDE_DESKTOP"));
1047        assert!(!READ.contains(&"PITBOARD_TEST_CLAUDE_DESKTOP"));
1048        assert!(Context::for_unit_test().scratch_claude().is_none());
1049        let folder = std::env::temp_dir().join("pitboard-scratch-claude");
1050        let named = folder.to_string_lossy().into_owned();
1051        let env: Environment = [
1052            ("HOME", "/home/x"),
1053            ("PITBOARD_TEST_CLAUDE_DESKTOP", named.as_str()),
1054        ]
1055        .into_iter()
1056        .collect();
1057        let read = Context::for_command_line(&env);
1058        let scratch = read.scratch_claude().expect("named");
1059        assert_eq!(scratch.data, folder.join("data"));
1060        assert_eq!(scratch.app, folder.join("Claude.app"));
1061    }
1062
1063    // It panics before anything there is listed or opened.
1064    #[test]
1065    #[cfg_attr(
1066        windows,
1067        ignore = "Claude Desktop is macOS only: Windows names no Application Support"
1068    )]
1069    fn a_scratch_inside_the_real_application_support_panics() {
1070        let home = {
1071            let _reaching = crate::host::user::testing::reaching_the_real_home();
1072            crate::host::user::home().expect("this account has a home")
1073        };
1074        let support = home.join("Library/Application Support");
1075        let named = |folder: PathBuf| {
1076            std::panic::catch_unwind(move || {
1077                Context::new("/nowhere".into()).with_scratch_claude(folder)
1078            })
1079            .is_err()
1080        };
1081        assert!(named(support.join("pitboard-test-never-made")));
1082        assert!(named(support.clone()));
1083        assert!(named(home.join("Library")), "it holds it");
1084        // The home is linked, since Linux has no `Library` for a link to resolve through.
1085        let link = std::env::temp_dir().join(format!("pitboard-home-link-{}", std::process::id()));
1086        let _ = std::fs::remove_file(&link);
1087        crate::host::fs::testing::link_dir(&home, &link).expect("a link");
1088        let through = named(link.join("Library/Application Support/pitboard-test-never-made"));
1089        let _ = std::fs::remove_file(&link);
1090        assert!(through, "through a link");
1091        assert!(!named(
1092            std::env::temp_dir().join("pitboard-scratch-elsewhere")
1093        ));
1094        let _guarding = testing::also_guarding(&std::env::temp_dir().join("pitboard-fake-home"));
1095        assert!(
1096            named(support.join("pitboard-test-never-made")),
1097            "while a test guards a fake home too"
1098        );
1099    }
1100
1101    #[test]
1102    #[cfg(windows)]
1103    fn a_scratch_in_claude_desktops_windows_folders_panics() {
1104        let named = |folder: PathBuf| {
1105            std::panic::catch_unwind(move || {
1106                Context::new(PathBuf::from(r"C:\nowhere")).with_scratch_claude(folder)
1107            })
1108            .is_err()
1109        };
1110        let (roaming, local) = crate::host::user::app_data_folders(true);
1111        assert!(!roaming.is_empty() && !local.is_empty());
1112        for dir in &roaming {
1113            assert!(named(dir.join("Claude").join("pitboard-test-never-made")));
1114            assert!(named(dir.join("claude-3p")), "in any case");
1115        }
1116        for dir in &local {
1117            assert!(named(dir.join("AnthropicClaude")));
1118        }
1119        assert!(!named(
1120            std::env::temp_dir().join("pitboard-scratch-elsewhere")
1121        ));
1122        let fake = std::env::temp_dir().join("pitboard-fake-profile");
1123        let _guarding = testing::also_guarding(&fake);
1124        let roaming = fake.join("AppData").join("Roaming");
1125        assert!(named(roaming.join("Claude").join("x")), "a fake profile's");
1126    }
1127
1128    // `..` past a folder not made yet is resolved only once something makes that folder.
1129    #[test]
1130    fn a_scratch_named_through_a_folder_not_made_yet_panics() {
1131        let home =
1132            std::env::temp_dir().join(format!("pitboard-scratch-climb-{}", std::process::id()));
1133        let _guarding = testing::also_guarding(&home);
1134        let climbed = std::panic::catch_unwind(|| {
1135            Context::new("/nowhere".into())
1136                .with_scratch_claude(home.join("never-made/../Library/Application Support/x"))
1137        });
1138        assert!(climbed.is_err());
1139    }
1140
1141    // As a helper given the name would write it: from the working directory.
1142    #[test]
1143    fn a_relative_name_lies_where_the_working_directory_puts_it() {
1144        let here = std::env::current_dir().expect("a working directory");
1145        let name = Path::new("pitboard-never-made");
1146        assert!(crate::host::lies_in(name, &here));
1147        assert!(!crate::host::lies_in(name, &here.join("elsewhere")));
1148    }
1149
1150    // A relative name lands wherever the test runs.
1151    #[test]
1152    fn a_relative_scratch_panics() {
1153        let named = std::panic::catch_unwind(|| {
1154            Context::new("/nowhere".into()).with_scratch_claude("pitboard-scratch".into())
1155        });
1156        assert!(named.is_err());
1157    }
1158
1159    #[test]
1160    #[cfg_attr(
1161        windows,
1162        ignore = "Claude Desktop is macOS only: Windows makes no link"
1163    )]
1164    fn a_scratch_whose_data_or_app_links_to_a_real_place_panics() {
1165        let root =
1166            std::env::temp_dir().join(format!("pitboard-scratch-links-{}", std::process::id()));
1167        let _ = std::fs::remove_dir_all(&root);
1168        let home = root.join("home");
1169        let support = home.join("Library/Application Support/Claude");
1170        let app = home.join("Applications/Claude.app");
1171        let other = root.join("other");
1172        for made in [&support, &app, &other] {
1173            std::fs::create_dir_all(made).expect("a folder to link to");
1174        }
1175        let _guarding = testing::also_guarding(&home);
1176        let linked = |scratch: &str, name: &str, to: &Path| {
1177            let scratch = root.join(scratch);
1178            std::fs::create_dir_all(&scratch).expect("a scratch");
1179            crate::host::fs::testing::link_dir(to, &scratch.join(name)).expect("a link");
1180            std::panic::catch_unwind(|| {
1181                Context::new("/nowhere".into()).with_scratch_claude(scratch)
1182            })
1183            .is_err()
1184        };
1185        let data = linked("one", "data", &support);
1186        let claude = linked("two", "Claude.app", &app);
1187        let elsewhere = linked("three", "data", &other);
1188        let not_made = linked("four", "data", &support.join("never-made"));
1189        let not_made_relative = linked(
1190            "five",
1191            "data",
1192            Path::new("../home/Library/Application Support/Claude/never-made"),
1193        );
1194        std::fs::remove_dir_all(&root).expect("the scratch removed");
1195        assert!(data, "data linked into Application Support");
1196        assert!(claude, "Claude.app linked to ~/Applications/Claude.app");
1197        assert!(!elsewhere, "data linked to a folder of the test's own");
1198        assert!(not_made, "data linked to a folder not made yet there");
1199        assert!(not_made_relative, "the same, by a relative link");
1200    }
1201
1202    #[test]
1203    #[cfg_attr(
1204        windows,
1205        ignore = "Claude Desktop is macOS only: Windows has no `/Applications`"
1206    )]
1207    fn a_scratch_of_an_applications_folder_panics() {
1208        let home =
1209            std::env::temp_dir().join(format!("pitboard-scratch-apps-{}", std::process::id()));
1210        let _guarding = testing::also_guarding(&home);
1211        let named = |folder: PathBuf| {
1212            std::panic::catch_unwind(move || {
1213                Context::new("/nowhere".into()).with_scratch_claude(folder)
1214            })
1215            .is_err()
1216        };
1217        assert!(named("/Applications".into()));
1218        assert!(named(home.join("Applications")));
1219        assert!(!named(home.join("Applications/elsewhere")));
1220    }
1221
1222    // /System/Volumes/Data/Users is /Users, and `canonicalize` keeps either name as it is.
1223    #[test]
1224    #[cfg_attr(
1225        not(target_os = "macos"),
1226        ignore = "a firmlink to the Data volume is macOS's own"
1227    )]
1228    fn a_scratch_named_through_the_data_volume_panics() {
1229        let root =
1230            std::env::temp_dir().join(format!("pitboard-scratch-data-{}", std::process::id()));
1231        let _ = std::fs::remove_dir_all(&root);
1232        let support = root.join("home/Library/Application Support/Claude");
1233        let scratch = root.join("scratch");
1234        for made in [&support, &scratch] {
1235            std::fs::create_dir_all(made).expect("a folder of the test's own");
1236        }
1237        let through = |place: &Path| {
1238            let real = std::fs::canonicalize(place).expect("a folder of the test's own");
1239            Path::new("/System/Volumes/Data").join(real.strip_prefix("/").expect("from the root"))
1240        };
1241        crate::host::fs::testing::link_dir(&through(&support), &scratch.join("data"))
1242            .expect("a link");
1243        let _guarding = testing::also_guarding(&root.join("home"));
1244        let named = |folder: PathBuf| {
1245            std::panic::catch_unwind(move || {
1246                Context::new("/nowhere".into()).with_scratch_claude(folder)
1247            })
1248            .is_err()
1249        };
1250        let linked = named(scratch);
1251        let inside = named(through(&support).join("pitboard-test"));
1252        let own = named(through(&root).join("own"));
1253        let applications = named("/System/Volumes/Data/Applications".into());
1254        std::fs::remove_dir_all(&root).expect("the scratch removed");
1255        assert!(
1256            linked,
1257            "data linked into Application Support through the Data volume"
1258        );
1259        assert!(
1260            inside,
1261            "a scratch in Application Support named through the Data volume"
1262        );
1263        assert!(
1264            !own,
1265            "a folder of the test's own named through the Data volume"
1266        );
1267        assert!(applications, "/Applications named through the Data volume");
1268    }
1269
1270    /// An empty `CLAUDE_CONFIG_DIR` is kept, by the builder as by the environment, since
1271    /// Claude Code takes it as the empty path for its config dir; the context is then
1272    /// refused as one whose home is not a full path. It was read as unset, which pointed
1273    /// Pitboard at `~/.claude`, where no Claude Code started with it keeps anything.
1274    #[test]
1275    fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
1276        let (home, named) = full_home();
1277        let ctx = Context::new(home.clone())
1278            .with_claude_config_dir(String::new())
1279            .with_secure_storage_dir(String::new());
1280        assert_eq!(
1281            ctx.pitboard_home,
1282            crate::host::OS.default_pitboard_home(&home)
1283        );
1284        assert_eq!(
1285            ctx.claude_config_dir.as_deref(),
1286            Some(""),
1287            "empty is the empty path for the config dir"
1288        );
1289        assert_eq!(
1290            ctx.secure_storage_dir.as_deref(),
1291            Some(""),
1292            "empty is set, and pins the default slot"
1293        );
1294        assert_eq!(ctx.claude_program(), Path::new("claude"));
1295        let env: Environment = [
1296            (crate::host::OS.home_variable(), named.as_str()),
1297            ("CLAUDE_CONFIG_DIR", ""),
1298        ]
1299        .into_iter()
1300        .collect();
1301        let read = Context::for_command_line(&env);
1302        assert_eq!(read.claude_config_dir, ctx.claude_config_dir);
1303        for ctx in [ctx, read] {
1304            assert!(
1305                matches!(
1306                    crate::home::check_absolute(&ctx),
1307                    Err(crate::error::Error::HomeNotAbsolute {
1308                        variable: "CLAUDE_CONFIG_DIR",
1309                        ..
1310                    })
1311                ),
1312                "refused"
1313            );
1314        }
1315    }
1316
1317    /// What a fixture asks of the context it made: whether it reaches nothing but the machine
1318    /// in memory and the scripted services it was given, which leaving out either, or giving
1319    /// another, makes false.
1320    #[test]
1321    fn a_context_says_whether_it_reaches_only_the_machine_and_services_it_was_given() {
1322        use crate::api::scripted::ScriptedApi;
1323        use crate::host::memory::MemoryHost;
1324        let (memory, api) = (MemoryHost::new(), ScriptedApi::new());
1325        let home = PathBuf::from("/home/x");
1326        let both = Context::new(home.clone())
1327            .with_memory_stores(Arc::clone(&memory))
1328            .with_scripted_api(Arc::clone(&api));
1329        assert!(both.reaches_only(&memory, &api));
1330        assert!(
1331            !Context::new(home.clone())
1332                .with_scripted_api(Arc::clone(&api))
1333                .reaches_only(&memory, &api),
1334            "this machine"
1335        );
1336        assert!(
1337            !Context::new(home)
1338                .with_memory_stores(Arc::clone(&memory))
1339                .reaches_only(&memory, &api),
1340            "the network"
1341        );
1342        assert!(
1343            !both.reaches_only(&MemoryHost::new(), &api),
1344            "another machine"
1345        );
1346        assert!(
1347            !both.reaches_only(&memory, &ScriptedApi::new()),
1348            "other services"
1349        );
1350    }
1351
1352    #[test]
1353    fn only_a_front_end_that_names_one_changes_what_the_schedule_runs() {
1354        assert_eq!(Context::for_unit_test().schedule_program(), None);
1355        let ctx = Context::new(PathBuf::from("/Users/x"));
1356        assert_eq!(ctx.schedule_program(), None);
1357        let bundled = PathBuf::from("/Applications/Pitboard.app/Contents/Helpers/pitboard");
1358        assert_eq!(
1359            ctx.with_schedule_program(bundled.clone())
1360                .schedule_program(),
1361            Some(bundled.as_path())
1362        );
1363    }
1364
1365    /// The command line reads the environment it is handed rather than this process's, so
1366    /// every variable can be tried without changing what the tests themselves run in.
1367    #[test]
1368    fn the_command_line_reads_the_environment_it_is_given() {
1369        let (home, named) = full_home();
1370        let env: Environment = [
1371            (crate::host::OS.home_variable(), named.as_str()),
1372            ("PITBOARD_CLAUDE", "/elsewhere/claude"),
1373            ("PITBOARD_CODEX", ""),
1374            ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example"),
1375            ("ANTHROPIC_API_KEY", "not-a-key"),
1376            ("ANTHROPIC_AUTH_TOKEN", ""),
1377            ("PATH", "/opt/tools/bin:/usr/bin"),
1378        ]
1379        .into_iter()
1380        .collect();
1381        let ctx = Context::for_command_line(&env);
1382        assert_eq!(ctx.claude_program(), Path::new("/elsewhere/claude"));
1383        assert_eq!(
1384            ctx.codex_program(),
1385            Path::new("codex"),
1386            "empty names nothing"
1387        );
1388        assert!(ctx.custom_oauth());
1389        assert_eq!(
1390            ctx.overriding_auth(),
1391            ["ANTHROPIC_API_KEY"],
1392            "empty is unset"
1393        );
1394        assert_eq!(ctx.search_path(), "/opt/tools/bin:/usr/bin");
1395        assert_eq!(
1396            ctx.pitboard_home,
1397            crate::host::OS.default_pitboard_home(&home)
1398        );
1399        assert_eq!(ctx.caller, "cli");
1400        let no_path: Environment = [(crate::host::OS.home_variable(), named.as_str())]
1401            .into_iter()
1402            .collect();
1403        assert_eq!(
1404            Context::for_command_line(&no_path).search_path(),
1405            "",
1406            "no PATH is nowhere, not this process's"
1407        );
1408    }
1409
1410    // RF-B4, run 38418961905: a program gets `Path` alone, and a block holding both spellings
1411    // hands both to a child, whose `var_os` takes the first for either.
1412    #[test]
1413    fn a_windows_environment_has_one_value_per_name_in_any_case_and_the_first_wins() {
1414        let search_path = |os: Os, pairs: &[(&str, &str)]| {
1415            let home = [("HOME", "/home/x"), ("USERPROFILE", "/home/x")];
1416            let env = Environment::on(os, home.iter().chain(pairs).copied());
1417            Context::read(&env, "cli").search_path()
1418        };
1419        let windows = |pairs: &[(&str, &str)]| search_path(Os::Windows, pairs);
1420        assert_eq!(
1421            windows(&[("Path", "C:\\a")]),
1422            "C:\\a",
1423            "as a program gets it"
1424        );
1425        assert_eq!(windows(&[("PATH", "C:\\b")]), "C:\\b");
1426        assert_eq!(windows(&[("Path", "C:\\a"), ("PATH", "C:\\b")]), "C:\\a");
1427        assert_eq!(windows(&[("PATH", "C:\\b"), ("Path", "C:\\a")]), "C:\\b");
1428        for os in [Os::MacOs, Os::Linux] {
1429            assert_eq!(search_path(os, &[("Path", "/a")]), "", "{os:?}");
1430            assert_eq!(search_path(os, &[("Path", "/a"), ("PATH", "/b")]), "/b");
1431        }
1432        let both = Environment::on(Os::Windows, [("Path", "C:\\a"), ("PATH", "C:\\b")]);
1433        let given: Vec<_> = both.iter().collect();
1434        assert_eq!(
1435            given,
1436            [(OsStr::new("Path"), OsStr::new("C:\\a"))],
1437            "a child gets one"
1438        );
1439        assert_eq!(both.text("PATH"), Some("C:\\a"));
1440    }
1441
1442    // Windows names the person in `USERNAME`, and sets no `USER`.
1443    #[test]
1444    fn the_person_is_named_by_the_systems_own_variable() {
1445        let user = |os: Os| {
1446            let pairs = [
1447                ("HOME", "/home/x"),
1448                ("USERPROFILE", "/home/x"),
1449                ("USER", "posix"),
1450                ("username", "windows"),
1451            ];
1452            Context::read(&Environment::on(os, pairs), "cli").user
1453        };
1454        assert_eq!(user(Os::Windows).as_deref(), Some("windows"));
1455        assert_eq!(user(Os::MacOs).as_deref(), Some("posix"));
1456        assert_eq!(user(Os::Linux).as_deref(), Some("posix"));
1457    }
1458
1459    // Git Bash sets `HOME`, which neither tool takes for the home on Windows.
1460    #[test]
1461    fn each_tool_finds_its_home_in_userprofile_on_windows_whatever_home_says() {
1462        let profile = std::env::temp_dir().join("pitboard-profile-tools");
1463        let named = profile.to_string_lossy().into_owned();
1464        let pairs = [("HOME", "/c/Users/x"), ("USERPROFILE", named.as_str())];
1465        let ctx = Context::read(&Environment::on(Os::Windows, pairs), "cli");
1466        assert_eq!(ctx.home, profile);
1467        assert_eq!(
1468            crate::provider::claude::paths::config_dir(&ctx),
1469            profile.join(".claude")
1470        );
1471        assert_eq!(
1472            crate::provider::claude::paths::config_file(&ctx),
1473            profile.join(".claude.json")
1474        );
1475        assert_eq!(
1476            crate::provider::codex::paths::home(&ctx),
1477            profile.join(".codex")
1478        );
1479        assert_eq!(
1480            ctx.pitboard_home,
1481            profile.join("AppData").join("Local").join("Pitboard")
1482        );
1483        let posix = Context::read(&Environment::on(Os::Linux, pairs), "cli");
1484        assert_eq!(posix.home, PathBuf::from("/c/Users/x"));
1485    }
1486
1487    // A full path on every system, which nothing makes.
1488    fn full_home() -> (PathBuf, String) {
1489        let home = std::env::temp_dir().join("pitboard-never-made-home");
1490        let named = home.to_string_lossy().into_owned();
1491        (home, named)
1492    }
1493
1494    /// A variable read without being listed is caught the first time a test reads it, so the
1495    /// list the tests withhold from every command they run cannot fall behind what is read.
1496    #[test]
1497    #[cfg(debug_assertions)]
1498    #[should_panic(expected = "PITBOARD_SOMETHING_NEW is read but not listed")]
1499    fn a_variable_read_without_being_listed_is_caught() {
1500        let _ = Environment::default().path("PITBOARD_SOMETHING_NEW");
1501    }
1502
1503    /// With no `HOME`, the home is the account's own, as the passwd database names it, which
1504    /// is where the app has always looked. The command line took an empty path instead, and
1505    /// so kept its files in `.pitboard` wherever it was run from.
1506    #[test]
1507    fn without_home_the_home_is_the_accounts_own() {
1508        let _real = crate::host::user::testing::reaching_the_real_home();
1509        let own = crate::host::user::home().expect("this account has a home");
1510        let ctx = Context::for_command_line(&Environment::default());
1511        assert_eq!(ctx.home, own);
1512        assert_eq!(
1513            ctx.pitboard_home,
1514            crate::host::OS.default_pitboard_home(&own)
1515        );
1516        let empty: Environment = [(crate::host::OS.home_variable(), "")]
1517            .into_iter()
1518            .collect();
1519        assert_eq!(
1520            Context::for_command_line(&empty).home,
1521            PathBuf::new(),
1522            "set, even empty, it is what the person said"
1523        );
1524    }
1525
1526    /// A unit test's context names one folder for every home, its own, under the temporary
1527    /// directory, and makes nothing there, so a test that forgets to give a scratch home of
1528    /// its own reads none of the real ones and writes only there. It took the person's real
1529    /// home from the passwd database, and every home under it.
1530    #[test]
1531    fn a_unit_tests_homes_are_a_folder_of_its_own_that_nothing_makes() {
1532        let ctx = Context::for_unit_test();
1533        let sentinel = ctx.home.clone();
1534        assert!(
1535            sentinel.starts_with(std::env::temp_dir()),
1536            "{}",
1537            sentinel.display()
1538        );
1539        assert!(!sentinel.exists(), "{}", sentinel.display());
1540        assert_eq!(ctx.pitboard_home, sentinel);
1541        assert_eq!(
1542            crate::provider::claude::paths::config_dir(&ctx),
1543            sentinel,
1544            "Claude Code's"
1545        );
1546        assert_eq!(
1547            crate::provider::codex::paths::home(&ctx),
1548            sentinel,
1549            "Codex's"
1550        );
1551        assert_eq!(Context::for_unit_test().home, sentinel, "the same test's");
1552        let another = std::thread::Builder::new()
1553            .name("another::test".into())
1554            .spawn(|| Context::for_unit_test().home)
1555            .expect("a thread")
1556            .join()
1557            .expect("its context");
1558        assert_ne!(another, sentinel, "another test's");
1559        assert!(another.ends_with("another--test"), "{}", another.display());
1560    }
1561
1562    /// The home and Pitboard directory an app asks of the environment it was started with
1563    /// are the ones the context it is given reads, path for path, so an app that keys records
1564    /// of its own by Pitboard's directory keys them by the core's.
1565    #[test]
1566    fn the_homes_an_app_asks_for_are_the_contexts() {
1567        let _real = crate::host::user::testing::reaching_the_real_home();
1568        let cases: [&[(&str, &str)]; 5] = [
1569            &[],
1570            &[("HOME", "/Users/x")],
1571            &[("HOME", "/Users/x/")],
1572            &[
1573                ("HOME", "/Users/x"),
1574                ("PITBOARD_HOME", "/elsewhere/./pitboard/"),
1575            ],
1576            &[("PITBOARD_HOME", "")],
1577        ];
1578        for pairs in cases {
1579            let env: Environment = pairs.iter().copied().collect();
1580            let ctx = Context::for_command_line(&env);
1581            assert_eq!(env.home(), ctx.home, "{pairs:?}");
1582            assert_eq!(env.pitboard_home(), ctx.pitboard_home, "{pairs:?}");
1583        }
1584        let given: Environment = [("PITBOARD_HOME", "/elsewhere/./pitboard/")]
1585            .into_iter()
1586            .collect();
1587        assert_eq!(
1588            given.pitboard_home().as_os_str(),
1589            "/elsewhere/./pitboard/",
1590            "as the environment gives it"
1591        );
1592    }
1593}