use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::{ApprovalRecord, RuntimeStore};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalReplayResult {
pub approval: Option<ApprovalRecord>,
pub replayed: bool,
pub summary: String,
}
pub fn approve_and_replay(id: &str) -> Result<ApprovalReplayResult> {
let store = RuntimeStore::open_default()?;
store.approvals().decide(id, "approved")?;
let approval = store
.approvals()
.get(id)?
.with_context(|| format!("approval not found after approval: {id}"))?;
let Some(raw_action) = approval.pending_action_json.as_deref() else {
return Ok(ApprovalReplayResult {
approval: Some(approval),
replayed: false,
summary: "approval recorded; no pending action was stored".to_string(),
});
};
let action: serde_json::Value = serde_json::from_str(raw_action)
.with_context(|| format!("approval {id} pending action was not valid JSON"))?;
let summary = replay_pending_action(&action)?;
let _ = crate::run_plugin_hooks(
"approval_decided",
&serde_json::json!({
"id": approval.id.clone(),
"decision": "approved",
"task_id": approval.task_id.clone(),
"replayed": true,
"summary": summary.clone(),
}),
);
if let Some(task_id) = approval.task_id.as_deref() {
let _ = store
.tasks()
.add_event(task_id, "approval_replayed", &summary);
}
if let Some(tool) = action.get("tool").and_then(|value| value.as_str()) {
let replay_run = store.tool_runs().start(crate::NewToolRun {
id: None,
task_id: approval.task_id.clone(),
turn_id: action
.get("turn_id")
.and_then(|value| value.as_i64())
.map(|value| value.to_string()),
call_id: action
.get("call_id")
.and_then(|value| value.as_i64())
.map(|value| value.to_string()),
tool_name: format!("approval_replay:{tool}"),
args_json: Some(raw_action.to_string()),
});
if let Ok(run) = replay_run {
let _ = store.tool_runs().finish(
&run.id,
"success",
Some(&serde_json::json!({"summary": summary}).to_string()),
);
}
}
Ok(ApprovalReplayResult {
approval: Some(approval),
replayed: true,
summary,
})
}
pub fn deny_approval(id: &str) -> Result<ApprovalReplayResult> {
let store = RuntimeStore::open_default()?;
store.approvals().decide(id, "denied")?;
let approval = store.approvals().get(id)?;
let _ = crate::run_plugin_hooks(
"approval_decided",
&serde_json::json!({
"id": id,
"decision": "denied",
"task_id": approval.as_ref().and_then(|record| record.task_id.clone()),
"replayed": false,
}),
);
if let Some(task_id) = approval
.as_ref()
.and_then(|record| record.task_id.as_deref())
{
let _ =
store
.tasks()
.add_event(task_id, "approval_denied", &format!("approval {id} denied"));
}
Ok(ApprovalReplayResult {
approval,
replayed: false,
summary: "approval denied".to_string(),
})
}
fn replay_pending_action(action: &serde_json::Value) -> Result<String> {
let tool = action
.get("tool")
.and_then(|value| value.as_str())
.context("pending action missing string `tool`")?;
let workdir = action
.get("workdir")
.and_then(|value| value.as_str())
.map(PathBuf::from)
.unwrap_or(std::env::current_dir()?);
let args = action.get("args").unwrap_or(action);
match tool {
"execute_command" => replay_execute_command(args, &workdir),
"write_file" => {
let path = string_arg(args, "path")?;
let content = string_arg(args, "content")?;
let target = resolve_replay_path(&workdir, path)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, content)?;
Ok(format!("replayed write_file {}", target.display()))
},
"edit_file" => {
let path = string_arg(args, "path")?;
let old = string_arg(args, "old_string")?;
let new = string_arg(args, "new_string")?;
let target = resolve_replay_path(&workdir, path)?;
replay_edit(&target, old, new)?;
Ok(format!("replayed edit_file {}", target.display()))
},
"delete_file" => {
let path = string_arg(args, "path")?;
let target = resolve_replay_path(&workdir, path)?;
std::fs::remove_file(&target)?;
Ok(format!("replayed delete_file {}", target.display()))
},
"create_directory" => {
let path = string_arg(args, "path")?;
let target = resolve_replay_path(&workdir, path)?;
std::fs::create_dir_all(&target)?;
Ok(format!("replayed create_directory {}", target.display()))
},
other => anyhow::bail!("approval replay does not support tool `{other}`"),
}
}
fn replay_execute_command(args: &serde_json::Value, workdir: &Path) -> Result<String> {
let command = string_arg(args, "command")?;
let effective_dir = args
.get("working_dir")
.and_then(|value| value.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| workdir.to_path_buf());
let mode = args
.get("mode")
.and_then(|value| value.as_str())
.unwrap_or("wait");
if mode == "background" {
let child = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&effective_dir)
.spawn()
.with_context(|| format!("failed to replay background command `{command}`"))?;
return Ok(format!(
"replayed execute_command in background with pid {}",
child.id()
));
}
let output = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&effective_dir)
.output()
.with_context(|| format!("failed to replay command `{command}`"))?;
anyhow::ensure!(
output.status.success(),
"replayed command failed with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
Ok(format!(
"replayed execute_command successfully ({} stdout bytes)",
output.stdout.len()
))
}
fn replay_edit(path: &Path, old_string: &str, new_string: &str) -> Result<()> {
let current = std::fs::read_to_string(path)?;
let count = current.matches(old_string).count();
anyhow::ensure!(count > 0, "old_string not found during approval replay");
anyhow::ensure!(
count == 1,
"old_string appears {count} times during approval replay"
);
std::fs::write(path, current.replacen(old_string, new_string, 1))?;
Ok(())
}
fn resolve_replay_path(workdir: &Path, path: &str) -> Result<PathBuf> {
let candidate = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
workdir.join(path)
};
let lexical = normalize_lexical(&candidate);
let root = normalize_lexical(workdir);
anyhow::ensure!(
lexical.starts_with(&root),
"approval replay path escapes workdir: {}",
path
);
Ok(lexical)
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {},
std::path::Component::ParentDir => {
out.pop();
},
other => out.push(other.as_os_str()),
}
}
out
}
fn string_arg<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str> {
args.get(name)
.and_then(|value| value.as_str())
.with_context(|| format!("pending action missing string arg `{name}`"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_replay_path_rejects_parent_escape() {
let root = std::env::temp_dir().join("mermaid_replay_root");
assert!(resolve_replay_path(&root, "../escape").is_err());
}
#[test]
fn replay_write_file_creates_parent() {
let root =
std::env::temp_dir().join(format!("mermaid_replay_write_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let action = serde_json::json!({
"tool": "write_file",
"workdir": root,
"args": {"path": "a/b.txt", "content": "ok"}
});
let summary = replay_pending_action(&action).unwrap();
assert!(summary.contains("write_file"));
}
}