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