1#![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
23const 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#[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
64pub(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#[derive(Clone, Debug, Default, PartialEq, Eq)]
80pub struct Environment(HashMap<OsString, OsString>);
81
82impl Environment {
83 pub fn of_this_process() -> Environment {
85 std::env::vars_os().collect()
86 }
87
88 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 pub(crate) fn text(&self, name: &str) -> Option<&str> {
101 self.path(name).and_then(OsStr::to_str)
102 }
103
104 pub(crate) fn set(&self, name: &str) -> bool {
106 self.text(name).is_some_and(|v| !v.is_empty())
107 }
108
109 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 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 #[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#[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#[derive(Debug, Clone)]
176pub struct ScratchClaude {
177 pub data: PathBuf,
178 pub app: PathBuf,
179}
180
181#[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 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 pub(crate) claude_config_dir: Option<String>,
253 pub(crate) secure_storage_dir: Option<String>,
256 pub(crate) user: Option<String>,
258 pub(crate) custom_oauth: bool,
261 pub(crate) overriding_auth: Vec<String>,
265 pub(crate) argv_fallback: bool,
270 pub(crate) caller: String,
273 pub(crate) programs: std::collections::BTreeMap<ProviderId, PathBuf>,
275 pub(crate) api_base: Option<String>,
277 pub(crate) proxy: crate::proxy::Proxies,
280 pub(crate) agent: crate::api::Agents,
283 pub(crate) hover_rest: bool,
285 pub(crate) codex_home: Option<String>,
287 pub(crate) schedule_program: Option<PathBuf>,
291 pub(crate) search_path: Option<OsString>,
296 pub(crate) scheduled_job: Option<String>,
299 pub(crate) marked_as_scheduled: bool,
303 pub(crate) sudo: bool,
307 pub(crate) clock: Arc<dyn Clock>,
310 pub(crate) locks: crate::lock::Locks,
313 pub(crate) host: Arc<dyn Host>,
316 pub(crate) api: Arc<dyn Api>,
318 pub(crate) openai: Arc<dyn OpenAi>,
320 #[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 #[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 pub(crate) fn now(&self) -> i64 {
350 self.clock.now()
351 }
352
353 pub(crate) fn now_millis(&self) -> i64 {
355 self.clock.now_millis()
356 }
357
358 pub(crate) fn zone(&self) -> jiff::tz::TimeZone {
360 self.clock.zone()
361 }
362
363 pub(crate) fn home(&self) -> &std::path::Path {
365 &self.home
366 }
367
368 pub(crate) fn host(&self) -> &dyn Host {
370 self.host.as_ref()
371 }
372
373 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 #[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 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 #[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 #[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 #[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 pub(crate) fn custom_oauth(&self) -> bool {
472 self.custom_oauth
473 }
474
475 pub fn claude_program(&self) -> &std::path::Path {
477 &self.programs[&ProviderId::Claude]
478 }
479
480 pub(crate) fn argv_fallback(&self) -> bool {
482 self.argv_fallback
483 }
484
485 pub(crate) fn overriding_auth(&self) -> &[String] {
487 &self.overriding_auth
488 }
489
490 #[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 #[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 #[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 pub fn codex_program(&self) -> &std::path::Path {
523 &self.programs[&ProviderId::Codex]
524 }
525
526 #[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 pub fn schedule_program(&self) -> Option<&std::path::Path> {
537 self.schedule_program.as_deref()
538 }
539
540 #[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 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 pub(crate) fn scheduled_job(&self) -> Option<&str> {
562 self.scheduled_job.as_deref()
563 }
564
565 pub(crate) fn sudo(&self) -> bool {
567 self.sudo
568 }
569
570 #[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 pub fn started_by_the_schedule(mut self) -> Context {
583 self.marked_as_scheduled = true;
584 self
585 }
586
587 pub(crate) fn marked_as_scheduled(&self) -> bool {
589 self.marked_as_scheduled
590 }
591
592 pub fn program_for(&self, tool: ProviderId) -> Option<&std::path::Path> {
594 self.programs.get(&tool).map(PathBuf::as_path)
595 }
596
597 pub(crate) fn set_program(&mut self, tool: ProviderId, program: PathBuf) {
599 self.programs.insert(tool, program);
600 }
601
602 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 #[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 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 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 pub fn from_env() -> Context {
667 Context::for_command_line(&Environment::of_this_process())
668 }
669
670 pub fn for_command_line(env: &Environment) -> Context {
673 Context::read(env, "cli")
674 }
675
676 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}