This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/epiphany-tool-mcp-runtime/src/native_source.rs
2026-08-11 06:56:16 +02:00

402 lines
14 KiB
Rust

use anyhow::{Context, Result, anyhow};
use epiphany_tool_adapter::EpiphanyToolInvocationIntent;
use serde_json::{Value, json};
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
pub fn execute_epiphany_source(
intent: &EpiphanyToolInvocationIntent,
store: &Path,
cwd: &Path,
) -> Result<Value> {
let arguments: Value =
serde_json::from_str(&intent.arguments_json).context("arguments_json is not valid JSON")?;
if !arguments.is_object() {
return Err(anyhow!("epiphany_source arguments must be an object"));
}
match intent.tool_name.as_str() {
"read_file" => read_file(cwd, &arguments),
"directory_inventory" => directory_inventory(cwd, &arguments),
"git_show" => git_show(cwd, &arguments),
"read_hands_receipt" => read_hands_receipt(store, &arguments),
other => Err(anyhow!("unknown epiphany_source tool {other:?}")),
}
}
fn directory_inventory(cwd: &Path, arguments: &Value) -> Result<Value> {
let requested = required_string(arguments, "path")?;
let maximum_depth = optional_u64(arguments, "maxDepth")?
.unwrap_or(3)
.clamp(0, 8) as usize;
let maximum_entries = optional_u64(arguments, "maxEntries")?
.unwrap_or(1_024)
.clamp(1, 4_096) as usize;
let maximum_samples = optional_u64(arguments, "maxSamples")?
.unwrap_or(40)
.clamp(1, 100) as usize;
let root = confined_path(cwd, requested)?;
if !root.is_dir() {
return Err(anyhow!("directory inventory path is not a directory"));
}
let mut pending = vec![(root.clone(), 0usize)];
let mut entries = Vec::new();
let mut entry_limit_hit = false;
let mut depth_limit_hit = false;
while let Some((directory, depth)) = pending.pop() {
let mut children = fs::read_dir(&directory)
.with_context(|| format!("reading directory {}", directory.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
children.sort_by_key(|entry| entry.file_name());
for child in children {
if entries.len() == maximum_entries {
entry_limit_hit = true;
break;
}
let path = child.path();
let metadata = fs::symlink_metadata(&path)
.with_context(|| format!("reading metadata for {}", path.display()))?;
let kind = if metadata.file_type().is_symlink() {
"symlink"
} else if metadata.is_dir() {
"directory"
} else if metadata.is_file() {
"file"
} else {
"other"
};
entries.push((path.clone(), kind, metadata.len()));
if kind == "directory" && depth < maximum_depth {
pending.push((path, depth + 1));
} else if kind == "directory"
&& fs::read_dir(&path)
.with_context(|| format!("testing directory depth at {}", path.display()))?
.next()
.transpose()?
.is_some()
{
depth_limit_hit = true;
}
}
if entry_limit_hit {
break;
}
pending.sort_by(|left, right| right.0.cmp(&left.0));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
let file_count = entries.iter().filter(|entry| entry.1 == "file").count();
let directory_count = entries
.iter()
.filter(|entry| entry.1 == "directory")
.count();
let symlink_count = entries.iter().filter(|entry| entry.1 == "symlink").count();
let total_file_bytes = entries
.iter()
.filter(|entry| entry.1 == "file")
.map(|entry| entry.2)
.sum::<u64>();
let sample = entries
.iter()
.take(maximum_samples)
.map(|(path, kind, bytes)| {
json!({
"path": path.strip_prefix(&root).unwrap_or(path).display().to_string(),
"kind": kind,
"bytes": if *kind == "file" { Some(*bytes) } else { None },
})
})
.collect::<Vec<_>>();
let complete = !entry_limit_hit && !depth_limit_hit;
Ok(json!({
"path": root.display().to_string(),
"maxDepth": maximum_depth,
"maxEntries": maximum_entries,
"complete": complete,
"entryLimitHit": entry_limit_hit,
"depthLimitHit": depth_limit_hit,
"entryCount": entries.len(),
"fileCount": file_count,
"directoryCount": directory_count,
"symlinkCount": symlink_count,
"totalFileBytes": total_file_bytes,
"sample": sample,
}))
}
fn read_file(cwd: &Path, arguments: &Value) -> Result<Value> {
let requested = required_string(arguments, "path")?;
let start = optional_u64(arguments, "startLine")?.unwrap_or(1).max(1) as usize;
let maximum = optional_u64(arguments, "maxLines")?
.unwrap_or(120)
.clamp(1, 240) as usize;
let path = confined_path(cwd, requested)?;
let reader =
BufReader::new(File::open(&path).with_context(|| format!("reading {}", path.display()))?);
let mut content = Vec::new();
let mut count = 0usize;
for line in reader.lines() {
let line = line.with_context(|| format!("reading {}", path.display()))?;
count += 1;
if count >= start && content.len() < maximum {
content.push(format!("{count}: {}", truncate_chars(&line, 8_192)));
}
}
Ok(
json!({"path":path.display().to_string(),"startLine":start,"maxLines":maximum,"lineCount":count,"content":content.join("\n")}),
)
}
fn git_show(cwd: &Path, arguments: &Value) -> Result<Value> {
let revision = required_string(arguments, "revision")?;
let maximum = optional_u64(arguments, "maxBytes")?
.unwrap_or(16_000)
.clamp(512, 24_000) as usize;
let mut command = Command::new("git");
command
.current_dir(cwd)
.args([
"show",
"--stat",
"--patch",
"--format=medium",
revision,
"--",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(paths) = arguments.get("paths").and_then(Value::as_array) {
for path in paths.iter().map(|value| {
value
.as_str()
.ok_or_else(|| anyhow!("git_show paths must be strings"))
}) {
command.arg(path?);
}
}
let mut child = command.spawn().context("starting git show")?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("git stdout unavailable"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow!("git stderr unavailable"))?;
let out = thread::spawn(move || read_retained(stdout, maximum));
let err = thread::spawn(move || read_retained(stderr, 4_000));
let status = child.wait().context("waiting for git show")?;
let stdout = out
.join()
.map_err(|_| anyhow!("git stdout reader panicked"))??;
let stderr = err
.join()
.map_err(|_| anyhow!("git stderr reader panicked"))??;
Ok(
json!({"revision":revision,"status":status.code(),"success":status.success(),"stdout":stdout,"stderr":stderr}),
)
}
fn read_retained(mut input: impl Read, limit: usize) -> Result<String> {
let mut retained = Vec::with_capacity(limit.min(8_192));
let mut buffer = [0u8; 8_192];
let mut truncated = false;
loop {
let count = input.read(&mut buffer)?;
if count == 0 {
break;
}
let available = limit.saturating_sub(retained.len());
retained.extend_from_slice(&buffer[..count.min(available)]);
truncated |= count > available;
}
let mut text = String::from_utf8_lossy(&retained).into_owned();
if truncated {
text.push_str("\n...<truncated>");
}
Ok(text)
}
fn read_hands_receipt(store: &Path, arguments: &Value) -> Result<Value> {
let id = required_string(arguments, "receiptId")?;
match required_string(arguments, "kind")? {
"patch" => {
let r = epiphany_core::runtime_hands_patch_receipt(store, id)?
.ok_or_else(|| anyhow!("Hands patch receipt {id:?} not found"))?;
serde_json::to_value(r).context("encoding Hands patch receipt")
}
"command" => {
let r = epiphany_core::runtime_hands_command_receipt(store, id)?
.ok_or_else(|| anyhow!("Hands command receipt {id:?} not found"))?;
serde_json::to_value(r).context("encoding Hands command receipt")
}
"commit" => {
let r = epiphany_core::runtime_hands_commit_receipt(store, id)?
.ok_or_else(|| anyhow!("Hands commit receipt {id:?} not found"))?;
serde_json::to_value(r).context("encoding Hands commit receipt")
}
other => Err(anyhow!("unsupported Hands receipt kind {other:?}")),
}
}
fn confined_path(cwd: &Path, requested: &str) -> Result<PathBuf> {
let root = cwd
.canonicalize()
.with_context(|| format!("canonicalizing cwd {}", cwd.display()))?;
let requested = PathBuf::from(requested);
let candidate = (if requested.is_absolute() {
requested
} else {
root.join(requested)
})
.canonicalize()
.context("canonicalizing requested path")?;
if !candidate.starts_with(&root) {
return Err(anyhow!("read path escapes workspace"));
}
Ok(candidate)
}
fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("missing required string argument {name:?}"))
}
fn optional_u64(value: &Value, name: &str) -> Result<Option<u64>> {
match value.get(name) {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_u64()
.map(Some)
.ok_or_else(|| anyhow!("argument {name:?} must be unsigned")),
}
}
fn truncate_chars(value: &str, limit: usize) -> String {
if value.chars().count() <= limit {
value.into()
} else {
value.chars().take(limit).collect::<String>() + "...<truncated>"
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_tool_adapter::EPIPHANY_TOOL_RUNTIME_ADAPTER_ID;
use std::fs;
fn intent(tool: &str, arguments: &str) -> EpiphanyToolInvocationIntent {
EpiphanyToolInvocationIntent::new(
"i",
EPIPHANY_TOOL_RUNTIME_ADAPTER_ID,
"epiphany_source",
tool,
arguments,
"test",
"test",
"now",
)
}
#[test]
fn reads_only_bounded_workspace_slice() -> Result<()> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("body.txt"), "one\ntwo\nthree\nfour\n")?;
let value = execute_epiphany_source(
&intent(
"read_file",
r#"{"path":"body.txt","startLine":2,"maxLines":2}"#,
),
dir.path(),
dir.path(),
)?;
assert_eq!(value["content"], "2: two\n3: three");
assert!(
execute_epiphany_source(
&intent("read_file", r#"{"path":"../escape"}"#),
dir.path(),
dir.path()
)
.is_err()
);
Ok(())
}
#[test]
fn retained_reader_discards_excess_bytes() -> Result<()> {
assert_eq!(read_retained(&b"abcdef"[..], 3)?, "abc\n...<truncated>");
Ok(())
}
#[test]
fn inventories_workspace_directory_with_deterministic_totals() -> Result<()> {
let dir = tempfile::tempdir()?;
fs::create_dir(dir.path().join("artifacts"))?;
fs::create_dir(dir.path().join("artifacts/pulse-2"))?;
fs::create_dir(dir.path().join("artifacts/pulse-1"))?;
fs::write(dir.path().join("artifacts/pulse-1/a.txt"), b"awake")?;
fs::write(dir.path().join("artifacts/pulse-2/b.txt"), b"machine")?;
let value = execute_epiphany_source(
&intent(
"directory_inventory",
r#"{"path":"artifacts","maxDepth":2,"maxEntries":10,"maxSamples":10}"#,
),
dir.path(),
dir.path(),
)?;
assert_eq!(value["complete"], true);
assert_eq!(value["entryCount"], 4);
assert_eq!(value["directoryCount"], 2);
assert_eq!(value["fileCount"], 2);
assert_eq!(value["totalFileBytes"], 12);
assert_eq!(value["sample"][0]["path"], "pulse-1");
assert!(
execute_epiphany_source(
&intent("directory_inventory", r#"{"path":"../escape"}"#),
dir.path(),
dir.path(),
)
.is_err()
);
Ok(())
}
#[test]
fn directory_inventory_reports_truncation_instead_of_partial_truth() -> Result<()> {
let dir = tempfile::tempdir()?;
fs::create_dir(dir.path().join("artifacts"))?;
for name in ["a", "b", "c"] {
fs::write(dir.path().join("artifacts").join(name), name)?;
}
let value = execute_epiphany_source(
&intent(
"directory_inventory",
r#"{"path":"artifacts","maxEntries":2}"#,
),
dir.path(),
dir.path(),
)?;
assert_eq!(value["complete"], false);
assert_eq!(value["entryCount"], 2);
fs::create_dir(dir.path().join("nested"))?;
fs::create_dir(dir.path().join("nested/child"))?;
fs::write(dir.path().join("nested/child/body"), b"hidden by depth")?;
let depth_limited = execute_epiphany_source(
&intent(
"directory_inventory",
r#"{"path":"nested","maxDepth":0,"maxEntries":10}"#,
),
dir.path(),
dir.path(),
)?;
assert_eq!(depth_limited["complete"], false);
assert_eq!(depth_limited["depthLimitHit"], true);
Ok(())
}
}