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    let system = system.to_lowercase();
315    // The git-bash family reports itself as MINGW64_NT-10.0-19045 and
316    // kin — a windows host by any prefix spelling, so the family gets
317    // the friendly name without a table row per environment.
318    if system.starts_with("mingw") || system.starts_with("msys") || system.starts_with("cygwin") {
319        return format!("Windows ({raw})");
320    }
321    match manifest.os_names.get(&system) {
322        Some(friendly) => format!("{friendly} ({raw})"),
323        None => raw.to_owned(),
324    }
325}
326
327/// Runs the probe script with `workspace` as working directory and
328/// returns its bounded stdout. Stdin is null so a shell that wrongly
329/// reads it sees EOF; anything still running at the deadline is killed
330/// and reaped.
331fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
332    let mut child = Command::new(rhema_agent_tools::exec::bash_executable())
333        .arg("-c")
334        .arg(probe_script(manifest))
335        .current_dir(workspace)
336        .stdin(Stdio::null())
337        .stdout(Stdio::piped())
338        .stderr(Stdio::null())
339        .spawn()
340        .map_err(|err| match err.kind() {
341            std::io::ErrorKind::NotFound => rhema_agent_tools::exec::bash_missing_message(),
342            _ => format!("cannot spawn the probe: {err}"),
343        })?;
344    let deadline = Instant::now() + PROBE_BUDGET;
345    let status = loop {
346        match child.try_wait() {
347            Ok(Some(status)) => break Some(status),
348            Ok(None) if Instant::now() >= deadline => break None,
349            Ok(None) => std::thread::sleep(POLL_STEP),
350            Err(err) => return Err(format!("cannot wait on the probe: {err}")),
351        }
352    };
353    let Some(status) = status else {
354        let _ = child.kill();
355        let _ = child.wait();
356        return Err(format!("no exit within {PROBE_BUDGET:?}"));
357    };
358    if !status.success() {
359        return Err(format!("probe exited: {status}"));
360    }
361    let mut bytes = Vec::new();
362    if let Some(mut stdout) = child.stdout.take() {
363        let _ = (&mut stdout)
364            .take(OUTPUT_CAP as u64)
365            .read_to_end(&mut bytes);
366    }
367    Ok(String::from_utf8_lossy(&bytes).into_owned())
368}
369
370/// The wall-clock UTC stamp, to the minute — the one timestamp shape
371/// the reports carry, stamped once at construction.
372fn utc_now() -> String {
373    let secs = SystemTime::now()
374        .duration_since(UNIX_EPOCH)
375        .unwrap_or_default()
376        .as_secs();
377    let (year, month, day) = civil_from_days((secs / 86_400) as i64);
378    let secs_of_day = secs % 86_400;
379    format!(
380        "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
381        secs_of_day / 3_600,
382        secs_of_day % 3_600 / 60
383    )
384}
385
386/// Days since the Unix epoch to (year, month, day), UTC — the civil
387/// calendar without a clock dependency.
388fn civil_from_days(days: i64) -> (i64, u32, u32) {
389    let z = days + 719_468;
390    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
391    let doe = z - era * 146_097;
392    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
393    let year = yoe + era * 400;
394    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
395    let mp = (5 * doy + 2) / 153;
396    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
397    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
398    (if month <= 2 { year + 1 } else { year }, month, day)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    /// The baked manifest parses and every text renders over sample
406    /// facts — the drift test a hand edit fails: a manifest that stops
407    /// parsing falls to the fallback at runtime, so the break belongs
408    /// in a test, not a startup.
409    #[test]
410    fn the_baked_manifest_parses_and_every_text_renders() {
411        let manifest = manifest();
412        assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
413        assert!(!manifest.report.is_empty());
414        assert!(
415            !manifest.facts.is_empty(),
416            "the facts block is written once"
417        );
418        let facts = facts("m", "h");
419        for text in [
420            &manifest.facts,
421            &manifest.report,
422            &manifest.notice,
423            &manifest.unavailable,
424        ] {
425            let context = facts.context(
426                manifest,
427                json!({
428                    "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
429                    "workspace": "/ws", "branch": "main",
430                    "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
431                    "kind": "k", "reason": "r",
432                }),
433            );
434            let rendered = render_template(text, &context).expect("the text renders");
435            assert!(!rendered.trim().is_empty());
436        }
437    }
438
439    /// The host report carries its facts — date, model, harness, the OS
440    /// line, one presence line per manifest entry — with no versions
441    /// anywhere. The branch line follows the probe's own rule, present
442    /// on a checkout and absent off one, so both sides get tested: the
443    /// CWD decides which, and a throwaway repo pins the positive side
444    /// wherever the suite happens to run. Runs real bash, like the
445    /// claw's environment tests.
446    #[test]
447    fn the_host_report_carries_facts_and_presence_without_versions() {
448        let manifest = manifest();
449        let report = host_report(Path::new("."), "glm-5.3", "rhema test");
450        assert!(report.starts_with("## Environment\n"), "{report}");
451        for fact in [
452            "- Date: ",
453            "- Model: glm-5.3",
454            "- Harness: rhema test",
455            "### Machine",
456            "- OS: ",
457            "### Workspace",
458            "- CWD: ",
459            "### Command-line tools",
460        ] {
461            assert!(report.contains(fact), "missing {fact}: {report}");
462        }
463        for entry in &manifest.probe {
464            assert!(
465                report.contains(&format!("- {}: ", entry.executable)),
466                "no line for {}: {report}",
467                entry.executable
468            );
469        }
470        let on_checkout = Command::new("git")
471            .args(["rev-parse", "--is-inside-work-tree"])
472            .output()
473            .is_ok_and(|out| out.status.success());
474        assert_eq!(
475            report.contains("- git branch: "),
476            on_checkout,
477            "branch line must match the checkout: {report}"
478        );
479        assert!(
480            !report.contains("version"),
481            "presence, not versions: {report}"
482        );
483
484        // A throwaway checkout proves the positive side on any runner:
485        // the report names whatever branch the fixture lands on.
486        let repo = std::env::temp_dir().join(format!("rhema-env-{}", std::process::id()));
487        let _ = std::fs::remove_dir_all(&repo);
488        std::fs::create_dir_all(&repo).expect("the fixture directory creates");
489        let run = |args: &[&str]| {
490            Command::new("git")
491                .args(["-c", "user.email=test@localhost", "-c", "user.name=test"])
492                .args(args)
493                .current_dir(&repo)
494                .output()
495                .expect("git runs")
496        };
497        run(&["init"]);
498        run(&["commit", "--allow-empty", "-m", "root"]);
499        let branch = String::from_utf8(run(&["branch", "--show-current"]).stdout)
500            .expect("the branch name is utf-8")
501            .trim()
502            .to_owned();
503        let report = host_report(&repo, "glm-5.3", "rhema test");
504        assert!(
505            report.contains(&format!("- git branch: {branch}")),
506            "the checkout's branch reaches the report: {report}"
507        );
508        assert!(
509            !report.contains("version"),
510            "presence, not versions: {report}"
511        );
512
513        // Detached, the branch line still stands: `git branch
514        // --show-current` answers empty there, and a PR checkout is
515        // exactly that shape - the line used to vanish and take the
516        // suite with it.
517        run(&["checkout", "--detach"]);
518        let report = host_report(&repo, "glm-5.3", "rhema test");
519        assert!(
520            report.contains("- git branch: detached"),
521            "a detached HEAD says so: {report}"
522        );
523        let _ = std::fs::remove_dir_all(&repo);
524    }
525
526    /// The OS line maps the friendly name beside the raw spelling and
527    /// keeps unmapped systems raw.
528    #[test]
529    fn the_os_line_maps_friendly_names_beside_the_raw() {
530        let manifest = manifest();
531        assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
532        assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
533    }
534
535    /// Advice joins the notes only when its executable answered — the
536    /// ripgrep note is data like any other entry's.
537    #[test]
538    fn advice_follows_presence_not_the_tool_name() {
539        let manifest = manifest();
540        let facts = facts("m", "h");
541        let with = render_report(
542            manifest,
543            Path::new("/ws"),
544            &facts,
545            "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
546        );
547        assert!(with.contains("Notes:"), "{with}");
548        assert!(with.contains("Use `rg`"), "{with}");
549        let without = render_report(
550            manifest,
551            Path::new("/ws"),
552            &facts,
553            "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
554        );
555        assert!(!without.contains("Notes:"), "{without}");
556        assert!(without.contains("- rg: absent"), "{without}");
557    }
558
559    /// The notice names the kind, the model, and the harness, and
560    /// claims no host facts.
561    #[test]
562    fn the_notice_names_the_kind_workspace_and_no_host_facts() {
563        let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
564        assert!(notice.contains("docker sandbox"), "{notice}");
565        assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
566        assert!(notice.contains("- Model: glm-5.3"), "{notice}");
567        assert!(notice.contains("- Harness: rhema test"), "{notice}");
568        assert!(!notice.contains("- OS: "), "{notice}");
569    }
570
571    /// A manifest entry the shell-safety filter refuses never reaches
572    /// the script: quoting in a probe is not executable data.
573    #[test]
574    fn unsafe_executables_never_reach_the_script() {
575        let mut manifest = manifest().clone();
576        manifest.probe.push(Probe {
577            executable: "x; rm -rf /".into(),
578            advice: None,
579        });
580        let script = probe_script(&manifest);
581        assert!(!script.contains("rm -rf"), "{script}");
582    }
583
584    /// A probe that cannot run degrades to the unavailable text, facts
585    /// and all.
586    #[test]
587    fn a_missing_workspace_renders_the_unavailable_text() {
588        let report = host_report(Path::new("/definitely/not/here"), "m", "h");
589        assert!(report.contains("did not answer"), "{report}");
590        assert!(report.contains("- Model: m"), "{report}");
591    }
592
593    /// The stamp is the civil UTC calendar: two anchors pin the shape.
594    #[test]
595    fn the_stamp_is_civil_utc() {
596        assert_eq!(civil_from_days(0), (1970, 1, 1));
597        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
598    }
599}