M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,538 @@
//! Cgroup v2 memory-high monitor for graceful OOM handling.
//!
//! Approach:
//!
//! 1. On startup we create a child cgroup under the current process's cgroup and
//! configure:
//! - `memory.high` = soft limit (the "desired" ceiling)
//! - `memory.max` = soft limit + headroom (hard OOM kill boundary)
//!
//! 2. Before each spawned command, we write the child PID into `cgroup.procs` to
//! move it (and its entire process group) into the cgroup.
//!
//! 3. A background `MemoryHighMonitor` task watches `memory.events` via inotify.
//! When the kernel increments the `high` counter (meaning a process touched
//! `memory.high`), the monitor reads `memory.current` and — if RSS is still
//! above 90 % of `memory.high` — sends a `MemoryHighEvent` through a
//! `tokio::sync::watch` channel.
//!
//! 4. The terminal actor polls `monitor.try_recv()` on every tick. When it
//! receives an event it kills the offending process group with SIGKILL and
//! reports exit-code **137** (128 + SIGKILL) with signal `"oom"`.
//!
//! The kigi-tools process itself is **never** inside this cgroup — only spawned
//! child commands are. After the child exits the cgroup is empty until the next
//! command.
//!
//! ## Platform
//!
//! Everything compiles on all platforms, but the actual cgroup + inotify logic is
//! gated behind `#[cfg(target_os = "linux")]`. On macOS / Windows the public
//! constructors return `None` / no-op stubs so callers don't need `#[cfg]`.
/// Exit code for processes killed due to memory pressure.
/// Matches the POSIX convention: 128 + signal-number (SIGKILL = 9).
pub const PROCESS_OOM_EXIT_CODE: i32 = 137;
// ============================================================================
// Public types (cross-platform)
// ============================================================================
/// Event emitted when `memory.high` is breached and RSS is still above
/// the 90 % buffer threshold.
#[derive(Debug, Clone)]
pub struct MemoryHighEvent {
/// Current cgroup memory usage in bytes when the event fired.
pub memory_current: u64,
/// The configured `memory.high` threshold in bytes.
pub memory_high_threshold: u64,
}
/// Configuration for cgroup memory limits.
#[derive(Debug, Clone)]
pub struct CgroupMemoryConfig {
/// Soft memory limit (`memory.high`). When a process inside the cgroup
/// exceeds this, the monitor fires.
pub memory_high_bytes: u64,
/// Extra headroom above `memory.high` before the kernel hard-kills
/// (`memory.max = memory_high_bytes + headroom_bytes`).
/// A reasonable default is 256 MiB.
pub headroom_bytes: u64,
}
impl CgroupMemoryConfig {
/// memory.max = memory.high + headroom
#[cfg(target_os = "linux")]
fn memory_max(&self) -> u64 {
self.memory_high_bytes.saturating_add(self.headroom_bytes)
}
}
// ============================================================================
// Linux implementation
// ============================================================================
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
use tokio::io::Interest;
use tokio::io::unix::AsyncFd;
use tokio::sync::watch;
// ── inotify FFI ──────────────────────────────────────────────────────
unsafe fn inotify_init1(flags: libc::c_int) -> std::io::Result<i32> {
#[allow(clippy::cast_possible_truncation)]
let ret = unsafe { libc::syscall(libc::SYS_inotify_init1, flags) as libc::c_int };
if ret < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(ret)
}
unsafe fn inotify_add_watch(
fd: libc::c_int,
pathname: *const libc::c_char,
mask: u32,
) -> std::io::Result<i32> {
#[allow(clippy::cast_possible_truncation)]
let ret = unsafe {
libc::syscall(libc::SYS_inotify_add_watch, fd, pathname, mask) as libc::c_int
};
if ret < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(ret)
}
const IN_MODIFY: u32 = 0x0000_0002;
// ── Inotify wrapper ──────────────────────────────────────────────────
struct Inotify {
fd: AsyncFd<OwnedFd>,
}
impl Inotify {
fn new() -> std::io::Result<Self> {
let raw = unsafe { inotify_init1(libc::O_NONBLOCK | libc::O_CLOEXEC) }?;
let owned = unsafe { OwnedFd::from_raw_fd(raw) };
let fd = AsyncFd::with_interest(owned, Interest::READABLE)?;
Ok(Inotify { fd })
}
fn add_watch(&self, path: &std::path::Path) -> std::io::Result<i32> {
let mut bytes = path.as_os_str().as_bytes().to_vec();
bytes.push(0); // NUL-terminate
let wd = unsafe {
inotify_add_watch(
self.fd.get_ref().as_raw_fd(),
bytes.as_ptr().cast(),
IN_MODIFY,
)
}?;
Ok(wd)
}
async fn wait_and_drain(&self) -> std::io::Result<()> {
let mut guard = self.fd.readable().await?;
// Drain all pending inotify events
let mut buf = [0u8; 4096];
loop {
let n = unsafe {
libc::read(
guard.get_inner().as_raw_fd(),
buf.as_mut_ptr().cast(),
buf.len(),
)
};
if n > 0 {
continue;
}
break;
}
guard.clear_ready();
Ok(())
}
}
// ── Cgroup helpers ───────────────────────────────────────────────────
/// Read `/proc/self/cgroup` to find our own cgroup path (cgroup v2 unified).
fn read_self_cgroup() -> std::io::Result<String> {
let contents = std::fs::read_to_string("/proc/self/cgroup")?;
// In cgroupv2 unified hierarchy, the line is "0::<path>"
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("0::") {
return Ok(rest.to_owned());
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not find cgroupv2 entry in /proc/self/cgroup",
))
}
/// Parse the `high <N>` counter from `memory.events` contents.
fn parse_memory_events_high(contents: &str) -> Option<u64> {
for line in contents.lines() {
if let Some(value) = line.strip_prefix("high ") {
return value.trim().parse::<u64>().ok();
}
}
None
}
// ── CgroupHandle ─────────────────────────────────────────────────────
/// Owns the lifecycle of a child cgroup directory.
pub(crate) struct CgroupHandle {
fs_path: PathBuf,
}
impl CgroupHandle {
/// Create a child cgroup under the current process's cgroup and
/// configure memory limits.
pub(crate) async fn create(config: &CgroupMemoryConfig) -> std::io::Result<Self> {
let self_cgroup = read_self_cgroup()?;
let name = format!("kigi-tools-{}", uuid::Uuid::now_v7());
let fs_path = PathBuf::from(format!("/sys/fs/cgroup{}/{}", self_cgroup, name));
tokio::fs::create_dir_all(&fs_path).await?;
// Enable memory + cpu controllers in the child cgroup's parent
// (the parent's subtree_control must list the controllers).
let parent = fs_path.parent().unwrap();
let subtree_ctl = parent.join("cgroup.subtree_control");
// Best-effort; may already be enabled or not permitted.
let _ = tokio::fs::write(&subtree_ctl, "+memory +cpu").await;
// Configure memory.high (soft limit)
let memory_high_path = fs_path.join("memory.high");
tokio::fs::write(&memory_high_path, config.memory_high_bytes.to_string()).await?;
// Configure memory.max (hard limit = high + headroom)
let memory_max_path = fs_path.join("memory.max");
tokio::fs::write(&memory_max_path, config.memory_max().to_string()).await?;
tracing::info!(
cgroup = %fs_path.display(),
memory_high = config.memory_high_bytes,
memory_max = config.memory_max(),
"Created cgroup with memory limits"
);
Ok(CgroupHandle { fs_path })
}
/// Move a process (by PID) into this cgroup.
pub(crate) async fn add_process(&self, pid: u32) -> std::io::Result<()> {
let procs_path = self.fs_path.join("cgroup.procs");
tokio::fs::write(&procs_path, pid.to_string()).await
}
/// Read `memory.current` from this cgroup.
#[allow(dead_code)]
pub(crate) async fn memory_current(&self) -> std::io::Result<u64> {
let s: String = tokio::fs::read_to_string(self.fs_path.join("memory.current")).await?;
s.trim()
.parse::<u64>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Filesystem path to this cgroup.
pub(crate) fn path(&self) -> &std::path::Path {
&self.fs_path
}
}
impl Drop for CgroupHandle {
fn drop(&mut self) {
let path = self.fs_path.clone();
// Always use tokio::spawn: the cleanup future is Send and Drop
// can fire after the LocalSet has shut down, making spawn_local
// unsafe here.
tokio::spawn(async move {
let kill_path = path.join("cgroup.kill");
let _ = tokio::fs::write(&kill_path, "1").await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Err(e) = tokio::fs::remove_dir(&path).await {
tracing::debug!(
cgroup = %path.display(),
"Failed to remove cgroup dir on drop (may already be gone): {e}"
);
}
});
}
}
// ── MemoryHighMonitor ────────────────────────────────────────────────
/// Watches `memory.events` via inotify and signals when the `high`
/// counter increments while RSS is still above 90% of the threshold.
pub(crate) struct MemoryHighMonitor {
rx: watch::Receiver<Option<MemoryHighEvent>>,
/// Dropping this aborts the background task.
_handle: tokio::task::JoinHandle<()>,
}
impl MemoryHighMonitor {
/// Start monitoring the given cgroup for memory.high events.
pub(crate) async fn start(
cgroup_path: PathBuf,
memory_high_threshold: u64,
use_spawn_local: bool,
) -> std::io::Result<Self> {
let (tx, rx) = watch::channel(None);
let inotify = Inotify::new()?;
let events_path = cgroup_path.join("memory.events");
inotify.add_watch(&events_path)?;
let monitor_fut = Self::monitor_loop(inotify, cgroup_path, memory_high_threshold, tx);
let handle = if use_spawn_local {
tokio::task::spawn_local(monitor_fut)
} else {
tokio::spawn(monitor_fut)
};
Ok(MemoryHighMonitor {
rx,
_handle: handle,
})
}
/// Non-blocking check: returns `Some(event)` if memory.high was
/// breached since the last call, `None` otherwise.
pub(crate) fn try_recv(&mut self) -> Option<MemoryHighEvent> {
if self.rx.has_changed().unwrap_or(false) {
self.rx.borrow_and_update().clone()
} else {
None
}
}
async fn monitor_loop(
inotify: Inotify,
cgroup_path: PathBuf,
memory_high_threshold: u64,
tx: watch::Sender<Option<MemoryHighEvent>>,
) {
let events_path = cgroup_path.join("memory.events");
let current_path = cgroup_path.join("memory.current");
// Read baseline high counter
let mut last_high_count = Self::read_high_counter(&events_path).await.unwrap_or(0);
loop {
// Block until inotify fires (memory.events was modified)
if inotify.wait_and_drain().await.is_err() {
break;
}
// Read new high counter
let current_high = Self::read_high_counter(&events_path).await.unwrap_or(0);
if current_high <= last_high_count {
continue;
}
last_high_count = current_high;
// Read current memory usage
let memory_current = match tokio::fs::read_to_string(&current_path).await {
Ok(s) => {
let s: String = s;
s.trim().parse::<u64>().unwrap_or(0)
}
Err(_) => continue,
};
// Only fire if still above 90 % of threshold (avoids false
// positives from transient spikes the kernel already handled).
let buffer_threshold = memory_high_threshold.saturating_mul(9) / 10;
if memory_current >= buffer_threshold {
let event = MemoryHighEvent {
memory_current,
memory_high_threshold,
};
if tx.send(Some(event)).is_err() {
break; // receiver dropped
}
}
}
}
async fn read_high_counter(events_path: &std::path::Path) -> Option<u64> {
let contents = tokio::fs::read_to_string(events_path).await.ok()?;
parse_memory_events_high(&contents)
}
}
impl Drop for MemoryHighMonitor {
fn drop(&mut self) {
self._handle.abort();
}
}
}
// ============================================================================
// Cross-platform re-exports
// ============================================================================
/// Cgroup handle — owns the child cgroup's lifecycle.
///
/// On Linux, this creates a real cgroupv2 directory with memory limits.
/// On other platforms, this is a no-op.
pub struct CgroupGuard {
#[cfg(target_os = "linux")]
inner: Option<linux::CgroupHandle>,
}
impl CgroupGuard {
/// Try to create a cgroup with the given memory config.
/// Returns a guard that cleans up the cgroup on drop.
///
/// On non-Linux platforms this always returns a no-op guard.
/// On Linux, if cgroup creation fails (e.g., not running as root,
/// cgroupv2 not available), it logs a warning and returns a no-op guard.
pub async fn try_create(config: &CgroupMemoryConfig) -> Self {
#[cfg(target_os = "linux")]
{
match linux::CgroupHandle::create(config).await {
Ok(handle) => CgroupGuard {
inner: Some(handle),
},
Err(e) => {
tracing::warn!("Failed to create cgroup (falling back to no limits): {e}");
CgroupGuard { inner: None }
}
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = config;
CgroupGuard {}
}
}
/// No-op guard with no backing cgroup.
pub fn noop() -> Self {
#[cfg(target_os = "linux")]
{
CgroupGuard { inner: None }
}
#[cfg(not(target_os = "linux"))]
{
CgroupGuard {}
}
}
/// Move a process into this cgroup by PID.
/// No-op if cgroup was not created.
pub async fn add_process(&self, _pid: u32) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
if let Some(ref handle) = self.inner {
return handle.add_process(_pid).await;
}
}
Ok(())
}
/// Returns the cgroup filesystem path, if available.
#[allow(dead_code)]
pub fn path(&self) -> Option<&std::path::Path> {
#[cfg(target_os = "linux")]
{
if let Some(ref handle) = self.inner {
return Some(handle.path());
}
}
None
}
/// Returns true if this guard has a real cgroup backing it.
pub fn is_active(&self) -> bool {
#[cfg(target_os = "linux")]
{
self.inner.is_some()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
}
/// Memory-high monitor — watches for memory pressure events.
///
/// On Linux, uses inotify on `memory.events`.
/// On other platforms, this is a no-op that never fires.
pub struct MemoryMonitor {
#[cfg(target_os = "linux")]
inner: Option<linux::MemoryHighMonitor>,
}
impl MemoryMonitor {
/// Start monitoring the given cgroup guard for memory.high events.
/// Returns a no-op monitor if the guard has no backing cgroup.
pub async fn start(
guard: &CgroupGuard,
config: &CgroupMemoryConfig,
use_spawn_local: bool,
) -> Self {
#[cfg(target_os = "linux")]
{
if let Some(ref handle) = guard.inner {
match linux::MemoryHighMonitor::start(
handle.path().to_path_buf(),
config.memory_high_bytes,
use_spawn_local,
)
.await
{
Ok(monitor) => {
return MemoryMonitor {
inner: Some(monitor),
};
}
Err(e) => {
tracing::warn!("Failed to start memory monitor: {e}");
}
}
}
MemoryMonitor { inner: None }
}
#[cfg(not(target_os = "linux"))]
{
let _ = (guard, config, use_spawn_local);
MemoryMonitor {}
}
}
/// No-op monitor that never fires.
pub fn noop() -> Self {
#[cfg(target_os = "linux")]
{
MemoryMonitor { inner: None }
}
#[cfg(not(target_os = "linux"))]
{
MemoryMonitor {}
}
}
/// Non-blocking poll: returns `Some(event)` if memory.high was breached
/// since the last call, `None` otherwise.
pub fn try_recv(&mut self) -> Option<MemoryHighEvent> {
#[cfg(target_os = "linux")]
{
if let Some(ref mut monitor) = self.inner {
return monitor.try_recv();
}
}
None
}
}
@@ -0,0 +1,805 @@
//! Shadow `find`→`bfs` and `grep`→`ugrep` when those binaries resolve.
//!
//! Per-tool enable state (default on) is resolved by the host via the shared
//! config helper `kigi-shell::util::config::resolve_search_tools_enabled`
//! (requirements > env `KIGI_TOOLS_FIND_BFS` / `KIGI_TOOLS_GREP_UGREP` (+
//! `KIGI_FIND_BFS` / `KIGI_GREP_UGREP` aliases, `DISABLE_EMBEDDED_SEARCH_TOOLS`
//! master) > `[toolset.bash]` config.toml > managed > default), baked into the
//! `LocalTerminalBackend` as a [`SearchShadowConfig`] and passed to
//! [`search_injection`] per command. The enable state lives on the backend (not
//! a process-global): a subagent that reuses the parent's backend inherits the
//! parent's shadows instead of clobbering a shared static. This module no longer
//! parses the flags itself.
//!
//! Resolve (host side, memoized): env override if a regular file → bundled binary
//! (release builds, self-extracted to `~/.kigi/vendor/<name>-<ver>-<target>`) →
//! `~/.kigi/vendor/{name}` if a regular file → `which` on the agent `$PATH`.
//! Env/vendor only require `is_file()` as a lenient hint (no `--version` probe).
//! This memoized path is only a *hint*: the injected shadow re-resolves at
//! **call time** — it uses the hint when it's still *executable* (`[ -x ]`), else
//! `command -v {bin}` on the live shell `PATH` (which includes login/rc additions
//! the agent process may lack), else falls back to the OS `{name}`. So a removed
//! or non-executable binary self-heals to OS `find`/`grep`, and a binary
//! reachable only through the login shell is still found.
//!
//! Inject is **always** non-empty on Unix callers: either install a shadow
//! function (which tags itself with a `__grok_shadow_{name}` marker) or a
//! marker-gated `unalias`+`unset -f` that drops *only* a prior harness shadow —
//! never a user-defined `find`/`grep` function replayed from the snapshot.
use super::SearchShadowConfig;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// BRE + ignore-files; recursive flags are no-ops on stdin (computer parity).
const UGREP_DEFAULT_ARGS: &[&str] = &[
"-G",
"--ignore-files",
"--hidden",
"-I",
"--exclude-dir=.git",
"--exclude-dir=.svn",
"--exclude-dir=.hg",
"--exclude-dir=.bzr",
"--exclude-dir=.jj",
"--exclude-dir=.sl",
];
// Binaries embedded by build.rs when `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` is set
// (release pipeline). Self-extracted to `~/.kigi/vendor` on first use, mirroring
// the ripgrep bundling in `grok_build::grep::ripgrep`.
#[cfg(bundle_bfs)]
const BFS_BYTES: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/bundle-bfs/bfs-",
env!("KIGI_TOOLS_BFS_VER"),
"-",
env!("KIGI_TOOLS_BFS_TARGET"),
".bin"
));
#[cfg(bundle_ugrep)]
const UGREP_BYTES: &[u8] = include_bytes!(concat!(
env!("OUT_DIR"),
"/bundle-ugrep/ugrep-",
env!("KIGI_TOOLS_UGREP_VER"),
"-",
env!("KIGI_TOOLS_UGREP_TARGET"),
".bin"
));
/// Oneline inject for shell wrappers; always ends with `"; "`.
///
/// `cfg` is the backend's resolved per-tool enable state (see module docs); it
/// is passed in per command rather than read from a process-global so subagents
/// sharing a backend can't clobber each other's shadows.
pub fn search_injection(cfg: SearchShadowConfig) -> String {
build_injection(cfg.find_bfs, cfg.grep_ugrep, resolved_tools())
}
/// Compose the inject from per-tool enable flags + resolved binaries. An enabled
/// tool installs a self-resolving shadow (the memoized `tools` path is only a
/// fast-path hint; the shadow re-resolves at call time and falls back to the OS
/// binary — see [`shell_function`]). A disabled tool emits a marker-gated
/// `restore` that drops only a prior harness shadow. Kept pure (flags/tools
/// passed in) so tests need no process-global env mutation — that is UB against
/// the `shell_state` integration tests that read env / spawn children
/// concurrently.
fn build_injection(find_on: bool, grep_on: bool, tools: &ResolvedTools) -> String {
let find = if find_on {
shell_function("find", "bfs", tools.bfs.as_deref(), &[])
} else {
restore_command("find")
};
let grep = if grep_on {
shell_function("grep", "ugrep", tools.ugrep.as_deref(), UGREP_DEFAULT_ARGS)
} else {
restore_command("grep")
};
format!("{find}; {grep}; ")
}
/// Drop a *previously installed harness* shadow so command-word `{name}` uses the
/// OS binary again. Gated on the `__grok_shadow_{name}` marker that
/// [`shell_function`] sets, so a user-defined `{name}` function replayed from the
/// shell snapshot is left intact — only the harness's own shadow is removed.
/// `set -u`/`set -e` safe and idempotent (`unset -f` is bash + zsh).
fn restore_command(name: &str) -> String {
format!(
"if [ -n \"${{__grok_shadow_{name}-}}\" ]; then \
unalias {name} 2>/dev/null || true; \
unset -f {name} 2>/dev/null || true; \
unset __grok_shadow_{name} 2>/dev/null || true; \
fi"
)
}
struct ResolvedTools {
bfs: Option<PathBuf>,
ugrep: Option<PathBuf>,
}
fn resolved_tools() -> &'static ResolvedTools {
static TOOLS: OnceLock<ResolvedTools> = OnceLock::new();
TOOLS.get_or_init(|| ResolvedTools {
bfs: resolve_tool("bfs", "KIGI_TOOLS_BFS_PATH", bundled_bfs()),
ugrep: resolve_tool("ugrep", "KIGI_TOOLS_UGREP_PATH", bundled_ugrep()),
})
}
/// Write embedded `bytes` to `~/.kigi/vendor/<versioned_name>` (chmod 755) on
/// first use and return the path; reused on later runs. Versioned so bumping the
/// bundled version writes a fresh file instead of reusing a stale one.
#[cfg(any(bundle_bfs, bundle_ugrep))]
fn extract_bundled(versioned_name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let dir = crate::util::kigi_home().join("vendor");
let dest = dir.join(versioned_name);
if !dest.exists() {
std::fs::create_dir_all(&dir)?;
// Write to a unique temp then atomically rename, so a concurrent first
// use (or an interrupted write) can't leave a half-written binary that
// gets cached and exec'd.
let tmp = dir.join(format!(
"{versioned_name}.tmp.{}.{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::write(&tmp, bytes)?;
let mut perms = std::fs::metadata(&tmp)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tmp, perms)?;
// Rename is atomic on the same filesystem. If another process won the
// race, `dest` already exists and is correct — drop our temp copy.
if let Err(e) = std::fs::rename(&tmp, &dest) {
let _ = std::fs::remove_file(&tmp);
if !dest.exists() {
return Err(e);
}
}
}
Ok(dest)
}
/// Path to the bundled `bfs` (extracted on first use), or `None` when not bundled.
fn bundled_bfs() -> Option<PathBuf> {
#[cfg(bundle_bfs)]
{
extract_bundled(
concat!(
"bfs-",
env!("KIGI_TOOLS_BFS_VER"),
"-",
env!("KIGI_TOOLS_BFS_TARGET")
),
BFS_BYTES,
)
.ok()
}
#[cfg(not(bundle_bfs))]
{
None
}
}
/// Path to the bundled `ugrep` (extracted on first use), or `None` when not bundled.
fn bundled_ugrep() -> Option<PathBuf> {
#[cfg(bundle_ugrep)]
{
extract_bundled(
concat!(
"ugrep-",
env!("KIGI_TOOLS_UGREP_VER"),
"-",
env!("KIGI_TOOLS_UGREP_TARGET")
),
UGREP_BYTES,
)
.ok()
}
#[cfg(not(bundle_ugrep))]
{
None
}
}
fn resolve_tool(bin_name: &str, env_override: &str, bundled: Option<PathBuf>) -> Option<PathBuf> {
resolve_tool_from(
std::env::var_os(env_override).map(PathBuf::from),
bundled,
crate::util::kigi_home().join("vendor").join(bin_name),
bin_name,
)
}
/// Resolution order: explicit env path → bundled (self-extracted) →
/// `~/.kigi/vendor/<bin>` → `which`. Env and vendor only require `is_file()` here
/// (a lenient hint, no `+x` probe) so an odd-permission copy still resolves; the
/// injected shadow gates on `[ -x ]` at call time and falls back to the OS binary
/// if the hint isn't executable, so a non-exec path can't hard-fail `find`/`grep`.
fn resolve_tool_from(
env_path: Option<PathBuf>,
bundled: Option<PathBuf>,
vendor: PathBuf,
bin_name: &str,
) -> Option<PathBuf> {
if let Some(path) = env_path
&& path.is_file()
{
return Some(path);
}
if let Some(path) = bundled {
return Some(path);
}
if vendor.is_file() {
return Some(vendor);
}
which::which(bin_name).ok()
}
fn bash_safe_quote(s: &str) -> String {
if s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '=' | ':' | '@' | '+' | '-')
}) {
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\\''"))
}
/// Oneline `name() { … }` for `-c` inject — a *self-resolving* shadow.
///
/// At call time it picks the binary: the host-resolved `preferred` path
/// (bundled/env/vendor/which) when that file still exists, else `command -v
/// {bin_name}` on the live shell `PATH` (which carries login/rc additions the
/// agent process may not have), else it falls back to the OS `{name}`. This keeps
/// the fast hard-coded path for the common case while self-healing when the
/// binary was removed (revalidation) or is only reachable through the shell's
/// richer `PATH`.
///
/// `exec -a` runs inside a subshell so a top-level call can't replace the wrapper
/// shell (it must survive to dump state); a call already inside a subshell
/// (`BASH_SUBSHELL > 0`, bash) execs directly to skip a fork. `${ZSH_VERSION-}`
/// keeps the probe `set -u`-safe (a bare `$ZSH_VERSION` aborts bash under
/// nounset). `exec -a` gives the binary the `find`/`grep` argv0 (ps display +
/// ugrep grep-personality) in both bash and zsh. The trailing
/// `__grok_shadow_{name}=1` marks this as a harness shadow so `restore_command`
/// only ever removes our own function — never a user's.
fn shell_function(
name: &str,
bin_name: &str,
preferred: Option<&Path>,
prepend_args: &[&str],
) -> String {
let qpref = preferred
.map(|p| bash_safe_quote(&p.to_string_lossy()))
.unwrap_or_else(|| "''".to_string());
let prepend = {
let qargs: Vec<String> = prepend_args.iter().map(|a| bash_safe_quote(a)).collect();
if qargs.is_empty() {
String::new()
} else {
format!("{} ", qargs.join(" "))
}
};
// `local __grok_bin` is re-resolved every call. The host hint is trusted
// only when it's *executable* (`[ -x ]`, not just `[ -f ]`): the resolver
// accepts any regular file as a hint, but `exec` needs `+x`, so a non-exec
// hint must fall through rather than hard-fail with no OS fallback. Then
// `command -v` on the live shell PATH (returns an executable), else the OS
// binary. `|| __grok_bin=''` keeps the lookup `set -e`-safe (a failed
// `command -v` would otherwise abort the function under errexit). The OS
// fallback uses `command {name}` to bypass this function. `{prepend}` is
// empty for find, the ugrep default flags for grep (and is omitted from the
// OS fallback, which gets the original args).
format!(
"unalias {name} 2>/dev/null || true; \
{name}() {{ \
local __grok_bin={qpref}; \
[ -x \"$__grok_bin\" ] || __grok_bin=$(command -v {bin_name} 2>/dev/null) || __grok_bin=''; \
if [ -z \"$__grok_bin\" ]; then command {name} \"$@\"; return; fi; \
if [[ -z ${{ZSH_VERSION-}} ]] && (( BASH_SUBSHELL > 0 )); then \
exec -a {name} \"$__grok_bin\" {prepend}\"$@\"; \
else \
(exec -a {name} \"$__grok_bin\" {prepend}\"$@\"); \
fi; \
}}; \
__grok_shadow_{name}=1"
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Both binaries resolved, for `build_injection` shape tests.
fn both_tools() -> ResolvedTools {
ResolvedTools {
bfs: Some(PathBuf::from("/tmp/bfs")),
ugrep: Some(PathBuf::from("/tmp/ugrep")),
}
}
#[test]
fn shell_function_shape() {
let fn_body = shell_function("find", "bfs", Some(Path::new("/tmp/bfs")), &[]);
assert!(fn_body.contains("unalias find"));
// Preferred path is the fast-path hint; the shadow execs `$__grok_bin`.
assert!(fn_body.contains("local __grok_bin=/tmp/bfs"));
assert!(fn_body.contains("exec -a find \"$__grok_bin\" \"$@\""));
// Hint is trusted only when executable (`[ -x ]`, not `[ -f ]`), so a
// non-exec hint falls through instead of hard-failing exec.
assert!(fn_body.contains("[ -x \"$__grok_bin\" ]"));
assert!(!fn_body.contains("[ -f \"$__grok_bin\" ]"));
// Self-heal: live-PATH lookup + OS fallback.
assert!(fn_body.contains("command -v bfs"));
assert!(fn_body.contains("command find \"$@\""));
assert!(fn_body.contains("BASH_SUBSHELL > 0"));
assert!(fn_body.contains("(exec -a find"));
// Marker so `restore_command` only removes our own shadow.
assert!(fn_body.contains("__grok_shadow_find=1"));
// set -u-safe zsh probe (a bare $ZSH_VERSION aborts bash under nounset).
assert!(fn_body.contains("${ZSH_VERSION-}"));
assert!(!fn_body.contains("[[ -n $ZSH_VERSION ]]"));
}
#[test]
fn shell_function_unresolved_uses_empty_hint() {
// No host-resolved path → empty hint, relies on live-PATH `command -v`.
let fn_body = shell_function("find", "bfs", None, &[]);
assert!(fn_body.contains("local __grok_bin=''"));
assert!(fn_body.contains("command -v bfs"));
assert!(fn_body.contains("command find \"$@\""));
}
#[test]
fn grep_prepends_ugrep_defaults() {
let fn_body = shell_function(
"grep",
"ugrep",
Some(Path::new("/tmp/ugrep")),
UGREP_DEFAULT_ARGS,
);
assert!(fn_body.contains("local __grok_bin=/tmp/ugrep"));
assert!(fn_body.contains("\"$__grok_bin\" -G --ignore-files --hidden -I"));
assert!(fn_body.contains("--exclude-dir=.git"));
assert!(fn_body.contains("command -v ugrep"));
}
#[test]
fn bash_safe_quote_escapes_metacharacters() {
assert_eq!(bash_safe_quote("/usr/bin/bfs"), "/usr/bin/bfs");
assert_eq!(bash_safe_quote("/tmp/my bfs"), "'/tmp/my bfs'");
let body = shell_function("find", "bfs", Some(Path::new("/tmp/evil$(id)")), &[]);
assert!(body.contains("local __grok_bin='/tmp/evil$(id)'"), "{body}");
assert!(!body.contains("=/tmp/evil$(id)"));
}
#[test]
fn restore_command_is_marker_gated() {
let r = restore_command("find");
// Only removes the harness shadow when our marker is set.
assert!(r.contains("if [ -n \"${__grok_shadow_find-}\" ]"));
assert!(r.contains("unalias find"));
assert!(r.contains("unset -f find"));
assert!(r.contains("unset __grok_shadow_find"));
}
#[test]
fn config_default_is_both_on() {
// Standalone/no-host backends default to shadowing both tools.
let cfg = SearchShadowConfig::default();
assert!(cfg.find_bfs);
assert!(cfg.grep_ugrep);
}
#[test]
fn build_injection_off_emits_marker_gated_restore_not_function() {
// Disabled tools emit a marker-gated restore so a stale harness shadow
// from a prior snapshot is dropped, but a user function is left intact.
let inject = build_injection(false, false, &both_tools());
assert!(inject.ends_with("; "));
assert!(inject.contains("if [ -n \"${__grok_shadow_find-}\" ]"));
assert!(inject.contains("if [ -n \"${__grok_shadow_grep-}\" ]"));
assert!(inject.contains("unset -f find"));
assert!(inject.contains("unset -f grep"));
assert!(!inject.contains("find()"));
assert!(!inject.contains("grep()"));
}
#[test]
fn build_injection_on_shadows_resolved_tools() {
let inject = build_injection(true, true, &both_tools());
assert!(inject.contains("find()"));
assert!(inject.contains("grep()"));
assert!(inject.contains("-G --ignore-files"));
assert!(inject.contains("__grok_shadow_find=1"));
}
#[test]
fn build_injection_enabled_unresolved_still_self_heals() {
// Enabled but no host-resolved path → still install a self-resolving
// shadow (live-PATH `command -v` + OS fallback), never a bare restore.
let tools = ResolvedTools {
bfs: None,
ugrep: None,
};
let inject = build_injection(true, true, &tools);
assert!(inject.contains("find()"));
assert!(inject.contains("grep()"));
assert!(inject.contains("command -v bfs"));
assert!(inject.contains("command -v ugrep"));
// OS fallback present; not a marker-gated restore.
assert!(inject.contains("command find \"$@\""));
assert!(!inject.contains("if [ -n \"${__grok_shadow_find-}\" ]"));
}
#[test]
fn injection_always_nonempty_and_trailing_sep() {
// Structural invariants that hold regardless of host flags/binaries:
// never empty (so a stale snapshot shadow is always overwritten) and a
// trailing `"; "` separator before the user command.
for cfg in [
SearchShadowConfig {
find_bfs: true,
grep_ugrep: true,
},
SearchShadowConfig {
find_bfs: false,
grep_ugrep: false,
},
] {
let inject = search_injection(cfg);
assert!(!inject.is_empty());
assert!(inject.ends_with("; "));
assert!(!inject.contains('\n'));
}
}
#[test]
fn env_override_accepts_regular_file_without_exec_bit() {
let bin = std::env::temp_dir().join(format!(
"grok-bfs-noexec-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&bin, b"#!/bin/sh\necho ok\n").unwrap();
// Mode 0o644 — no execute bit; Nix-like / restricted copies.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&bin, perms).unwrap();
}
// Bundled + vendor intentionally absent so the env override is what wins.
let got = resolve_tool_from(
Some(bin.clone()),
None,
PathBuf::from("/nonexistent/vendor/bfs"),
"bfs",
);
let _ = std::fs::remove_file(&bin);
assert_eq!(got.as_deref(), Some(bin.as_path()));
}
#[test]
fn resolve_tool_precedence() {
// Real temp files so the is_file() checks pass.
let dir = std::env::temp_dir().join(format!("grok-resolve-{}-{:?}", std::process::id(), {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
}));
std::fs::create_dir_all(&dir).unwrap();
let envp = dir.join("env");
let bundled = dir.join("bundled");
let vendor = dir.join("vendor");
for p in [&envp, &bundled, &vendor] {
std::fs::write(p, b"x").unwrap();
}
// env override beats everything.
assert_eq!(
resolve_tool_from(
Some(envp.clone()),
Some(bundled.clone()),
vendor.clone(),
"bfs"
)
.as_deref(),
Some(envp.as_path())
);
// bundled beats the manual vendor copy.
assert_eq!(
resolve_tool_from(None, Some(bundled.clone()), vendor.clone(), "bfs").as_deref(),
Some(bundled.as_path())
);
// vendor used when neither env nor bundled is present.
assert_eq!(
resolve_tool_from(None, None, vendor.clone(), "bfs").as_deref(),
Some(vendor.as_path())
);
// A non-file env override is ignored and falls through to vendor.
assert_eq!(
resolve_tool_from(Some(dir.join("missing")), None, vendor.clone(), "bfs").as_deref(),
Some(vendor.as_path())
);
let _ = std::fs::remove_dir_all(&dir);
}
/// Only compiled when the binaries are actually bundled (release pipeline, or
/// `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` at build time). Verifies the embedded
/// bytes self-extract under `~/.kigi/vendor` and the extracted `bfs` runs.
#[cfg(all(bundle_bfs, bundle_ugrep))]
#[test]
fn bundled_binaries_extract_and_run() {
let vendor = crate::util::kigi_home().join("vendor");
let bfs = bundled_bfs().expect("bfs should be bundled");
let ugrep = bundled_ugrep().expect("ugrep should be bundled");
assert!(bfs.is_file() && bfs.starts_with(&vendor), "bfs at {bfs:?}");
assert!(
ugrep.is_file() && ugrep.starts_with(&vendor),
"ugrep at {ugrep:?}"
);
let v = std::process::Command::new(&bfs)
.arg("--version")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&v.stdout)
.to_lowercase()
.contains("bfs"),
"bfs --version: {}",
String::from_utf8_lossy(&v.stdout)
);
}
/// Regression: a bare `$ZSH_VERSION` probe aborted the shadow under `set -u`
/// ("unbound variable"), killing find/grep and dropping the state dump. Runs
/// the generated function with `/bin/echo` standing in for the binary.
#[test]
fn shadow_runs_under_nounset_bash() {
let Ok(bash) = which::which("bash") else {
return;
};
let inject = shell_function("find", "bfs", Some(Path::new("/bin/echo")), &[]);
let script = format!("set -euo pipefail; {inject}; find hello world");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"find shadow aborted under set -u: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello world");
}
/// Command substitution runs with `BASH_SUBSHELL > 0`, taking the direct
/// `exec -a` branch; it must still forward args and succeed under `set -u`,
/// and the outer shell must survive (so its state dump can run).
#[test]
fn shadow_execs_directly_in_subshell_bash() {
let Ok(bash) = which::which("bash") else {
return;
};
let inject = shell_function("find", "bfs", Some(Path::new("/bin/echo")), &[]);
let script =
format!("set -euo pipefail; {inject}; printf '[%s]' \"$(find sub shell)\"; echo ALIVE");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout), "[sub shell]ALIVE\n");
}
/// zsh takes the subshell `exec -a` branch (consistent argv0 with bash); it
/// must run under nounset without referencing the bash-only `BASH_SUBSHELL`.
#[test]
fn shadow_runs_under_nounset_zsh() {
let Ok(zsh) = which::which("zsh") else {
return;
};
let inject = shell_function("grep", "ugrep", Some(Path::new("/bin/echo")), &[]);
let script = format!("setopt nounset errexit pipefail; {inject}; grep hi there");
let out = std::process::Command::new(&zsh)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"grep shadow aborted in zsh: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi there");
}
/// #3 regression: a user-defined `find` must survive a disabled-tool restore.
/// Without the marker gate, the unconditional `unset -f find` dropped it.
#[test]
fn restore_preserves_user_function_without_marker() {
let Ok(bash) = which::which("bash") else {
return;
};
let restore = restore_command("find");
let script =
format!("set -euo pipefail; find() {{ echo USERFIND; }}; {restore}; find anything");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "USERFIND");
}
/// The marker-gated restore *does* drop a prior harness shadow (which set the
/// marker), so a flipped-off tool falls back to the OS binary.
#[test]
fn restore_removes_harness_shadow_with_marker() {
let Ok(bash) = which::which("bash") else {
return;
};
let shadow = shell_function("find", "bfs", Some(Path::new("/bin/echo")), &[]);
let restore = restore_command("find");
let script = format!("set -euo pipefail; {shadow}; {restore}; type -t find || echo NOFUNC");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.lines().any(|l| l.trim() == "function"),
"find should no longer be a function after restore: {stdout:?}"
);
}
/// #1 + #4: the host hint points at a missing file, but the binary is on the
/// live shell `PATH` — the shadow re-resolves via `command -v` at call time.
#[test]
fn shadow_self_heals_via_path_lookup() {
let Ok(bash) = which::which("bash") else {
return;
};
let dir = std::env::temp_dir().join(format!(
"grok-selfheal-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("myfind");
std::fs::write(&bin, "#!/bin/sh\necho SELFHEAL \"$@\"\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin, perms).unwrap();
}
// Hint is a nonexistent path; `myfind` is only reachable via PATH.
let shadow = shell_function("find", "myfind", Some(Path::new("/nonexistent/bfs")), &[]);
let script = format!(
"set -euo pipefail; export PATH={}:\"$PATH\"; {shadow}; find X",
bash_safe_quote(&dir.to_string_lossy())
);
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "SELFHEAL X");
}
/// #1 fallback: neither the hint nor `command -v` resolves → the OS binary
/// runs (via `command find`), so the shadow never breaks `find`.
#[test]
fn shadow_falls_back_to_os_when_binary_absent() {
let Ok(bash) = which::which("bash") else {
return;
};
let shadow = shell_function(
"find",
"grok_no_such_search_bin_xyz",
Some(Path::new("/nonexistent/bfs")),
&[],
);
// `find /dev/null` prints the path on every find implementation.
let script = format!("set -euo pipefail; {shadow}; find /dev/null");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "/dev/null");
}
/// #1 regression: a host hint that exists but is **not executable** (e.g. a
/// mode-0644 `KIGI_TOOLS_*_PATH` / vendor copy) must fall through to the OS
/// binary rather than hard-fail `exec` with EACCES. The `[ -x ]` guard (not
/// `[ -f ]`) is what makes this work.
#[test]
fn shadow_falls_back_when_hint_not_executable() {
let Ok(bash) = which::which("bash") else {
return;
};
let dir = std::env::temp_dir().join(format!(
"grok-noexec-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let hint = dir.join("bfs");
std::fs::write(&hint, "#!/bin/sh\necho SHOULD_NOT_RUN\n").unwrap();
{
// Mode 0644 — exists but not executable by anyone.
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hint).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&hint, perms).unwrap();
}
// Hint is the non-exec file; bin_name isn't on PATH → must reach OS find.
let shadow = shell_function(
"find",
"grok_no_such_search_bin_xyz",
Some(hint.as_path()),
&[],
);
let script = format!("set -euo pipefail; {shadow}; find /dev/null");
let out = std::process::Command::new(&bash)
.args(["-c", &script])
.output()
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
out.status.success(),
"non-exec hint should fall back to OS find, not fail: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim(), "/dev/null");
assert!(
!stdout.contains("SHOULD_NOT_RUN"),
"non-exec hint was run: {stdout:?}"
);
}
}
@@ -0,0 +1,344 @@
use std::{future::Future, io, path::Path, time::Duration};
use tokio::{fs, time::sleep};
use crate::computer::types::{AsyncFileSystem, ComputerError};
/// Creates a local FS access which allows writing and reading from the local files
pub struct LocalFs;
// Keep the window short: these retries absorb brief Windows editor/indexer/AV
// races without hiding persistent locks, ACL failures, or sandbox denials.
const WRITE_RETRY_DELAYS: &[Duration] = &[
Duration::from_millis(25),
Duration::from_millis(50),
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(400),
];
#[cfg(any(windows, test))]
const WINDOWS_ERROR_SHARING_VIOLATION: i32 = 32;
#[cfg(any(windows, test))]
const WINDOWS_ERROR_LOCK_VIOLATION: i32 = 33;
/// Check if an IO error is a permission denial (EACCES or EPERM),
/// which indicates a sandbox violation.
fn is_permission_error(e: &io::Error) -> bool {
matches!(e.kind(), io::ErrorKind::PermissionDenied)
}
#[cfg(any(windows, test))]
fn is_windows_transient_write_lock_raw_os_error(raw_os_error: Option<i32>) -> bool {
matches!(
raw_os_error,
Some(WINDOWS_ERROR_SHARING_VIOLATION | WINDOWS_ERROR_LOCK_VIOLATION)
)
}
fn is_transient_write_lock_error(e: &io::Error) -> bool {
#[cfg(windows)]
{
is_windows_transient_write_lock_raw_os_error(e.raw_os_error())
}
#[cfg(not(windows))]
{
let _ = e;
false
}
}
#[cfg(test)]
fn is_test_transient_write_lock_error(e: &io::Error) -> bool {
is_windows_transient_write_lock_raw_os_error(e.raw_os_error())
}
async fn write_file_with_transient_lock_retries(path: &Path, data: &[u8]) -> io::Result<()> {
write_file_with_retry_hooks(
|| fs::write(path, data),
|delay| sleep(delay),
|retry_count| {
tracing::debug!(
path = %path.display(),
retry_count,
"file write succeeded after transient lock retries"
);
},
|error, retry_count, delay| {
tracing::debug!(
path = %path.display(),
error = %error,
retry_count,
delay_ms = delay.as_millis(),
"file write hit transient lock; retrying"
);
},
|error, retry_count| {
tracing::debug!(
path = %path.display(),
error = %error,
retry_count,
"file write exhausted transient lock retries"
);
},
is_transient_write_lock_error,
)
.await
}
async fn write_file_with_retry_hooks<W, WFut, S, SFut, Success, Retry, Exhausted, IsRetryable>(
mut write: W,
mut sleep_for: S,
mut on_retry_success: Success,
mut on_retry: Retry,
mut on_exhausted: Exhausted,
is_retryable: IsRetryable,
) -> io::Result<()>
where
W: FnMut() -> WFut,
WFut: Future<Output = io::Result<()>>,
S: FnMut(Duration) -> SFut,
SFut: Future<Output = ()>,
Success: FnMut(usize),
Retry: FnMut(&io::Error, usize, Duration),
Exhausted: FnMut(&io::Error, usize),
IsRetryable: Fn(&io::Error) -> bool,
{
let mut retry_count = 0usize;
loop {
match write().await {
Ok(()) => {
if retry_count > 0 {
on_retry_success(retry_count);
}
return Ok(());
}
Err(e) if is_retryable(&e) => {
if retry_count >= WRITE_RETRY_DELAYS.len() {
on_exhausted(&e, retry_count);
return Err(e);
}
let delay = WRITE_RETRY_DELAYS[retry_count];
retry_count += 1;
on_retry(&e, retry_count, delay);
sleep_for(delay).await;
}
Err(e) => return Err(e),
}
}
}
#[async_trait::async_trait]
impl AsyncFileSystem for LocalFs {
#[tracing::instrument(name = "fs.read_file", skip_all)]
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError> {
match fs::read(path).await {
Ok(data) => Ok(data),
Err(e) => {
if is_permission_error(&e) {
kigi_sandbox::log_violation(&path.display().to_string(), "read");
}
Err(e.into())
}
}
}
#[tracing::instrument(name = "fs.write_file", skip_all)]
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError> {
// implicitly creates the missing directories if any
if let Some(dir) = path.parent()
&& let Err(e) = fs::create_dir_all(dir).await
{
if is_permission_error(&e) {
kigi_sandbox::log_violation(&dir.display().to_string(), "mkdir");
}
return Err(e.into());
}
if let Err(e) = write_file_with_transient_lock_retries(path, data).await {
if is_permission_error(&e) {
kigi_sandbox::log_violation(&path.display().to_string(), "write");
}
return Err(e.into());
}
Ok(())
}
#[tracing::instrument(name = "fs.delete_file", skip_all)]
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError> {
if let Err(e) = fs::remove_file(path).await {
if is_permission_error(&e) {
kigi_sandbox::log_violation(&path.display().to_string(), "delete");
}
return Err(e.into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::rc::Rc;
#[test]
fn classifies_windows_transient_write_lock_errors() {
assert!(is_windows_transient_write_lock_raw_os_error(Some(32)));
assert!(is_windows_transient_write_lock_raw_os_error(Some(33)));
assert!(!is_windows_transient_write_lock_raw_os_error(Some(5)));
assert!(!is_windows_transient_write_lock_raw_os_error(None));
}
#[cfg(windows)]
#[test]
fn classifies_windows_transient_write_lock_io_errors() {
assert!(is_transient_write_lock_error(
&io::Error::from_raw_os_error(WINDOWS_ERROR_SHARING_VIOLATION,)
));
assert!(is_transient_write_lock_error(
&io::Error::from_raw_os_error(WINDOWS_ERROR_LOCK_VIOLATION,)
));
assert!(!is_transient_write_lock_error(
&io::Error::from_raw_os_error(5)
));
}
#[cfg(not(windows))]
#[test]
fn non_windows_does_not_retry_windows_raw_error_numbers() {
assert!(!is_transient_write_lock_error(
&io::Error::from_raw_os_error(WINDOWS_ERROR_SHARING_VIOLATION,)
));
}
#[tokio::test]
async fn first_attempt_success_does_not_fire_retry_callbacks() {
let result = write_file_with_retry_hooks(
|| async { Ok(()) },
|_| async { panic!("should not sleep") },
|_| panic!("should not fire on_retry_success"),
|_, _, _| panic!("should not fire on_retry"),
|_, _| panic!("should not fire on_exhausted"),
is_test_transient_write_lock_error,
)
.await;
result.unwrap();
}
#[tokio::test]
async fn transient_lock_errors_are_retried_until_success() {
let attempts = Rc::new(Cell::new(0usize));
let sleeps = Rc::new(Cell::new(0usize));
let retry_success_count = Rc::new(Cell::new(0usize));
let retry_log_count = Rc::new(Cell::new(0usize));
let result = write_file_with_retry_hooks(
{
let attempts = Rc::clone(&attempts);
move || {
let attempts = Rc::clone(&attempts);
async move {
let next = attempts.get() + 1;
attempts.set(next);
if next <= 2 {
Err(io::Error::from_raw_os_error(
WINDOWS_ERROR_SHARING_VIOLATION,
))
} else {
Ok(())
}
}
}
},
{
let sleeps = Rc::clone(&sleeps);
move |_| {
let sleeps = Rc::clone(&sleeps);
async move {
sleeps.set(sleeps.get() + 1);
}
}
},
|count| retry_success_count.set(count),
|_, _, _| retry_log_count.set(retry_log_count.get() + 1),
|_, _| panic!("retry budget should not be exhausted"),
is_test_transient_write_lock_error,
)
.await;
result.unwrap();
assert_eq!(attempts.get(), 3);
assert_eq!(sleeps.get(), 2);
assert_eq!(retry_log_count.get(), 2);
assert_eq!(retry_success_count.get(), 2);
}
#[tokio::test]
async fn non_transient_errors_are_not_retried() {
let attempts = Rc::new(Cell::new(0usize));
let result = write_file_with_retry_hooks(
{
let attempts = Rc::clone(&attempts);
move || {
let attempts = Rc::clone(&attempts);
async move {
attempts.set(attempts.get() + 1);
Err(io::Error::new(io::ErrorKind::NotFound, "missing"))
}
}
},
|_| async {},
|_| panic!("write did not succeed"),
|_, _, _| panic!("non-transient errors must not be retried"),
|_, _| panic!("non-transient errors must not exhaust retry budget"),
is_test_transient_write_lock_error,
)
.await;
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
assert_eq!(attempts.get(), 1);
}
#[tokio::test]
async fn persistent_transient_lock_exhausts_retry_budget() {
let attempts = Rc::new(Cell::new(0usize));
let sleeps = Rc::new(Cell::new(0usize));
let exhausted_count = Rc::new(Cell::new(0usize));
let result = write_file_with_retry_hooks(
{
let attempts = Rc::clone(&attempts);
move || {
let attempts = Rc::clone(&attempts);
async move {
attempts.set(attempts.get() + 1);
Err(io::Error::from_raw_os_error(WINDOWS_ERROR_LOCK_VIOLATION))
}
}
},
{
let sleeps = Rc::clone(&sleeps);
move |_| {
let sleeps = Rc::clone(&sleeps);
async move {
sleeps.set(sleeps.get() + 1);
}
}
},
|_| panic!("write did not succeed"),
|_, _, _| {},
|_, count| exhausted_count.set(count),
is_test_transient_write_lock_error,
)
.await;
assert_eq!(
result.unwrap_err().raw_os_error(),
Some(WINDOWS_ERROR_LOCK_VIOLATION)
);
assert_eq!(attempts.get(), WRITE_RETRY_DELAYS.len() + 1);
assert_eq!(sleeps.get(), WRITE_RETRY_DELAYS.len());
assert_eq!(exhausted_count.get(), WRITE_RETRY_DELAYS.len());
}
}
@@ -0,0 +1,122 @@
//! Mock file system implementation for testing.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::computer::types::{AsyncFileSystem, ComputerError};
/// In-memory file system for testing.
/// Thread-safe and async-compatible.
pub struct MockFs {
files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
}
impl Default for MockFs {
fn default() -> Self {
Self::new()
}
}
impl MockFs {
/// Create a new empty mock file system.
pub fn new() -> Self {
Self {
files: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Set a file's contents directly (for test setup).
pub async fn set_file(&self, path: impl AsRef<Path>, content: &[u8]) {
self.files
.write()
.await
.insert(path.as_ref().to_path_buf(), content.to_vec());
}
/// Get a file's contents directly (for test assertions).
pub async fn get_file(&self, path: impl AsRef<Path>) -> Option<Vec<u8>> {
self.files.read().await.get(path.as_ref()).cloned()
}
/// Check if a file exists.
pub async fn exists(&self, path: impl AsRef<Path>) -> bool {
self.files.read().await.contains_key(path.as_ref())
}
/// List all files in the mock filesystem.
pub async fn list_files(&self) -> Vec<PathBuf> {
self.files.read().await.keys().cloned().collect()
}
}
#[async_trait::async_trait]
impl AsyncFileSystem for MockFs {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError> {
self.files.read().await.get(path).cloned().ok_or_else(|| {
ComputerError::IOError(
format!("File not found: {}", path.display()),
Some(std::io::ErrorKind::NotFound),
)
})
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError> {
self.files
.write()
.await
.insert(path.to_path_buf(), data.to_vec());
Ok(())
}
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError> {
self.files.write().await.remove(path);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_fs_read_write() {
let fs = MockFs::new();
// File doesn't exist initially
assert!(fs.read_file(Path::new("/test.txt")).await.is_err());
// Write a file
fs.write_file(Path::new("/test.txt"), b"hello world")
.await
.unwrap();
// Read it back
let content = fs.read_file(Path::new("/test.txt")).await.unwrap();
assert_eq!(content, b"hello world");
}
#[tokio::test]
async fn test_mock_fs_delete() {
let fs = MockFs::new();
fs.write_file(Path::new("/test.txt"), b"hello")
.await
.unwrap();
assert!(fs.exists(Path::new("/test.txt")).await);
fs.delete_file(Path::new("/test.txt")).await.unwrap();
assert!(!fs.exists(Path::new("/test.txt")).await);
}
#[tokio::test]
async fn test_mock_fs_set_file() {
let fs = MockFs::new();
fs.set_file("/preset.txt", b"preset content").await;
let content = fs.read_file(Path::new("/preset.txt")).await.unwrap();
assert_eq!(content, b"preset content");
}
}
@@ -0,0 +1,37 @@
pub mod cgroup;
#[cfg(unix)]
pub mod embedded_search_tools;
pub mod file_system;
pub mod mock_fs;
#[cfg(unix)]
pub mod shell_state;
pub mod terminal;
pub use cgroup::{CgroupMemoryConfig, PROCESS_OOM_EXIT_CODE};
pub use file_system::LocalFs;
pub use mock_fs::MockFs;
pub use terminal::{ExitStatus, LocalTerminalBackend};
/// Per-backend enable state for the bash-harness `find`→`bfs` / `grep`→`ugrep`
/// shadows.
///
/// Resolved once by the host (config.toml `[toolset.bash]` / env / requirements)
/// and baked into a [`LocalTerminalBackend`] at creation. Keeping it on the
/// backend instead of a process-global means a subagent that reuses the parent's
/// `LocalTerminalBackend` inherits the parent's shadows — it can't overwrite the
/// enable state for bash that later runs on the shared backend. Defaults to
/// both-on for standalone backends with no host wiring.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SearchShadowConfig {
pub find_bfs: bool,
pub grep_ugrep: bool,
}
impl Default for SearchShadowConfig {
fn default() -> Self {
Self {
find_bfs: true,
grep_ugrep: true,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
pub mod local;
/// Contains the computer implementation
pub mod types;
@@ -0,0 +1,422 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use crate::notification::types::ToolNotificationHandle;
// ============================================================================
// Error types
// ============================================================================
#[derive(thiserror::Error, Debug, Clone)]
pub enum ComputerError {
#[error("IO Error: {0}")]
IOError(String, Option<std::io::ErrorKind>),
#[error("UnQuoted command")]
CommandNotQuoted,
}
impl ComputerError {
/// Create an IO error from a message string with no preserved error kind.
pub fn io(msg: impl Into<String>) -> Self {
Self::IOError(msg.into(), None)
}
pub fn io_with_kind(msg: impl Into<String>, kind: std::io::ErrorKind) -> Self {
Self::IOError(msg.into(), Some(kind))
}
/// Returns the underlying `io::ErrorKind` or `None`
pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
match self {
Self::IOError(_, kind) => *kind,
_ => None,
}
}
}
impl From<std::io::Error> for ComputerError {
fn from(err: std::io::Error) -> Self {
Self::IOError(err.to_string(), Some(err.kind()))
}
}
// ============================================================================
// File system trait
// ============================================================================
#[async_trait::async_trait]
pub trait AsyncFileSystem: Send + Sync {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError>;
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError>;
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError>;
}
// ============================================================================
// Terminal types
// ============================================================================
pub struct TerminalRunRequest {
pub command: String,
pub working_directory: PathBuf,
pub env: HashMap<String, String>,
pub timeout: Duration,
pub output_byte_limit: usize,
/// File path to write output incrementally as it arrives.
/// This ensures full output is always available even after in-memory buffer is truncated.
/// For background tasks, this allows retrieval of output after the agent has moved on.
pub output_file: PathBuf,
/// Notification handle for streaming output chunks during execution.
/// The backend sends `BashOutputChunk` notifications every ~100ms.
/// Callers that don't need streaming pass `ToolNotificationHandle::noop()`
/// — messages are silently dropped. No `Option` wrapper needed.
pub notification_handle: ToolNotificationHandle,
/// Tool call ID for correlating notifications with the tool invocation.
/// Flows from `ToolContext::tool_call_id()` through the actor to
/// `BashOutputChunk.base.tool_call_id`.
pub tool_call_id: String,
/// Original user command before isolation wrapping.
///
/// When set, the terminal actor stores this on the `ProcessEntry` so
/// `get_task()` returns it in `TaskSnapshot.display_command`. This
/// ensures model-facing `get_task_output` shows the user's command
/// instead of the `unshare`/mount wrapper.
pub display_command: Option<String>,
/// Auto-background on timeout instead of killing (default `false`).
pub auto_background_on_timeout: bool,
/// When [`Self::auto_background_on_timeout`] is true, maximum time the
/// command may block the turn before being moved to the background (process
/// keeps running). Independent of [`Self::timeout`].
///
/// - `None` → use the terminal backend default (typically 15s).
/// - `Some(Duration::MAX)` → no short budget; auto-bg only when `timeout` elapses.
/// - `Some(d)` → auto-bg after `d` if still running.
pub foreground_block_budget: Option<Duration>,
/// Task kind for distinguishing monitor tasks from regular bash tasks.
pub kind: TaskKind,
/// Session that owns this process. Used to scope kill operations so
/// `kill_all_background_tasks_by_owner` only targets the requesting
/// session's processes — not the parent's or sibling's.
pub owner_session_id: Option<String>,
}
/// Distinguishes different types of background tasks.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum TaskKind {
/// Regular bash command.
#[default]
Bash,
/// Monitor tool — streams stdout events with rate limiting.
Monitor,
}
#[derive(Clone)]
pub struct TerminalRunResult {
pub combined_output: String,
pub exit_code: Option<i32>,
pub truncated: bool,
pub signal: Option<String>,
pub timed_out: bool,
/// Path to the output file where full output is stored.
/// Use read_file tool to retrieve full output when truncated.
pub output_file: PathBuf,
/// Total bytes of output (before truncation).
/// When truncated, combined_output contains the first and last portions up to output_byte_limit chars.
pub total_bytes: usize,
/// PID of the spawned shell process, when available. Set by the
/// local terminal backend at spawn time. Useful for foreground
/// commands that auto-background on timeout: the resulting
/// `BackgroundTaskStarted` can carry the real PID instead of a
/// placeholder. `None` for backends that cannot surface a local
/// PID (e.g. ACP/remote terminals) or when the process exited
/// before `child.id()` could be queried.
pub pid: Option<u32>,
}
/// Returned by `TerminalBackend::run_background` — gives the caller the task_id
/// to use for subsequent queries via `get_task`, `kill_task`, `wait_for_completion`.
pub struct BackgroundHandle {
pub task_id: String,
pub output_file: PathBuf,
/// PID of the spawned shell process, when available. `None` for
/// backends that do not surface a local PID (e.g. ACP/remote
/// gateways) or when the process exited before the PID could be
/// captured.
pub pid: Option<u32>,
}
/// Full snapshot of a task's state.
/// Used by both local and ACP backends.
#[derive(
Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct TaskSnapshot {
pub task_id: String,
/// The actual command that was executed (may be isolation-wrapped).
pub command: String,
/// The original user command before isolation wrapping.
///
/// When set, model/user-facing output should prefer this over `command`
/// to avoid exposing internal isolation mechanics (unshare/mount wrapper).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_command: Option<String>,
pub cwd: String,
pub start_time: std::time::SystemTime,
pub end_time: Option<std::time::SystemTime>,
pub output: String,
pub output_file: PathBuf,
pub truncated: bool,
pub exit_code: Option<i32>,
pub signal: Option<String>,
pub completed: bool,
/// Task kind: bash (default) or monitor.
#[serde(default)]
pub kind: TaskKind,
/// Whether a block-waiter (`block=true`) consumed this task's result.
/// When set, the notification bridge skips auto-wake synthetic prompts
/// because the blocking caller already received the result directly.
#[serde(default)]
pub block_waited: bool,
/// Whether this task was explicitly killed via the `kill_command_or_subagent` tool.
/// When set, auto-wake synthetic prompts are suppressed because the model
/// already received the kill result via `KillTaskResult`.
/// Also set during `kill_all_background_tasks` teardown (e.g. subagent
/// cleanup), where auto-wake suppression is irrelevant since the session
/// is shutting down.
#[serde(default)]
pub explicitly_killed: bool,
/// Session that owns this task. Used for scoped kill operations so
/// subagent teardown only kills the subagent's own tasks, not
/// the parent's or sibling's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_session_id: Option<String>,
}
impl TaskSnapshot {
/// Calculate duration in seconds.
/// If task is still running, returns time since start.
pub fn duration_secs(&self) -> f64 {
let end = self.end_time.unwrap_or_else(std::time::SystemTime::now);
end.duration_since(self.start_time)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// True iff the task has NOT yet completed — covers bash AND
/// monitor task kinds (the `kind` field doesn't change this
/// predicate; the runtime turn-end TodoGate counts both as
/// backing work).
pub fn is_outstanding(&self) -> bool {
!self.completed
}
}
/// Result of killing a terminal task.
///
/// Serialized over the wire in the `x.ai/task/kill` ext response
/// (`kigi-shell::extensions::task::KillTaskResponse`) and deserialized
/// by clients (kigi-tui), so it derives both serde directions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KillOutcome {
Killed,
AlreadyExited,
NotFound,
}
// ============================================================================
// TerminalBackend trait
// ============================================================================
/// The single abstraction over terminal execution backends.
///
/// Implemented by:
/// - `LocalTerminalBackend` (in kigi-tools, spawns processes)
/// - `AcpTerminalBackend` (in kigi-shell, calls ACP protocol)
#[async_trait::async_trait]
pub trait TerminalBackend: Send + Sync {
/// Run a command. Blocks until completion or timeout.
async fn run(&self, request: TerminalRunRequest) -> Result<TerminalRunResult, ComputerError>;
/// Start a command in the background. Returns immediately with a handle ID.
/// The process continues running; use get_task/kill_task/wait_for_completion to manage it.
async fn run_background(
&self,
request: TerminalRunRequest,
) -> Result<BackgroundHandle, ComputerError>;
/// Get current snapshot of a background task.
async fn get_task(&self, task_id: &str) -> Option<TaskSnapshot>;
/// Kill a background task.
async fn kill_task(&self, task_id: &str) -> KillOutcome;
/// Kill all running foreground processes.
async fn kill_foreground_commands(&self) {}
/// Kill all running foreground processes owned by a specific session.
/// Used on a shared terminal backend so a subagent's cancel doesn't
/// kill the parent's foreground commands.
async fn kill_foreground_commands_by_owner(&self, _owner_session_id: &str) {}
/// Kill all running background tasks.
/// Used during subagent teardown to clean up orphaned processes.
async fn kill_all_background_tasks(&self) {}
/// Kill all running background tasks owned by a specific session.
/// Used during subagent teardown on a shared terminal backend so
/// only the subagent's own tasks are killed — not the parent's.
async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {}
/// Fire-and-forget prewarm of the persistent login shell; default no-op for
/// backends without one (ACP/remote, non-persistent).
async fn warm_persistent_shell(&self, _cwd: &std::path::Path) {}
/// Reparent notification handles for all tasks owned by `old_owner_session_id`.
/// Swaps the dead child session's notification handle with the parent's
/// live handle so events from surviving processes route correctly.
/// Also re-spawns monitor pipelines on the caller's runtime so monitor
/// events continue streaming to the parent.
///
/// `backend_weak` is a [`Weak`](std::sync::Weak) to *this* backend (anchored
/// by the parent session's `Arc`); it drives re-spawned monitor pipelines
/// without keeping the backend alive. See `run_monitor_pipeline`.
async fn reparent_notifications(
&self,
_old_owner_session_id: &str,
_new_owner_session_id: &str,
_new_handle: crate::notification::types::ToolNotificationHandle,
_backend_weak: std::sync::Weak<dyn TerminalBackend>,
) {
}
/// Move a foreground command to background by tool_call_id.
/// The process keeps running but the foreground waiter is unblocked.
/// Returns `true` if a matching foreground process was found.
async fn background_foreground_command(&self, _tool_call_id: &str) -> bool {
false
}
/// Wait for a background task to complete, with optional timeout.
async fn wait_for_completion(
&self,
task_id: &str,
timeout: Option<Duration>,
) -> Option<TaskSnapshot>;
/// List all known background tasks (running and completed).
/// Used for context compaction to include task state in summaries.
async fn list_tasks(&self) -> Vec<TaskSnapshot>;
/// Return the persistent shell's current working directory, if persistent
/// shell state is enabled. Returns `None` when persistence is off or the
/// backend doesn't support it (e.g. ACP/remote).
async fn get_shell_cwd(&self) -> Option<std::path::PathBuf> {
None
}
}
// ============================================================================
// Computer struct
// ============================================================================
/// Contains the computer struct which provides access to both the terminal and the fs
pub struct Computer {
pub terminal: Arc<dyn TerminalBackend>,
pub file_system: Arc<dyn AsyncFileSystem>,
}
impl Computer {
pub fn new(terminal: Arc<dyn TerminalBackend>, file_system: Arc<dyn AsyncFileSystem>) -> Self {
Self {
terminal,
file_system,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn io_error_kind_preserved_through_from() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let ce = ComputerError::from(io_err);
assert_eq!(ce.io_error_kind(), Some(std::io::ErrorKind::NotFound));
}
#[test]
fn io_error_kind_is_a_directory() {
let io_err = std::io::Error::new(std::io::ErrorKind::IsADirectory, "it's a dir");
let ce = ComputerError::from(io_err);
assert_eq!(ce.io_error_kind(), Some(std::io::ErrorKind::IsADirectory));
}
#[test]
fn io_error_kind_none_for_string_constructor() {
let ce = ComputerError::io("something broke");
assert_eq!(ce.io_error_kind(), None);
}
#[test]
fn io_error_kind_none_for_non_io_variant() {
let ce = ComputerError::CommandNotQuoted;
assert_eq!(ce.io_error_kind(), None);
}
#[test]
fn io_with_kind_preserves_not_found() {
let ce = ComputerError::io_with_kind("Resource not found", std::io::ErrorKind::NotFound);
assert_eq!(ce.io_error_kind(), Some(std::io::ErrorKind::NotFound));
}
#[test]
fn io_with_kind_preserves_permission_denied() {
let ce = ComputerError::io_with_kind("access denied", std::io::ErrorKind::PermissionDenied);
assert_eq!(
ce.io_error_kind(),
Some(std::io::ErrorKind::PermissionDenied)
);
}
#[test]
fn io_with_kind_matches_local_fs_dispatch_for_not_found() {
// Simulate what LocalFs produces for a missing file
let local_err = ComputerError::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
));
// Simulate what AcpFsAdapter now produces for RESOURCE_NOT_FOUND
let acp_err =
ComputerError::io_with_kind("Resource not found", std::io::ErrorKind::NotFound);
// Both must produce the same io_error_kind so read_file dispatches
// to FileNotFound("Error: {path} does not exist.") in both cases.
assert_eq!(local_err.io_error_kind(), acp_err.io_error_kind());
}
}