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-07-21 19:27:28 +01:00

233 lines
7.8 KiB
Rust

use anyhow::{Context, Result, anyhow};
use epiphany_tool_adapter::EpiphanyToolInvocationIntent;
use serde_json::{Value, json};
use std::fs::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),
"git_show" => git_show(cwd, &arguments),
"read_hands_receipt" => read_hands_receipt(store, &arguments),
other => Err(anyhow!("unknown epiphany_source tool {other:?}")),
}
}
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(())
}
}