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
//! Persisted operator desired-state for supervisor auto-resume (#1222 / RFC Q6).
//!
//! Why: `TRUSTY_MPM_AUTO_RESUME` is a *process* env var read by the supervisor
//! at startup. The supervisor runs as a separate launchd-managed process, so the
//! daemon (and therefore the console, which speaks MCP to the daemon) cannot
//! mutate the supervisor's live environment. To give the console a real, non-CLI
//! control over auto-resume (RFC §6 Q6 — "the console SHALL provide controls to
//! enable/disable auto-resume"), we persist the operator's *desired* flag to a
//! tiny file under the framework root. The supervisor reads this file on every
//! sweep, and `supervisor_status` surfaces it so the console can render the
//! toggle's true state.
//!
//! #5208: until this issue, the supervisor read nothing — the console wrote the
//! file, `supervisor_status` displayed it, and no code path acted on it. The
//! supervisor now consults it in [`crate::supervisor::Supervisor::tick`] with
//! this precedence: the file when present (the operator's live console choice) >
//! the boot-time `TRUSTY_MPM_AUTO_RESUME` env / `--auto-resume` flag > off.
//! Decide on that with [`read_override_at`] (tri-state), never with
//! [`read_desired_at`] — the latter collapses "file absent" into `false`, so
//! using it would let a never-touched toggle silently disable an env-enabled
//! supervisor.
//! What: [`read_desired`] / [`write_desired`] read/write
//! `~/.trusty-mpm/auto_resume` (a one-line `true` / `false`); [`read_override`] /
//! [`read_override_at`] expose the same file as a tri-state `Option<bool>` that
//! distinguishes "no override" from "explicitly off"; [`effective_from_env`]
//! reports the env-derived flag the supervisor process actually booted with. A
//! missing file means "no operator override"; [`read_desired_at`] renders that as
//! `false` for display (auto-resume is opt-in).
//! Test: `read_missing_is_false`, `write_then_read_round_trips`,
//! `read_override_distinguishes_absent_from_false`,
//! `read_override_propagates_non_notfound_errors`,
//! `effective_from_env_parses_truthy` in the `tests` module.
use std::path::{Path, PathBuf};
use crate::core::paths::FrameworkPaths;
use crate::supervisor::config::ENV_AUTO_RESUME;
/// Filename (under the framework root) holding the persisted desired flag.
///
/// Why: a single named constant keeps the console, the daemon, and the
/// supervisor agreeing on one location without drift.
/// What: `auto_resume` — a plain-text file containing `true` or `false`.
/// Test: `desired_path_is_under_root`.
pub const AUTO_RESUME_FILE: &str = "auto_resume";
/// Resolve the path of the persisted desired-state file.
///
/// Why: centralising the path keeps every reader/writer consistent and lets
/// tests point it at a temp root.
/// What: `<root>/auto_resume` derived from the given [`FrameworkPaths`].
/// Test: `desired_path_is_under_root`.
pub fn desired_path(paths: &FrameworkPaths) -> PathBuf {
paths.root.join(AUTO_RESUME_FILE)
}
/// Read the persisted desired flag from an explicit path.
///
/// Why: separating the path-taking core from the home-resolving wrapper keeps
/// the file logic hermetically testable.
/// What: returns `Ok(true)` only when the file's trimmed contents are a truthy
/// token (`1`, `true`, `yes`, `on`, case-insensitive); a missing file is
/// `Ok(false)` (no override). I/O errors other than not-found propagate.
/// Test: `read_missing_is_false`, `write_then_read_round_trips`.
pub fn read_desired_at(path: &Path) -> std::io::Result<bool> {
// #5208: display-only flattening — the supervisor must use `read_override_at`.
Ok(read_override_at(path)?.unwrap_or(false))
}
/// Read the persisted flag as a tri-state operator override.
///
/// Why: the supervisor has to tell "the operator never touched the console
/// toggle" (no file → its boot-time env / `--auto-resume` flag stands) apart from
/// "the operator explicitly turned auto-resume OFF" (file says `false` → override
/// the boot flag). [`read_desired_at`] collapses both to `false`, which is right
/// for rendering a toggle and wrong for deciding behavior: it would let an absent
/// file silently disable an env-enabled supervisor — the same write-only /
/// fail-open shape #5208 exists to close.
/// What: `Ok(None)` when the file does not exist, `Ok(Some(flag))` when it does,
/// and `Err` for every other I/O failure so the caller can decide what to hold on
/// to rather than being handed a fabricated `false`.
/// Test: `read_override_distinguishes_absent_from_false`,
/// `read_override_propagates_non_notfound_errors`.
pub fn read_override_at(path: &Path) -> std::io::Result<Option<bool>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(parse_truthy(contents.trim()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
/// Write the persisted desired flag to an explicit path, creating parents.
///
/// Why: the console toggle must durably record the operator's choice so it
/// survives daemon restarts and is visible to the supervisor.
/// What: ensures the parent directory exists, then writes `true` or `false` plus
/// a trailing newline.
/// Test: `write_then_read_round_trips`.
pub fn write_desired_at(path: &Path, enabled: bool) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, if enabled { "true\n" } else { "false\n" })
}
/// Read the persisted desired flag from the default framework root.
///
/// Why: production callers want `~/.trusty-mpm/auto_resume` without resolving
/// the home directory themselves.
/// What: resolves [`FrameworkPaths::default`] and reads `desired_path`.
/// Test: covered indirectly via `read_desired_at`.
pub fn read_desired() -> std::io::Result<bool> {
read_desired_at(&desired_path(&FrameworkPaths::default()))
}
/// Read the tri-state operator override from the default framework root.
///
/// Why: the console's status payload must distinguish "no override" from
/// "explicitly off" to report what the next supervisor sweep will actually do.
/// What: resolves [`FrameworkPaths::default`] and reads [`read_override_at`].
/// Test: covered indirectly via `read_override_distinguishes_absent_from_false`.
pub fn read_override() -> std::io::Result<Option<bool>> {
read_override_at(&desired_path(&FrameworkPaths::default()))
}
/// Write the persisted desired flag to the default framework root.
///
/// Why: the console's auto-resume toggle calls this through the MCP backend.
/// What: resolves [`FrameworkPaths::default`] and writes `desired_path`.
/// Test: covered indirectly via `write_desired_at`.
pub fn write_desired(enabled: bool) -> std::io::Result<()> {
write_desired_at(&desired_path(&FrameworkPaths::default()), enabled)
}
/// Report the auto-resume flag the supervisor process booted with.
///
/// Why: the console shows the boot env flag beside the persisted desire so an
/// operator can see which one is in force. Since #5208 the persisted file
/// outranks this and applies on the next sweep, so this is the fallback when no
/// override file exists — not, as it once was, the thing a restart is needed to
/// change.
/// What: parses `TRUSTY_MPM_AUTO_RESUME` from the process environment as a
/// truthy token; absent or non-truthy is `false`.
/// Test: `effective_from_env_parses_truthy`.
pub fn effective_from_env() -> bool {
std::env::var(ENV_AUTO_RESUME)
.ok()
.map(|v| parse_truthy(v.trim()))
.unwrap_or(false)
}
/// Parse a truthy token the same way across the env var and the file.
///
/// Why: the console may write `true`/`false` while an operator may set the env
/// to `1`; one parser keeps both forms consistent.
/// What: case-insensitive match against `1`, `true`, `yes`, `on`.
///
/// Visibility (#5784): `pub(crate)` so
/// [`crate::core::host_state_gate`]'s opt-in reads the same spellings rather
/// than adding a fourth truthy parser to this crate.
/// Test: `parse_truthy_accepts_known_tokens`.
pub(crate) fn parse_truthy(s: &str) -> bool {
matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Why: a missing override file must read as `false` (auto-resume is opt-in),
/// not error — the console shows the toggle off by default.
/// Test: this test.
#[test]
fn read_missing_is_false() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("auto_resume");
assert!(!read_desired_at(&path).expect("read missing"));
}
/// Why: the console toggle's write must be durable and round-trip exactly so
/// a re-read (after a daemon restart) reflects the operator's choice.
/// Test: this test.
#[test]
fn write_then_read_round_trips() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("nested").join("auto_resume");
write_desired_at(&path, true).expect("write true");
assert!(read_desired_at(&path).expect("read true"));
write_desired_at(&path, false).expect("write false");
assert!(!read_desired_at(&path).expect("read false"));
}
/// Why: #5208's precedence rule turns on this distinction — "file absent"
/// must leave the supervisor's boot flag alone, while "file says false" must
/// override it. `read_desired_at` cannot express that; `read_override_at`
/// must.
/// Test: this test.
#[test]
fn read_override_distinguishes_absent_from_false() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("auto_resume");
assert_eq!(read_override_at(&path).expect("absent"), None);
write_desired_at(&path, false).expect("write false");
assert_eq!(
read_override_at(&path).expect("explicit false"),
Some(false)
);
write_desired_at(&path, true).expect("write true");
assert_eq!(read_override_at(&path).expect("explicit true"), Some(true));
// The display-flattening wrapper still reports both absent and explicit
// false as `false`, which is why the supervisor must not use it.
std::fs::remove_file(&path).expect("remove");
assert!(!read_desired_at(&path).expect("flattened absent"));
}
/// Why: an I/O failure that is NOT not-found (a directory in the file's
/// place, an unreadable mode) must reach the caller. Swallowing it into
/// `false` would silently disable an operator-enabled supervisor — the exact
/// fail-open #5208 closes, relocated one layer out.
/// Test: this test.
#[test]
fn read_override_propagates_non_notfound_errors() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("auto_resume");
// A directory where the file should be: reads fail with EISDIR, not NotFound.
std::fs::create_dir(&path).expect("mkdir");
let err = read_override_at(&path).expect_err("a directory must not read as a flag");
assert_ne!(
err.kind(),
std::io::ErrorKind::NotFound,
"only not-found may be flattened to `no override`: {err}"
);
}
/// Why: an operator may set the env to `1` (not `true`); the env parser must
/// accept the same truthy tokens as the file.
/// Test: this test.
#[test]
fn parse_truthy_accepts_known_tokens() {
for t in ["1", "true", "TRUE", "Yes", "on"] {
assert!(parse_truthy(t), "{t} should be truthy");
}
for f in ["0", "false", "no", "off", ""] {
assert!(!parse_truthy(f), "{f} should be falsy");
}
}
/// Why: the desired-state file must live under the framework root so the
/// supervisor and console agree on one location.
/// Test: this test.
#[test]
fn desired_path_is_under_root() {
let paths = FrameworkPaths::under("/tmp/test-base");
let p = desired_path(&paths);
assert!(p.ends_with(".trusty-mpm/auto_resume"), "{p:?}");
}
}