e32300d1f8
bypassPermissions ignored the worktree's .claude/settings.json entirely, letting agents run any Bash command including cargo test (which they'd spawn 4+ times concurrently, deadlocking on the build directory lock). allowFullAutoEdit respects the settings.json allowlist, so agents can only use the Bash commands we explicitly permit (cargo check, cargo build, git) and must use MCP tools for everything else (run_tests, run_lint, run_build). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
863 lines
33 KiB
Rust
863 lines
33 KiB
Rust
use crate::agents::move_story_to_stage;
|
|
use crate::http::context::AppContext;
|
|
use crate::log_buffer;
|
|
use crate::slog;
|
|
use crate::slog_warn;
|
|
use serde_json::{Value, json};
|
|
use std::fs;
|
|
|
|
pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
|
|
let lines_count = args
|
|
.get("lines")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|n| n.min(1000) as usize)
|
|
.unwrap_or(100);
|
|
let filter = args.get("filter").and_then(|v| v.as_str());
|
|
let severity = args
|
|
.get("severity")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(log_buffer::LogLevel::from_str_ci);
|
|
|
|
let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
|
|
let joined = recent.join("\n");
|
|
// Clamp to lines_count actual lines in case any entry contains embedded newlines.
|
|
let all_lines: Vec<&str> = joined.lines().collect();
|
|
let start = all_lines.len().saturating_sub(lines_count);
|
|
Ok(all_lines[start..].join("\n"))
|
|
}
|
|
|
|
/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
|
|
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
|
|
slog!("[rebuild] Rebuild and restart requested via MCP tool");
|
|
|
|
// Signal the Matrix bot (if active) so it can send its own shutdown
|
|
// announcement before the process is replaced. Best-effort: we wait up
|
|
// to 1.5 s for the message to be delivered.
|
|
if let Some(ref tx) = ctx.matrix_shutdown_tx {
|
|
let _ = tx.send(Some(crate::rebuild::ShutdownReason::Rebuild));
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
}
|
|
|
|
let project_root = ctx.state.get_project_root().unwrap_or_default();
|
|
let notifier = ctx.bot_shutdown.as_deref();
|
|
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
|
|
}
|
|
|
|
/// Generate a Claude Code permission rule string for the given tool name and input.
|
|
///
|
|
/// - `Edit` / `Write` / `Read` / `Grep` / `Glob` etc. → just the tool name
|
|
/// - `Bash` → `Bash(first_word *)` derived from the `command` field in `tool_input`
|
|
/// - `mcp__*` → the full tool name (e.g. `mcp__huskies__create_story`)
|
|
fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
|
|
if tool_name == "Bash" {
|
|
// Extract command from tool_input.command and use first word as prefix
|
|
let command_str = tool_input
|
|
.get("command")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let first_word = command_str.split_whitespace().next().unwrap_or("unknown");
|
|
format!("Bash({first_word} *)")
|
|
} else {
|
|
// For Edit, Write, Read, Glob, Grep, MCP tools, etc. — use the tool name directly
|
|
tool_name.to_string()
|
|
}
|
|
}
|
|
|
|
/// Add a permission rule to `.claude/settings.json` in the project root.
|
|
/// Does nothing if the rule already exists. Creates the file if missing.
|
|
pub(super) fn add_permission_rule(
|
|
project_root: &std::path::Path,
|
|
rule: &str,
|
|
) -> Result<(), String> {
|
|
let claude_dir = project_root.join(".claude");
|
|
fs::create_dir_all(&claude_dir)
|
|
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
|
|
|
|
let settings_path = claude_dir.join("settings.json");
|
|
let mut settings: Value = if settings_path.exists() {
|
|
let content = fs::read_to_string(&settings_path)
|
|
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
|
|
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
|
|
} else {
|
|
json!({ "permissions": { "allow": [] } })
|
|
};
|
|
|
|
let allow_arr = settings
|
|
.pointer_mut("/permissions/allow")
|
|
.and_then(|v| v.as_array_mut());
|
|
|
|
let allow = match allow_arr {
|
|
Some(arr) => arr,
|
|
None => {
|
|
// Ensure the structure exists
|
|
settings
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.entry("permissions")
|
|
.or_insert(json!({ "allow": [] }));
|
|
settings
|
|
.pointer_mut("/permissions/allow")
|
|
.unwrap()
|
|
.as_array_mut()
|
|
.unwrap()
|
|
}
|
|
};
|
|
|
|
// Check for duplicates — exact string match
|
|
let rule_value = Value::String(rule.to_string());
|
|
if allow.contains(&rule_value) {
|
|
return Ok(());
|
|
}
|
|
|
|
// Also check for wildcard coverage: if "mcp__huskies__*" exists, don't add
|
|
// a more specific "mcp__huskies__create_story".
|
|
let dominated = allow.iter().any(|existing| {
|
|
if let Some(pat) = existing.as_str()
|
|
&& let Some(prefix) = pat.strip_suffix('*')
|
|
{
|
|
return rule.starts_with(prefix);
|
|
}
|
|
false
|
|
});
|
|
if dominated {
|
|
return Ok(());
|
|
}
|
|
|
|
allow.push(rule_value);
|
|
|
|
let pretty =
|
|
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
|
|
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
|
|
///
|
|
/// Forwards the permission request through the shared channel to the active
|
|
/// WebSocket session, which presents a dialog to the user. Blocks until the
|
|
/// user approves or denies (with a 5-minute timeout).
|
|
pub(super) async fn tool_prompt_permission(
|
|
args: &Value,
|
|
ctx: &AppContext,
|
|
) -> Result<String, String> {
|
|
let tool_name = args
|
|
.get("tool_name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
let tool_input = args.get("input").cloned().unwrap_or(json!({}));
|
|
|
|
let request_id = uuid::Uuid::new_v4().to_string();
|
|
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
|
|
|
// Try to forward to the interactive session (WebSocket/Matrix).
|
|
// If no session is active (headless agent), auto-deny the permission.
|
|
if ctx.perm_tx
|
|
.send(crate::http::context::PermissionForward {
|
|
request_id: request_id.clone(),
|
|
tool_name: tool_name.clone(),
|
|
tool_input: tool_input.clone(),
|
|
response_tx,
|
|
})
|
|
.is_err()
|
|
{
|
|
crate::slog!(
|
|
"[permission] Auto-denied '{tool_name}' (no interactive session — agent mode)"
|
|
);
|
|
return serde_json::to_string_pretty(&json!({
|
|
"behavior": "deny",
|
|
"message": format!("Permission denied for '{tool_name}'. Use the appropriate MCP tool instead (e.g. run_tests, run_build, run_lint).")
|
|
}))
|
|
.map_err(|e| format!("Serialization error: {e}"));
|
|
}
|
|
|
|
use crate::http::context::PermissionDecision;
|
|
|
|
let decision = tokio::time::timeout(std::time::Duration::from_secs(300), response_rx)
|
|
.await
|
|
.map_err(|_| {
|
|
let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes");
|
|
slog_warn!("[permission] {msg}");
|
|
msg
|
|
})?
|
|
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
|
|
|
if decision == PermissionDecision::AlwaysAllow {
|
|
// Persist the rule so Claude Code won't prompt again for this tool.
|
|
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
|
let rule = generate_permission_rule(&tool_name, &tool_input);
|
|
if let Err(e) = add_permission_rule(&root, &rule) {
|
|
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
|
} else {
|
|
slog!("[permission] Added always-allow rule: {rule}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
|
// Claude Code SDK expects:
|
|
// Allow: { behavior: "allow", updatedInput: <record> }
|
|
// Deny: { behavior: "deny", message: string }
|
|
Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string())
|
|
} else {
|
|
slog_warn!("[permission] User denied permission for '{tool_name}'");
|
|
Ok(json!({
|
|
"behavior": "deny",
|
|
"message": format!("User denied permission for '{tool_name}'")
|
|
})
|
|
.to_string())
|
|
}
|
|
}
|
|
|
|
pub(super) fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|
let root = ctx.state.get_project_root()?;
|
|
let filter_story = args.get("story_id").and_then(|v| v.as_str());
|
|
|
|
let all_records = crate::agents::token_usage::read_all(&root)?;
|
|
let records: Vec<_> = all_records
|
|
.into_iter()
|
|
.filter(|r| filter_story.is_none_or(|s| r.story_id == s))
|
|
.collect();
|
|
|
|
let total_cost: f64 = records.iter().map(|r| r.usage.total_cost_usd).sum();
|
|
let total_input: u64 = records.iter().map(|r| r.usage.input_tokens).sum();
|
|
let total_output: u64 = records.iter().map(|r| r.usage.output_tokens).sum();
|
|
let total_cache_create: u64 = records
|
|
.iter()
|
|
.map(|r| r.usage.cache_creation_input_tokens)
|
|
.sum();
|
|
let total_cache_read: u64 = records
|
|
.iter()
|
|
.map(|r| r.usage.cache_read_input_tokens)
|
|
.sum();
|
|
|
|
serde_json::to_string_pretty(&json!({
|
|
"records": records.iter().map(|r| json!({
|
|
"story_id": r.story_id,
|
|
"agent_name": r.agent_name,
|
|
"timestamp": r.timestamp,
|
|
"input_tokens": r.usage.input_tokens,
|
|
"output_tokens": r.usage.output_tokens,
|
|
"cache_creation_input_tokens": r.usage.cache_creation_input_tokens,
|
|
"cache_read_input_tokens": r.usage.cache_read_input_tokens,
|
|
"total_cost_usd": r.usage.total_cost_usd,
|
|
})).collect::<Vec<_>>(),
|
|
"totals": {
|
|
"records": records.len(),
|
|
"input_tokens": total_input,
|
|
"output_tokens": total_output,
|
|
"cache_creation_input_tokens": total_cache_create,
|
|
"cache_read_input_tokens": total_cache_read,
|
|
"total_cost_usd": total_cost,
|
|
}
|
|
}))
|
|
.map_err(|e| format!("Serialization error: {e}"))
|
|
}
|
|
|
|
pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|
let story_id = args
|
|
.get("story_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("Missing required argument: story_id")?;
|
|
let target_stage = args
|
|
.get("target_stage")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("Missing required argument: target_stage")?;
|
|
|
|
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
|
|
|
let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;
|
|
|
|
serde_json::to_string_pretty(&json!({
|
|
"story_id": story_id,
|
|
"from_stage": from_stage,
|
|
"to_stage": to_stage,
|
|
"message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
|
|
}))
|
|
.map_err(|e| format!("Serialization error: {e}"))
|
|
}
|
|
|
|
/// MCP tool: dump the raw in-memory CRDT state for debugging.
|
|
///
|
|
/// **Debug tool only** — for normal pipeline introspection use `get_pipeline_status`.
|
|
pub(super) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
|
|
let story_id_filter = args.get("story_id").and_then(|v| v.as_str());
|
|
let dump = crate::crdt_state::dump_crdt_state(story_id_filter);
|
|
|
|
let items: Vec<Value> = dump
|
|
.items
|
|
.into_iter()
|
|
.map(|item| {
|
|
json!({
|
|
"story_id": item.story_id,
|
|
"stage": item.stage,
|
|
"name": item.name,
|
|
"agent": item.agent,
|
|
"retry_count": item.retry_count,
|
|
"blocked": item.blocked,
|
|
"depends_on": item.depends_on,
|
|
"claimed_by": item.claimed_by,
|
|
"claimed_at": item.claimed_at,
|
|
"content_index": item.content_index,
|
|
"is_deleted": item.is_deleted,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
serde_json::to_string_pretty(&json!({
|
|
"metadata": {
|
|
"in_memory_state_loaded": dump.in_memory_state_loaded,
|
|
"total_items": dump.total_items,
|
|
"total_ops_in_list": dump.total_ops_in_list,
|
|
"max_seq_in_list": dump.max_seq_in_list,
|
|
"persisted_ops_count": dump.persisted_ops_count,
|
|
"pending_persist_ops_count": null,
|
|
},
|
|
"items": items,
|
|
}))
|
|
.map_err(|e| format!("Serialization error: {e}"))
|
|
}
|
|
|
|
/// MCP tool: count lines in a specific file relative to the project root.
|
|
pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|
let file_path = args
|
|
.get("file_path")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| "Missing required argument: file_path".to_string())?;
|
|
|
|
let project_root = ctx.state.get_project_root()?;
|
|
Ok(crate::chat::commands::loc::loc_single_file(&project_root, file_path))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::http::test_helpers::test_ctx;
|
|
|
|
#[test]
|
|
fn tool_get_server_logs_no_args_returns_string() {
|
|
let result = tool_get_server_logs(&json!({})).unwrap();
|
|
// Returns recent log lines (possibly empty in tests) — just verify no panic
|
|
let _ = result;
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_server_logs_with_filter_returns_matching_lines() {
|
|
let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
|
|
assert_eq!(
|
|
result, "",
|
|
"filter with no matches should return empty string"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_server_logs_with_line_limit() {
|
|
let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
|
|
assert!(result.lines().count() <= 5);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_server_logs_max_cap_is_1000() {
|
|
// Lines > 1000 are capped — just verify it returns without error
|
|
let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
|
|
let _ = result;
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_token_usage_empty_returns_zero_totals() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
|
|
assert_eq!(parsed["totals"]["records"], 0);
|
|
assert_eq!(parsed["totals"]["total_cost_usd"], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_token_usage_returns_written_records() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let ctx = test_ctx(root);
|
|
|
|
let usage = crate::agents::TokenUsage {
|
|
input_tokens: 100,
|
|
output_tokens: 200,
|
|
cache_creation_input_tokens: 5000,
|
|
cache_read_input_tokens: 10000,
|
|
total_cost_usd: 1.57,
|
|
};
|
|
let record =
|
|
crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage);
|
|
crate::agents::token_usage::append_record(root, &record).unwrap();
|
|
|
|
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
|
assert_eq!(parsed["records"][0]["story_id"], "42_story_foo");
|
|
assert_eq!(parsed["records"][0]["agent_name"], "coder-1");
|
|
assert_eq!(parsed["records"][0]["input_tokens"], 100);
|
|
assert_eq!(parsed["totals"]["records"], 1);
|
|
assert!((parsed["totals"]["total_cost_usd"].as_f64().unwrap() - 1.57).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_get_token_usage_filters_by_story_id() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let ctx = test_ctx(root);
|
|
|
|
let usage = crate::agents::TokenUsage {
|
|
input_tokens: 50,
|
|
output_tokens: 60,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
total_cost_usd: 0.5,
|
|
};
|
|
let r1 =
|
|
crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone());
|
|
let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage);
|
|
crate::agents::token_usage::append_record(root, &r1).unwrap();
|
|
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
|
|
|
let result = tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
|
assert_eq!(parsed["records"][0]["story_id"], "10_story_a");
|
|
assert_eq!(parsed["totals"]["records"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_prompt_permission_approved_returns_updated_input() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
|
|
// Spawn a task that immediately sends approval through the channel.
|
|
let perm_rx = ctx.perm_rx.clone();
|
|
tokio::spawn(async move {
|
|
let mut rx = perm_rx.lock().await;
|
|
if let Some(forward) = rx.recv().await {
|
|
let _ = forward
|
|
.response_tx
|
|
.send(crate::http::context::PermissionDecision::Approve);
|
|
}
|
|
});
|
|
|
|
let result = tool_prompt_permission(
|
|
&json!({"tool_name": "Bash", "input": {"command": "echo hello"}}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("should succeed on approval");
|
|
|
|
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
|
assert_eq!(
|
|
parsed["behavior"], "allow",
|
|
"approved must return behavior:allow"
|
|
);
|
|
assert_eq!(
|
|
parsed["updatedInput"]["command"], "echo hello",
|
|
"approved must return updatedInput with original tool input for Claude Code SDK compatibility"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_prompt_permission_denied_returns_deny_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
|
|
// Spawn a task that immediately sends denial through the channel.
|
|
let perm_rx = ctx.perm_rx.clone();
|
|
tokio::spawn(async move {
|
|
let mut rx = perm_rx.lock().await;
|
|
if let Some(forward) = rx.recv().await {
|
|
let _ = forward
|
|
.response_tx
|
|
.send(crate::http::context::PermissionDecision::Deny);
|
|
}
|
|
});
|
|
|
|
let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx)
|
|
.await
|
|
.expect("denial must return Ok, not Err");
|
|
|
|
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
|
assert_eq!(
|
|
parsed["behavior"], "deny",
|
|
"denied must return behavior:deny"
|
|
);
|
|
assert!(parsed["message"].is_string(), "deny must include a message");
|
|
}
|
|
|
|
// ── Permission rule generation tests ─────────────────────────
|
|
|
|
#[test]
|
|
fn generate_rule_for_edit_tool() {
|
|
let rule = generate_permission_rule("Edit", &json!({}));
|
|
assert_eq!(rule, "Edit");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_rule_for_write_tool() {
|
|
let rule = generate_permission_rule("Write", &json!({}));
|
|
assert_eq!(rule, "Write");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_rule_for_bash_git() {
|
|
let rule = generate_permission_rule("Bash", &json!({"command": "git status"}));
|
|
assert_eq!(rule, "Bash(git *)");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_rule_for_bash_cargo() {
|
|
let rule = generate_permission_rule("Bash", &json!({"command": "cargo test --all"}));
|
|
assert_eq!(rule, "Bash(cargo *)");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_rule_for_bash_empty_command() {
|
|
let rule = generate_permission_rule("Bash", &json!({}));
|
|
assert_eq!(rule, "Bash(unknown *)");
|
|
}
|
|
|
|
#[test]
|
|
fn generate_rule_for_mcp_tool() {
|
|
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
|
|
assert_eq!(rule, "mcp__huskies__create_story");
|
|
}
|
|
|
|
// ── Settings.json writing tests ──────────────────────────────
|
|
|
|
#[test]
|
|
fn add_rule_creates_settings_file_when_missing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
add_permission_rule(tmp.path(), "Edit").unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
|
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
assert!(allow.contains(&json!("Edit")));
|
|
}
|
|
|
|
#[test]
|
|
fn add_rule_does_not_duplicate_existing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
add_permission_rule(tmp.path(), "Edit").unwrap();
|
|
add_permission_rule(tmp.path(), "Edit").unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
|
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn add_rule_skips_when_wildcard_already_covers() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let claude_dir = tmp.path().join(".claude");
|
|
fs::create_dir_all(&claude_dir).unwrap();
|
|
fs::write(
|
|
claude_dir.join("settings.json"),
|
|
r#"{"permissions":{"allow":["mcp__huskies__*"]}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
add_permission_rule(tmp.path(), "mcp__huskies__create_story").unwrap();
|
|
|
|
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
|
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
assert_eq!(allow.len(), 1);
|
|
assert_eq!(allow[0], "mcp__huskies__*");
|
|
}
|
|
|
|
#[test]
|
|
fn add_rule_appends_to_existing_rules() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let claude_dir = tmp.path().join(".claude");
|
|
fs::create_dir_all(&claude_dir).unwrap();
|
|
fs::write(
|
|
claude_dir.join("settings.json"),
|
|
r#"{"permissions":{"allow":["Edit"]}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
add_permission_rule(tmp.path(), "Write").unwrap();
|
|
|
|
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
|
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
|
assert_eq!(allow.len(), 2);
|
|
assert!(allow.contains(&json!("Edit")));
|
|
assert!(allow.contains(&json!("Write")));
|
|
}
|
|
|
|
#[test]
|
|
fn add_rule_preserves_other_settings_fields() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let claude_dir = tmp.path().join(".claude");
|
|
fs::create_dir_all(&claude_dir).unwrap();
|
|
fs::write(
|
|
claude_dir.join("settings.json"),
|
|
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["huskies"]}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
add_permission_rule(tmp.path(), "Write").unwrap();
|
|
|
|
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
|
let settings: Value = serde_json::from_str(&content).unwrap();
|
|
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
|
|
assert_eq!(servers.len(), 1);
|
|
assert_eq!(servers[0], "huskies");
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_and_restart_in_tools_list() {
|
|
use super::super::handle_tools_list;
|
|
let resp = handle_tools_list(Some(json!(1)));
|
|
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
|
let tool = tools.iter().find(|t| t["name"] == "rebuild_and_restart");
|
|
assert!(
|
|
tool.is_some(),
|
|
"rebuild_and_restart missing from tools list"
|
|
);
|
|
let t = tool.unwrap();
|
|
assert!(t["description"].as_str().unwrap().contains("Rebuild"));
|
|
assert!(t["inputSchema"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_and_restart_kills_agents_before_build() {
|
|
// Verify that calling rebuild_and_restart on an empty pool doesn't
|
|
// panic and proceeds to the build step. We can't test exec() in a
|
|
// unit test, but we can verify the build attempt happens.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
|
|
// The build will succeed (we're running in the real workspace) and
|
|
// then exec() will be called — which would replace our test process.
|
|
// So we only test that the function *runs* without panicking up to
|
|
// the agent-kill step. We do this by checking the pool is empty.
|
|
assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
|
|
ctx.agents.kill_all_children(); // should not panic on empty pool
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_uses_matching_build_profile() {
|
|
// The build must use the same profile (debug/release) as the running
|
|
// binary, otherwise cargo build outputs to a different target dir and
|
|
// current_exe() still points at the old binary.
|
|
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
|
vec!["build", "-p", "huskies"]
|
|
} else {
|
|
vec!["build", "--release", "-p", "huskies"]
|
|
};
|
|
|
|
// Tests always run in debug mode, so --release must NOT be present.
|
|
assert!(
|
|
!build_args.contains(&"--release"),
|
|
"In debug builds, rebuild must not pass --release (would put \
|
|
the binary in target/release/ while current_exe() points to \
|
|
target/debug/)"
|
|
);
|
|
}
|
|
|
|
// ── move_story tool tests ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn move_story_in_tools_list() {
|
|
use super::super::handle_tools_list;
|
|
let resp = handle_tools_list(Some(json!(1)));
|
|
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
|
let tool = tools.iter().find(|t| t["name"] == "move_story");
|
|
assert!(tool.is_some(), "move_story missing from tools list");
|
|
let t = tool.unwrap();
|
|
assert!(t["description"].is_string());
|
|
let required = t["inputSchema"]["required"].as_array().unwrap();
|
|
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
|
assert!(req_names.contains(&"story_id"));
|
|
assert!(req_names.contains(&"target_stage"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_missing_story_id() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
let result = tool_move_story(&json!({"target_stage": "current"}), &ctx);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("story_id"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_missing_target_stage() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
let result = tool_move_story(&json!({"story_id": "1_story_test"}), &ctx);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("target_stage"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_invalid_target_stage() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
// Seed project root in state so get_project_root works
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
fs::write(backlog.join("1_story_test.md"), "---\nname: Test\n---\n").unwrap();
|
|
let ctx = test_ctx(root);
|
|
let result = tool_move_story(
|
|
&json!({"story_id": "1_story_test", "target_stage": "invalid"}),
|
|
&ctx,
|
|
);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_moves_from_backlog_to_current() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
let current = root.join(".huskies/work/2_current");
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
fs::create_dir_all(¤t).unwrap();
|
|
let content = "---\nname: Test\n---\n";
|
|
fs::write(backlog.join("5_story_test.md"), content).unwrap();
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_content("5_story_test", content);
|
|
|
|
let ctx = test_ctx(root);
|
|
let result = tool_move_story(
|
|
&json!({"story_id": "5_story_test", "target_stage": "current"}),
|
|
&ctx,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(crate::db::read_content("5_story_test").is_some());
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["story_id"], "5_story_test");
|
|
assert_eq!(parsed["from_stage"], "backlog");
|
|
assert_eq!(parsed["to_stage"], "current");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_moves_from_current_to_backlog() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let current = root.join(".huskies/work/2_current");
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
fs::create_dir_all(¤t).unwrap();
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
let content = "---\nname: Back\n---\n";
|
|
fs::write(current.join("6_story_back.md"), content).unwrap();
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_content("6_story_back", content);
|
|
|
|
let ctx = test_ctx(root);
|
|
let result = tool_move_story(
|
|
&json!({"story_id": "6_story_back", "target_stage": "backlog"}),
|
|
&ctx,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(crate::db::read_content("6_story_back").is_some());
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
// from_stage may be inaccurate when using the content-store fallback
|
|
// (it lacks stage tracking), but the move itself must succeed.
|
|
assert_eq!(parsed["to_stage"], "backlog");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_idempotent_when_already_in_target() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
let current = root.join(".huskies/work/2_current");
|
|
fs::create_dir_all(¤t).unwrap();
|
|
// Use a unique high-numbered story ID to avoid collisions with stale
|
|
// entries in the global content store from parallel tests.
|
|
let content = "---\nname: Idem\n---\n";
|
|
fs::write(current.join("9907_story_idem.md"), content).unwrap();
|
|
crate::db::ensure_content_store();
|
|
crate::db::write_content("9907_story_idem", content);
|
|
|
|
let ctx = test_ctx(root);
|
|
let result = tool_move_story(
|
|
&json!({"story_id": "9907_story_idem", "target_stage": "current"}),
|
|
&ctx,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(crate::db::read_content("9907_story_idem").is_some());
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
// When CRDT is uninitialised the content-store fallback handles the
|
|
// move, so idempotency detection may not fire. Verify the to_stage
|
|
// is correct regardless.
|
|
assert_eq!(parsed["to_stage"], "current");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_move_story_error_when_not_found() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ctx = test_ctx(tmp.path());
|
|
let result = tool_move_story(
|
|
&json!({"story_id": "99_story_ghost", "target_stage": "current"}),
|
|
&ctx,
|
|
);
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.contains("not found in any pipeline stage")
|
|
);
|
|
}
|
|
|
|
// ── dump_crdt tool tests ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tool_dump_crdt_returns_valid_json() {
|
|
let result = tool_dump_crdt(&json!({})).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).expect("result must be valid JSON");
|
|
assert!(parsed["metadata"].is_object(), "must have metadata object");
|
|
assert!(parsed["items"].is_array(), "must have items array");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_dump_crdt_metadata_has_required_fields() {
|
|
let result = tool_dump_crdt(&json!({})).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
let meta = &parsed["metadata"];
|
|
assert!(meta["in_memory_state_loaded"].is_boolean());
|
|
assert!(meta["total_items"].is_number());
|
|
assert!(meta["total_ops_in_list"].is_number());
|
|
assert!(meta["max_seq_in_list"].is_number());
|
|
assert!(meta["persisted_ops_count"].is_number());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_dump_crdt_with_story_id_filter_returns_valid_json() {
|
|
let result =
|
|
tool_dump_crdt(&json!({"story_id": "9999_story_nonexistent"})).unwrap();
|
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
|
assert!(parsed["items"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dump_crdt_in_tools_list() {
|
|
use super::super::handle_tools_list;
|
|
let resp = handle_tools_list(Some(json!(1)));
|
|
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
|
let tool = tools.iter().find(|t| t["name"] == "dump_crdt");
|
|
assert!(tool.is_some(), "dump_crdt missing from tools list");
|
|
let t = tool.unwrap();
|
|
assert!(
|
|
t["description"].as_str().unwrap().to_lowercase().contains("debug"),
|
|
"description must mention this is a debug tool"
|
|
);
|
|
assert!(t["inputSchema"].is_object());
|
|
}
|
|
}
|