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
//! `tm doctor` row: can the daemon's resolver actually reach its credentials?
//!
//! Why (#8236 item 8): moving a credential out of a plaintext plist and into
//! the Keychain trades one failure mode for another. A launchd-spawned binary
//! rebuilt by `cargo install` has a NEW cdhash, so its first Keychain read
//! raises a SecurityAgent approval dialog; a measured run took 12 s with a
//! human clicking and timed out when nobody did. With no diagnostic for that,
//! the only symptom is "the overseer stopped working after an update" with
//! nothing in `tm doctor` to explain it. This row explains it.
//!
//! What: [`check_credential_reach`] asks
//! [`trusty_common::credentials::resolve_env_var_bounded`] for each credential
//! the daemon consumes and reports PRESENT / ABSENT / the store's error KIND /
//! TIMED OUT. It never prints a value, and it is bounded by the same constant
//! the daemon uses, so running `tm doctor` can never hang on a dialog either.
//!
//! Test: `doctor_credential_reach_tests.rs`.
use trusty_common::credential_registry::env_var_for;
use trusty_common::credentials::SecretResolveError;
use crate::core::doctor::{CheckStatus, DoctorCheck};
/// The `tm doctor` row name.
const CHECK_NAME: &str = "credential_reach";
/// Provider keys the daemon itself resolves at runtime.
///
/// Why: not the whole registry — a row that reported every registered provider
/// absent would be noise on every host. These are the four the daemon's own
/// code paths read: the LLM overseer and session-manager inference
/// (`openrouter`, `anthropic`) and the chat channels (`telegram`, `slack`).
/// Test: `row_reports_one_line_per_daemon_credential`.
const DAEMON_PROVIDERS: &[&str] = &["openrouter", "anthropic", "telegram", "slack"];
/// One credential's reachability, as a phrase for the row.
///
/// Why: separated from the row so every arm is testable without a store.
/// What: the variable name plus a verdict word. Never a value.
/// Test: `a_timeout_is_reported_as_a_waiting_dialog`,
/// `an_absent_credential_is_not_an_error`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachVerdict {
/// The canonical environment-variable name.
pub var: String,
/// What the resolver answered. Never a value.
pub verdict: String,
/// True when the daemon would have the credential disabled.
pub degraded: bool,
/// True when a human approval is the likely remedy.
pub needs_approval: bool,
}
/// Describe one resolver outcome without disclosing it.
///
/// Why (#8236 item 8d): after every `cargo install` the EXPECTED state is a
/// timeout, because the rebuilt binary's first Keychain read waits on a
/// dialog. An operator who cannot tell that apart from "absent" restores the
/// plaintext plist entry, which is the defect coming back.
/// What: one phrase per [`SecretResolveError`] arm, plus the success arm.
/// Test: `a_timeout_is_reported_as_a_waiting_dialog`,
/// `an_absent_credential_is_not_an_error`,
/// `a_store_error_names_its_kind`,
/// `a_present_credential_is_never_printed`,
/// `an_unregistered_name_is_reported_as_unresolvable`.
#[must_use]
pub fn verdict_for(var: &str, outcome: &Result<String, SecretResolveError>) -> ReachVerdict {
let (verdict, degraded, needs_approval) = match outcome {
Ok(_) => ("present".to_string(), false, false),
Err(SecretResolveError::Absent { .. }) => (
"absent (nothing in the process environment, `.env.local`, or the credential store)"
.to_string(),
true,
false,
),
Err(SecretResolveError::Unregistered { .. }) => (
"not a registered credential name — it cannot be resolved at all".to_string(),
true,
false,
),
Err(SecretResolveError::Timeout { waited_ms, .. }) => (
format!(
"timed out after {waited_ms} ms waiting for the credential store — a macOS \
Keychain approval dialog may be waiting on screen. This is the expected state \
after a `cargo install` rebuild: approve it once (or re-run `tm doctor` from a \
terminal and approve), and every later read is immediate"
),
true,
true,
),
Err(SecretResolveError::Store { kind, .. }) => (
format!("the credential store could not supply it ({kind})"),
true,
matches!(kind, trusty_common::credentials::StoreErrorKind::Keyring),
),
// `SecretResolveError` is `#[non_exhaustive]`. A variant added in
// trusty-common later has to land here DEGRADED — a row that read a new
// failure as reachable would be the fail-open this check exists to
// close. Its `kind()` is a value-free label, so this cannot leak one.
Err(other) => (format!("unreachable ({})", other.kind()), true, false),
};
ReachVerdict {
var: var.to_string(),
verdict,
degraded,
needs_approval,
}
}
/// The `credential_reach` doctor row.
///
/// Why/What: see the module docs.
/// `Warn` when a credential the daemon uses is unreachable, `Unknown` when one
/// timed out (the answer is genuinely not known — the read may still land),
/// `Ok` when every one resolves. Never `Fail`: a host that deliberately runs
/// without Telegram is not broken.
/// Test: `row_reports_one_line_per_daemon_credential`,
/// `row_is_unknown_when_a_credential_times_out`.
pub(crate) fn check_credential_reach() -> DoctorCheck {
// Runs on the blocking pool — see `check_credential_reach_async`, the only
// caller on an async path. Nothing here may be awaited.
// #9121: the gated resolver, so the latch holds even if this row's own
// sandbox check regressed.
check_credential_reach_with(
crate::secret_source::sandboxed(),
crate::secret_source::resolve_bounded_gated,
)
}
/// [`check_credential_reach`] with the sandbox state and the resolver injected.
///
/// Why (#9121): this probe walks `.env.local` and the Keychain, so a sandboxed
/// daemon answering `GET /api/v1/doctor` would read the operator's store.
/// What: `sandbox` → an `Ok` row saying no tier was consulted; `resolve` is
/// never called. Otherwise one verdict per [`DAEMON_PROVIDERS`] entry.
/// Test: `a_sandboxed_row_consults_no_credential_tier`,
/// `an_unsandboxed_row_consults_every_daemon_credential`.
pub(crate) fn check_credential_reach_with(
sandbox: bool,
resolve: impl Fn(&str) -> Result<String, SecretResolveError>,
) -> DoctorCheck {
if sandbox {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
"sandbox mode (#9121): no credential tier is consulted",
);
}
let verdicts: Vec<ReachVerdict> = DAEMON_PROVIDERS
.iter()
.map(|provider| {
let var = env_var_for(provider).unwrap_or(provider);
verdict_for(var, &resolve(var))
})
.collect();
build_row(&verdicts)
}
/// [`check_credential_reach`], off the async runtime.
///
/// Why (#8236): the row is reached by `GET /api/v1/doctor`, and each of the
/// four providers can park on a `Condvar::wait_timeout` for
/// `STORE_READ_TIMEOUT` — up to ~12 s of a tokio worker thread held by a
/// SecurityAgent dialog, which is the failure this row exists to diagnose.
/// What: `spawn_blocking`, with a join failure (the probe panicked, or the
/// runtime is shutting down) reported as `Unknown` — a row that did not run has
/// not shown the daemon healthy.
/// Test: `the_row_is_produced_off_the_runtime_thread`,
/// `a_probe_that_panics_is_unknown_not_a_lost_row`.
pub(crate) async fn check_credential_reach_async() -> DoctorCheck {
off_runtime(check_credential_reach).await
}
/// Run a blocking doctor probe on the blocking pool.
///
/// Why: separated from the probe so a test can pass one that REPORTS the thread
/// it ran on, which is the only way to prove the move actually happened.
/// Test: `the_row_is_produced_off_the_runtime_thread`,
/// `a_probe_that_panics_is_unknown_not_a_lost_row`.
async fn off_runtime<F>(probe: F) -> DoctorCheck
where
F: FnOnce() -> DoctorCheck + Send + 'static,
{
match tokio::task::spawn_blocking(probe).await {
Ok(check) => check,
Err(e) => DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
format!(
"the credential-reach probe did not run ({e}) — whether the daemon can \
reach its credentials is UNKNOWN"
),
),
}
}
/// The pure verdict behind [`check_credential_reach`].
///
/// Test: `row_reports_one_line_per_daemon_credential`,
/// `row_is_unknown_when_a_credential_times_out`,
/// `row_is_ok_when_every_credential_resolves`.
pub(crate) fn build_row(verdicts: &[ReachVerdict]) -> DoctorCheck {
let detail = verdicts
.iter()
.map(|v| format!("{}: {}", v.var, v.verdict))
.collect::<Vec<_>>()
.join("; ");
let status = if verdicts.iter().any(|v| v.needs_approval) {
CheckStatus::Unknown
} else if verdicts.iter().any(|v| v.degraded) {
CheckStatus::Warn
} else {
CheckStatus::Ok
};
DoctorCheck::new(CHECK_NAME, status, detail)
}
#[cfg(test)]
#[path = "doctor_credential_reach_tests.rs"]
mod tests;