locode_host/lib.rs
1//! locode-host — the one place the system touches the OS (ADR-0008).
2//!
3//! Every side effect funnels through the [`Host`]: a path jail, shell execution with a
4//! timeout + output cap, jailed filesystem helpers, and the shared
5//! post-processes. Tools never call `std::fs`/`Command` directly —
6//! they go through a `Host`. This crate is a *sibling* of `locode-tools`, so it defines
7//! its own plain error types; the harness pack (which depends on both) maps them to
8//! `ToolError::Respond`.
9
10mod fs;
11mod path;
12mod rg;
13mod shell;
14mod walk;
15
16pub use fs::{DirEntry, FileRead, FileStat, FsError};
17pub use path::PathError;
18pub use rg::rg_program;
19pub use shell::{
20 ExecError, ExecOutput, ExecRequest, FrontBackCapture, FrontBackSpec, SPILL_RETAIN_MAX,
21 ShellSpec,
22};
23pub use walk::{WalkEntry, WalkOptions};
24
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28/// Whether the filesystem tools are jailed to the workspace root (ADR-0008 amendment).
29///
30/// The jail is the **default** posture, not a hard wall — every studied harness offers a
31/// full-access mode, and since we are headless (no permission prompt) a caller must be
32/// able to opt out (`--dangerously-skip-permissions` / `--yolo`). The shell tool is never
33/// path-jailed regardless; only the structured FS tools consult this.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum PathPolicy {
36 /// Resolve FS-tool paths under `workspace_root`; reject `..`/absolute/symlink escapes.
37 #[default]
38 Jailed,
39 /// Resolve relative paths against `cwd`, allow absolute / out-of-root paths (the
40 /// `--dangerously-skip-permissions` / `--yolo` behavior).
41 Unrestricted,
42}
43
44/// Limits on shell execution. Defaults mirror Grok Build (the primary model).
45#[derive(Debug, Clone)]
46pub struct ExecLimits {
47 /// Timeout used when a call passes none (grok/codex default = 10s).
48 pub default_timeout: Duration,
49 /// Hard ceiling; a caller-supplied timeout is clamped to this.
50 pub max_timeout: Duration,
51 /// Max retained output bytes per stream before truncation (grok = `30_000`).
52 pub max_output_bytes: usize,
53 /// Grace between `SIGTERM` and `SIGKILL` when killing a timed-out/cancelled command.
54 pub kill_grace: Duration,
55}
56
57impl Default for ExecLimits {
58 fn default() -> Self {
59 Self {
60 default_timeout: Duration::from_secs(10),
61 max_timeout: Duration::from_mins(10),
62 max_output_bytes: 30_000,
63 kill_grace: Duration::from_secs(2),
64 }
65 }
66}
67
68/// Construction-time configuration for a [`Host`].
69#[derive(Debug, Clone)]
70pub struct HostConfig {
71 /// The path-jail root (canonicalized at [`Host::new`]).
72 pub workspace_root: PathBuf,
73 /// Whether FS tools are jailed to `workspace_root` (default [`PathPolicy::Jailed`]).
74 pub path_policy: PathPolicy,
75 /// Shell execution limits.
76 pub exec: ExecLimits,
77 /// The shell program to run commands through (default `bash`). A configurable field
78 /// so a later pack / shell-detection can override it.
79 pub shell_program: String,
80 /// Run the shell as a login shell (`-lc`, loads the user's profile/RC — matches all
81 /// four studied harnesses) rather than `-c`. Default `true`.
82 pub login_shell: bool,
83}
84
85impl HostConfig {
86 /// A config for `workspace_root` with all defaults (jailed, `bash -lc`, grok limits).
87 pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
88 Self {
89 workspace_root: workspace_root.into(),
90 path_policy: PathPolicy::default(),
91 exec: ExecLimits::default(),
92 shell_program: "bash".to_string(),
93 login_shell: true,
94 }
95 }
96}
97
98/// The injectable side-effect seam (ADR-0008). Construct once per session, share behind
99/// an `Arc`; holds the jail root + policy and the exec/truncation limits.
100#[derive(Debug, Clone)]
101pub struct Host {
102 pub(crate) workspace_root: PathBuf,
103 pub(crate) path_policy: PathPolicy,
104 pub(crate) limits: ExecLimits,
105 pub(crate) shell_program: String,
106 pub(crate) login_shell: bool,
107 /// Lazily-built workspace gitignore matcher (Task 26 Slice 0).
108 pub(crate) gitignore: walk::GitignoreCache,
109 /// Once-probed login-shell PATH (Task 26 Slice 0b; grok's PATH probe).
110 pub(crate) login_path: std::sync::Arc<tokio::sync::OnceCell<Option<String>>>,
111}
112
113impl Host {
114 /// Build a host, canonicalizing `workspace_root` (the jail root must be a real,
115 /// symlink-resolved absolute path).
116 ///
117 /// # Errors
118 /// [`PathError::InvalidRoot`] if `workspace_root` does not exist or cannot be
119 /// canonicalized.
120 pub fn new(config: HostConfig) -> Result<Self, PathError> {
121 let workspace_root = std::fs::canonicalize(&config.workspace_root).map_err(|e| {
122 PathError::InvalidRoot(format!("{}: {e}", config.workspace_root.display()))
123 })?;
124 Ok(Self {
125 workspace_root,
126 path_policy: config.path_policy,
127 limits: config.exec,
128 shell_program: config.shell_program,
129 login_shell: config.login_shell,
130 gitignore: walk::GitignoreCache::new(),
131 login_path: std::sync::Arc::new(tokio::sync::OnceCell::new()),
132 })
133 }
134
135 /// The canonicalized jail root.
136 #[must_use]
137 pub fn workspace_root(&self) -> &Path {
138 &self.workspace_root
139 }
140
141 /// The active path policy.
142 #[must_use]
143 pub fn path_policy(&self) -> PathPolicy {
144 self.path_policy
145 }
146
147 /// The shell execution limits.
148 #[must_use]
149 pub fn limits(&self) -> &ExecLimits {
150 &self.limits
151 }
152}
153
154#[cfg(test)]
155pub(crate) fn test_host(root: &Path, policy: PathPolicy, login: bool) -> Host {
156 let mut config = HostConfig::new(root);
157 config.path_policy = policy;
158 config.login_shell = login;
159 Host::new(config).expect("test host")
160}