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