Skip to main content

rhema_coding_agent/
environment.rs

1//! The startup environment report: machine facts for the system prompt,
2//! probed once before the first model call so no turn is spent
3//! rediscovering them. The claw's daemon writes the same report at boot
4//! (`ENVIRONMENT.md`); here the report renders straight into the
5//! prompt's environment section.
6//!
7//! Every text the report carries is data: [`environment.yaml`] at the
8//! crate root — beside SYSTEM.md — holds the OS name mapping, the probe
9//! inventory, and the report, notice, and unavailable texts as Jinja
10//! templates over the facts this module gathers. The Rust here is
11//! mechanics alone: run the probe, shape the facts, render the
12//! template. The file is baked in (`include_str!`), not read at
13//! runtime: the report must not turn machine-dependent, and the cloud
14//! frontends have no filesystem to read it from anyway.
15//!
16//! The probe is synchronous on purpose — agent construction is sync —
17//! and best-effort: one `bash -c` under a short budget on a fixed
18//! script, a failure degrading to the `unavailable` text, never a
19//! failed startup. It describes the host, which is where commands run
20//! unless a sandbox re-points them; a sandboxed session takes
21//! [`remote_notice`] instead, because host facts would be lies about a
22//! container. The report's date is stamped once at construction: it
23//! rides the system prompt, which stays byte-stable for a session's
24//! life.
25//!
26//! [`environment.yaml`]: crate::environment::manifest
27
28use std::collections::BTreeMap;
29use std::io::Read;
30use std::path::Path;
31use std::process::{Command, Stdio};
32use std::sync::OnceLock;
33use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
34
35use rhema_agent_tools::render_template;
36use serde_json::json;
37
38/// One inventoried tool.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
40pub struct Probe {
41    /// The executable the probe looks for and the report names — the
42    /// command the model would type, not the project's name for it.
43    pub executable: String,
44    /// Guidance that joins the report's notes only when `executable`
45    /// answered. Advice about a missing tool would be the stale kind.
46    #[serde(default)]
47    pub advice: Option<String>,
48}
49
50/// The report's data: the inventory, the OS name mapping, and the
51/// texts (Jinja templates) the report renders.
52#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
53pub struct Manifest {
54    /// The friendly name for each `uname -s` system value; a system
55    /// without a row keeps its raw uname spelling.
56    #[serde(default)]
57    pub os_names: BTreeMap<String, String>,
58    /// The probed executables, in report order.
59    pub probe: Vec<Probe>,
60    /// The session facts every shape places by reference — written
61    /// once, never duplicated in the texts.
62    pub facts: String,
63    /// The report a local session renders.
64    pub report: String,
65    /// The message a sandboxed session shows instead.
66    pub notice: String,
67    /// The message a failed probe degrades to.
68    pub unavailable: String,
69}
70
71/// The baked-in manifest (`environment.yaml` at the crate root), parsed
72/// once. A manifest that does not parse falls to [`fallback_report`]:
73/// the drift test is where that break belongs, never a startup.
74pub fn manifest() -> &'static Manifest {
75    static MANIFEST: OnceLock<Manifest> = OnceLock::new();
76    MANIFEST.get_or_init(|| {
77        serde_yaml::from_str(include_str!("../environment.yaml")).unwrap_or_else(|_| Manifest {
78            os_names: BTreeMap::new(),
79            probe: Vec::new(),
80            facts: String::new(),
81            report: String::new(),
82            notice: String::new(),
83            unavailable: String::new(),
84        })
85    })
86}
87
88/// An executable name the probe script can carry verbatim: letters,
89/// digits, dots, dashes, underscores — nothing that could close the
90/// `"$t"` quoting and become shell of its own.
91fn shell_safe(executable: &str) -> bool {
92    !executable.is_empty()
93        && executable
94            .chars()
95            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
96}
97
98/// The probe's whole budget: long enough for a cold tool list, short
99/// enough that a hung shell cannot stall a startup past it.
100const PROBE_BUDGET: Duration = Duration::from_secs(5);
101
102/// How often a running probe is polled for exit.
103const POLL_STEP: Duration = Duration::from_millis(10);
104
105/// The report-side bound on the probe's stdout: the inventory is
106/// fixed-size, so this is a guard against a pathological shell, not an
107/// expectation.
108const OUTPUT_CAP: usize = 4 * 1024;
109
110/// The probe script: OS, shell, one presence line per manifest entry —
111/// versions stay out; presence is the fact that changes how the model
112/// should work — and the workspace's git branch when it is a checkout,
113/// `detached` when HEAD is detached: the shape a PR checkout lands in.
114/// POSIX sh throughout — every executor in the tree runs `bash -c`.
115fn probe_script(manifest: &Manifest) -> String {
116    let tools: Vec<&str> = manifest
117        .probe
118        .iter()
119        .filter(|entry| shell_safe(&entry.executable))
120        .map(|entry| entry.executable.as_str())
121        .collect();
122    format!(
123        r#"
124uname -sm
125printf 'shell: %s\n' "${{SHELL:-unset}}"
126for t in {tools}; do
127  if command -v "$t" >/dev/null 2>&1; then
128    printf '%s: present\n' "$t"
129  else
130    printf '%s: absent\n' "$t"
131  fi
132done
133if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
134  # `git branch --show-current` answers empty, and exits 0, on a
135  # detached HEAD (a PR checkout, a tag, a bisect), so the name is
136  # captured first: a blank one means detached, not "no branch line".
137  b=$(git branch --show-current 2>/dev/null)
138  [ -n "$b" ] || b=detached
139  printf 'git branch: %s\n' "$b"
140fi
141"#,
142        tools = tools.join(" ")
143    )
144}
145
146/// The host report: what `bash` on this machine, rooted at `workspace`,
147/// would answer at startup — carrying `model`, the model the session
148/// runs, and `harness`, the frontend assembling it. Never fails: a
149/// probe that cannot run renders the unavailable text.
150pub fn host_report(workspace: &Path, model: &str, harness: &str) -> String {
151    let facts = facts(model, harness);
152    match run_probe(manifest(), workspace) {
153        Ok(output) => render_report(manifest(), workspace, &facts, &output),
154        Err(reason) => unavailable(&facts, &reason),
155    }
156}
157
158/// The section a sandboxed session renders instead of host facts: the
159/// report's honesty rule — commands run somewhere a host probe cannot
160/// see, so it claims none and names the kind. The sandbox-side
161/// `workspace` is carried — where the model's commands run is not a
162/// host fact.
163pub fn remote_notice(kind: &str, workspace: &str, model: &str, harness: &str) -> String {
164    let manifest = manifest();
165    let facts = facts(model, harness);
166    let rendered = render_text(
167        &manifest.notice,
168        &facts.context(manifest, json!({ "kind": kind, "workspace": workspace })),
169    );
170    if rendered.is_empty() {
171        fallback_report(&facts)
172    } else {
173        rendered
174    }
175}
176
177/// The facts every report shape carries, however the probe went.
178struct Facts {
179    /// The construction-time UTC stamp, to the minute.
180    date: String,
181    /// The model the session runs.
182    model: String,
183    /// The frontend assembling the agent.
184    harness: String,
185}
186
187impl Facts {
188    /// The template context every text extends, carrying the composed
189    /// `facts` block — the yaml's one copy of the session facts,
190    /// placed by reference, never re-typed in a text.
191    fn context(&self, manifest: &Manifest, extra: serde_json::Value) -> serde_json::Value {
192        let serde_json::Value::Object(mut map) = extra else {
193            return serde_json::Value::Null;
194        };
195        map.insert("date".into(), json!(self.date));
196        map.insert("model".into(), json!(self.model));
197        map.insert("harness".into(), json!(self.harness));
198        let facts = render_template(
199            &manifest.facts,
200            &json!({ "date": self.date, "model": self.model, "harness": self.harness }),
201        )
202        .unwrap_or_default();
203        map.insert("facts".into(), json!(facts));
204        serde_json::Value::Object(map)
205    }
206}
207
208fn facts(model: &str, harness: &str) -> Facts {
209    Facts {
210        date: utc_now(),
211        model: model.to_owned(),
212        harness: harness.to_owned(),
213    }
214}
215
216/// Renders one manifest text over its context; an empty text (the
217/// unparsed-manifest case) falls to [`fallback_report`].
218fn render_text(text: &str, context: &serde_json::Value) -> String {
219    if text.is_empty() {
220        return String::new();
221    }
222    render_template(text, context).unwrap_or_default()
223}
224
225/// Shapes the probe's bounded stdout into the report over the manifest:
226/// the first two lines are the OS and the shell, the presence lines
227/// follow, and every answered executable with advice earns its note.
228fn render_report(manifest: &Manifest, workspace: &Path, facts: &Facts, output: &str) -> String {
229    let mut lines = output.lines().filter(|line| !line.trim().is_empty());
230    let os = os_label(manifest, lines.next().unwrap_or("unknown"));
231    let shell = lines
232        .next()
233        .and_then(|line| line.strip_prefix("shell: "))
234        .unwrap_or("unknown")
235        .to_owned();
236    let rest: Vec<&str> = lines.collect();
237    let branch = rest
238        .iter()
239        .find_map(|l| l.strip_prefix("git branch: "))
240        .map(str::to_owned);
241    let tools: Vec<serde_json::Value> = manifest
242        .probe
243        .iter()
244        .map(|entry| {
245            let present = rest
246                .iter()
247                .any(|l| *l == format!("{}: present", entry.executable));
248            json!({ "name": entry.executable, "state": if present { "present" } else { "absent" } })
249        })
250        .collect();
251    let notes: Vec<String> = manifest
252        .probe
253        .iter()
254        .filter(|entry| {
255            entry.advice.is_some()
256                && rest
257                    .iter()
258                    .any(|l| *l == format!("{}: present", entry.executable))
259        })
260        .filter_map(|entry| entry.advice.clone())
261        .collect();
262    let context = facts.context(
263        manifest,
264        json!({
265            "os": os,
266            "shell": shell,
267            "workspace": workspace.display().to_string(),
268            "branch": branch,
269            "tools": tools,
270            "notes": notes,
271        }),
272    );
273    let rendered = render_text(&manifest.report, &context);
274    if rendered.is_empty() {
275        fallback_report(facts)
276    } else {
277        rendered
278    }
279}
280
281/// The unavailable text over the failure reason.
282fn unavailable(facts: &Facts, reason: &str) -> String {
283    let manifest = manifest();
284    let rendered = render_text(
285        &manifest.unavailable,
286        &facts.context(manifest, json!({ "reason": reason })),
287    );
288    if rendered.is_empty() {
289        fallback_report(facts)
290    } else {
291        rendered
292    }
293}
294
295/// The mechanism's last resort, when the manifest itself did not parse:
296/// the facts the shell-independent clock still names, no template.
297fn fallback_report(facts: &Facts) -> String {
298    format!(
299        "## Environment\n\n\
300         The report's data did not parse; ask the shell (`uname -sm`,\n\
301         `command -v <tool>`) when a fact matters.\n\n\
302         - Date: {}\n- Model: {}\n- Harness: {}\n",
303        facts.date, facts.model, facts.harness
304    )
305}
306
307/// The OS line: the mapping's friendly name with the raw uname spelling
308/// beside it — `macOS (Darwin arm64)` — or the raw spelling alone when
309/// the manifest maps no friendly name.
310fn os_label(manifest: &Manifest, raw: &str) -> String {
311    let Some((system, _rest)) = raw.split_once(' ') else {
312        return raw.to_owned();
313    };
314    match manifest.os_names.get(&system.to_lowercase()) {
315        Some(friendly) => format!("{friendly} ({raw})"),
316        None => raw.to_owned(),
317    }
318}
319
320/// Runs the probe script with `workspace` as working directory and
321/// returns its bounded stdout. Stdin is null so a shell that wrongly
322/// reads it sees EOF; anything still running at the deadline is killed
323/// and reaped.
324fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
325    let mut child = Command::new("bash")
326        .arg("-c")
327        .arg(probe_script(manifest))
328        .current_dir(workspace)
329        .stdin(Stdio::null())
330        .stdout(Stdio::piped())
331        .stderr(Stdio::null())
332        .spawn()
333        .map_err(|err| format!("cannot spawn the probe: {err}"))?;
334    let deadline = Instant::now() + PROBE_BUDGET;
335    let status = loop {
336        match child.try_wait() {
337            Ok(Some(status)) => break Some(status),
338            Ok(None) if Instant::now() >= deadline => break None,
339            Ok(None) => std::thread::sleep(POLL_STEP),
340            Err(err) => return Err(format!("cannot wait on the probe: {err}")),
341        }
342    };
343    let Some(status) = status else {
344        let _ = child.kill();
345        let _ = child.wait();
346        return Err(format!("no exit within {PROBE_BUDGET:?}"));
347    };
348    if !status.success() {
349        return Err(format!("probe exited: {status}"));
350    }
351    let mut bytes = Vec::new();
352    if let Some(mut stdout) = child.stdout.take() {
353        let _ = (&mut stdout)
354            .take(OUTPUT_CAP as u64)
355            .read_to_end(&mut bytes);
356    }
357    Ok(String::from_utf8_lossy(&bytes).into_owned())
358}
359
360/// The wall-clock UTC stamp, to the minute — the one timestamp shape
361/// the reports carry, stamped once at construction.
362fn utc_now() -> String {
363    let secs = SystemTime::now()
364        .duration_since(UNIX_EPOCH)
365        .unwrap_or_default()
366        .as_secs();
367    let (year, month, day) = civil_from_days((secs / 86_400) as i64);
368    let secs_of_day = secs % 86_400;
369    format!(
370        "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
371        secs_of_day / 3_600,
372        secs_of_day % 3_600 / 60
373    )
374}
375
376/// Days since the Unix epoch to (year, month, day), UTC — the civil
377/// calendar without a clock dependency.
378fn civil_from_days(days: i64) -> (i64, u32, u32) {
379    let z = days + 719_468;
380    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
381    let doe = z - era * 146_097;
382    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
383    let year = yoe + era * 400;
384    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
385    let mp = (5 * doy + 2) / 153;
386    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
387    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
388    (if month <= 2 { year + 1 } else { year }, month, day)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    /// The baked manifest parses and every text renders over sample
396    /// facts — the drift test a hand edit fails: a manifest that stops
397    /// parsing falls to the fallback at runtime, so the break belongs
398    /// in a test, not a startup.
399    #[test]
400    fn the_baked_manifest_parses_and_every_text_renders() {
401        let manifest = manifest();
402        assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
403        assert!(!manifest.report.is_empty());
404        assert!(
405            !manifest.facts.is_empty(),
406            "the facts block is written once"
407        );
408        let facts = facts("m", "h");
409        for text in [
410            &manifest.facts,
411            &manifest.report,
412            &manifest.notice,
413            &manifest.unavailable,
414        ] {
415            let context = facts.context(
416                manifest,
417                json!({
418                    "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
419                    "workspace": "/ws", "branch": "main",
420                    "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
421                    "kind": "k", "reason": "r",
422                }),
423            );
424            let rendered = render_template(text, &context).expect("the text renders");
425            assert!(!rendered.trim().is_empty());
426        }
427    }
428
429    /// The host report carries its facts — date, model, harness, the OS
430    /// line, one presence line per manifest entry — with no versions
431    /// anywhere. The branch line follows the probe's own rule, present
432    /// on a checkout and absent off one, so both sides get tested: the
433    /// CWD decides which, and a throwaway repo pins the positive side
434    /// wherever the suite happens to run. Runs real bash, like the
435    /// claw's environment tests.
436    #[test]
437    fn the_host_report_carries_facts_and_presence_without_versions() {
438        let manifest = manifest();
439        let report = host_report(Path::new("."), "glm-5.3", "rhema test");
440        assert!(report.starts_with("## Environment\n"), "{report}");
441        for fact in [
442            "- Date: ",
443            "- Model: glm-5.3",
444            "- Harness: rhema test",
445            "### Machine",
446            "- OS: ",
447            "### Workspace",
448            "- CWD: ",
449            "### Command-line tools",
450        ] {
451            assert!(report.contains(fact), "missing {fact}: {report}");
452        }
453        for entry in &manifest.probe {
454            assert!(
455                report.contains(&format!("- {}: ", entry.executable)),
456                "no line for {}: {report}",
457                entry.executable
458            );
459        }
460        let on_checkout = Command::new("git")
461            .args(["rev-parse", "--is-inside-work-tree"])
462            .output()
463            .is_ok_and(|out| out.status.success());
464        assert_eq!(
465            report.contains("- git branch: "),
466            on_checkout,
467            "branch line must match the checkout: {report}"
468        );
469        assert!(
470            !report.contains("version"),
471            "presence, not versions: {report}"
472        );
473
474        // A throwaway checkout proves the positive side on any runner:
475        // the report names whatever branch the fixture lands on.
476        let repo = std::env::temp_dir().join(format!("rhema-env-{}", std::process::id()));
477        let _ = std::fs::remove_dir_all(&repo);
478        std::fs::create_dir_all(&repo).expect("the fixture directory creates");
479        let run = |args: &[&str]| {
480            Command::new("git")
481                .args(["-c", "user.email=test@localhost", "-c", "user.name=test"])
482                .args(args)
483                .current_dir(&repo)
484                .output()
485                .expect("git runs")
486        };
487        run(&["init"]);
488        run(&["commit", "--allow-empty", "-m", "root"]);
489        let branch = String::from_utf8(run(&["branch", "--show-current"]).stdout)
490            .expect("the branch name is utf-8")
491            .trim()
492            .to_owned();
493        let report = host_report(&repo, "glm-5.3", "rhema test");
494        assert!(
495            report.contains(&format!("- git branch: {branch}")),
496            "the checkout's branch reaches the report: {report}"
497        );
498        assert!(
499            !report.contains("version"),
500            "presence, not versions: {report}"
501        );
502
503        // Detached, the branch line still stands: `git branch
504        // --show-current` answers empty there, and a PR checkout is
505        // exactly that shape - the line used to vanish and take the
506        // suite with it.
507        run(&["checkout", "--detach"]);
508        let report = host_report(&repo, "glm-5.3", "rhema test");
509        assert!(
510            report.contains("- git branch: detached"),
511            "a detached HEAD says so: {report}"
512        );
513        let _ = std::fs::remove_dir_all(&repo);
514    }
515
516    /// The OS line maps the friendly name beside the raw spelling and
517    /// keeps unmapped systems raw.
518    #[test]
519    fn the_os_line_maps_friendly_names_beside_the_raw() {
520        let manifest = manifest();
521        assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
522        assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
523    }
524
525    /// Advice joins the notes only when its executable answered — the
526    /// ripgrep note is data like any other entry's.
527    #[test]
528    fn advice_follows_presence_not_the_tool_name() {
529        let manifest = manifest();
530        let facts = facts("m", "h");
531        let with = render_report(
532            manifest,
533            Path::new("/ws"),
534            &facts,
535            "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
536        );
537        assert!(with.contains("Notes:"), "{with}");
538        assert!(with.contains("Use `rg`"), "{with}");
539        let without = render_report(
540            manifest,
541            Path::new("/ws"),
542            &facts,
543            "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
544        );
545        assert!(!without.contains("Notes:"), "{without}");
546        assert!(without.contains("- rg: absent"), "{without}");
547    }
548
549    /// The notice names the kind, the model, and the harness, and
550    /// claims no host facts.
551    #[test]
552    fn the_notice_names_the_kind_workspace_and_no_host_facts() {
553        let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
554        assert!(notice.contains("docker sandbox"), "{notice}");
555        assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
556        assert!(notice.contains("- Model: glm-5.3"), "{notice}");
557        assert!(notice.contains("- Harness: rhema test"), "{notice}");
558        assert!(!notice.contains("- OS: "), "{notice}");
559    }
560
561    /// A manifest entry the shell-safety filter refuses never reaches
562    /// the script: quoting in a probe is not executable data.
563    #[test]
564    fn unsafe_executables_never_reach_the_script() {
565        let mut manifest = manifest().clone();
566        manifest.probe.push(Probe {
567            executable: "x; rm -rf /".into(),
568            advice: None,
569        });
570        let script = probe_script(&manifest);
571        assert!(!script.contains("rm -rf"), "{script}");
572    }
573
574    /// A probe that cannot run degrades to the unavailable text, facts
575    /// and all.
576    #[test]
577    fn a_missing_workspace_renders_the_unavailable_text() {
578        let report = host_report(Path::new("/definitely/not/here"), "m", "h");
579        assert!(report.contains("did not answer"), "{report}");
580        assert!(report.contains("- Model: m"), "{report}");
581    }
582
583    /// The stamp is the civil UTC calendar: two anchors pin the shape.
584    #[test]
585    fn the_stamp_is_civil_utc() {
586        assert_eq!(civil_from_days(0), (1970, 1, 1));
587        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
588    }
589}