Skip to main content

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