diff --git a/crates/colibri-daemon/tests/cost_pipeline.rs b/crates/colibri-daemon/tests/cost_pipeline.rs new file mode 100644 index 0000000..6cb5a6b --- /dev/null +++ b/crates/colibri-daemon/tests/cost_pipeline.rs @@ -0,0 +1,69 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +/// Verify SSH to mother with report-task-cost forced command works. +/// Sends payload via stdin (matching daemon push_cost_to_mother behaviour). +#[test] +fn ssh_mother_report_task_cost_roundtrip() { + let mother = match std::env::var("COLIBRI_MOTHER_HOST") { + Ok(h) => h, + Err(_) => { eprintln!("SKIP: COLIBRI_MOTHER_HOST not set"); return; } + }; + + let payload = format!(r#"{{"node_hostname":"test-runner","task_id":"mcp-test-{}","provider":"deepseek","model":"deepseek-chat","input_tokens":10,"output_tokens":5,"cost_usd":0.0001,"success":true}}"#, std::process::id()); + + let mut child = Command::new("ssh") + .args(["-o","BatchMode=yes","-o","ConnectTimeout=5",&mother,"report-task-cost"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("ssh spawn failed"); + + if let Some(ref mut stdin) = child.stdin { + writeln!(stdin, "{payload}").unwrap(); + } + + let output = child.wait_with_output().expect("ssh wait failed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("INSERT 0 1"), + "expected INSERT 0 1\ngot: {}\nstderr: {}", + stdout.trim(), + String::from_utf8_lossy(&output.stderr).trim()); +} + +/// Verify SSH forced command works for tools discovery. +#[test] +fn ssh_mother_tools_lists_daemon_tools() { + let mother = match std::env::var("COLIBRI_MOTHER_HOST") { + Ok(h) => h, + Err(_) => { eprintln!("SKIP: COLIBRI_MOTHER_HOST not set"); return; } + }; + + let output = Command::new("ssh") + .args(["-o","BatchMode=yes","-o","ConnectTimeout=5",&mother,"tools"]) + .output() + .expect("ssh tools failed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("colibri_list_tasks"), "tools missing list_tasks"); + assert!(stdout.contains("colibri_external_mcp_servers"), "tools missing external_mcp_servers"); +} + +/// Verify unknown commands are rejected by the forced-command wrapper. +#[test] +fn ssh_mother_rejects_unknown_commands() { + let mother = match std::env::var("COLIBRI_MOTHER_HOST") { + Ok(h) => h, + Err(_) => { eprintln!("SKIP: COLIBRI_MOTHER_HOST not set"); return; } + }; + + let output = Command::new("ssh") + .args(["-o","BatchMode=yes","-o","ConnectTimeout=5",&mother,"bogus-command"]) + .output() + .expect("ssh failed"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "bogus command should be rejected"); + assert!(stderr.contains("rejected"), "should reject unknown commands, got: {}", stderr.trim()); +} diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index 2a3fbf3..50c1c50 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -209,7 +209,7 @@ pub fn tool_list() -> Vec { ), // ── Deploy tools ── json_tool( - "colibrie_deploy_run", + "colibri_deploy_run", "Run a shell command on the host or in a Bastille jail. Use deploy_targets to list available targets.", Some(serde_json::json!({ "type": "object", @@ -221,7 +221,7 @@ pub fn tool_list() -> Vec { })), ), json_tool( - "colibrie_deploy_targets", + "colibri_deploy_targets", "List deploy targets: host and available Bastille jails.", None, ), diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs index 978fefa..3bf99ea 100644 --- a/crates/colibri-mcp/tests/tool_dispatch.rs +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -255,5 +255,84 @@ fn tool_list_has_all_phase1_tools() { assert!(names.contains(&"colibri_list_task_costs")); assert!(names.contains(&"colibri_get_task")); + assert!(names.contains(&"colibri_deploy_run")); + assert!(names.contains(&"colibri_deploy_targets")); + assert!(names.contains(&"colibri_wiki_search")); + assert!(names.contains(&"colibri_wiki_page")); + assert!(names.contains(&"colibri_zfs_list_snapshots")); + assert!(names.contains(&"colibri_zfs_destroy_snapshot")); + assert!(names.contains(&"colibri_pf_list_rules")); + assert!(names.contains(&"colibri_pf_list_states")); assert_eq!(names.len(), 20); } + +#[tokio::test] +async fn tool_list_task_costs_returns_cost_data() { + let socket = mock_daemon(json!({ + "ok": true, + "data": [{ + "id": "tc1", + "title": "cost test task", + "status": "Done", + "provider": "deepseek", + "model": "deepseek-chat", + "input_tokens": 45000, + "output_tokens": 2800, + "cache_read_tokens": 12000, + "cost_usd": 0.0042, + "success": true + }] + })) + .await; + + let client = DaemonClient::new(&socket); + let cfg = config(socket, false); + + let result = dispatch_tool(&client, &cfg, "colibri_list_task_costs", &json!({})) + .await + .unwrap(); + + let result_val: serde_json::Value = serde_json::to_value(&result).unwrap(); + let text = result_val["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("tc1")); + assert!(text.contains("deepseek")); + assert!(text.contains("0.0042")); +} + +#[tokio::test] +async fn tool_get_task_returns_cost_fields() { + let socket = mock_daemon(json!({ + "ok": true, + "data": [{ + "id": "tc99", + "title": "one task with cost", + "status": "Done", + "provider": "anthropic", + "model": "claude-sonnet-4", + "input_tokens": 1000, + "output_tokens": 500, + "cache_read_tokens": 300, + "cost_usd": 0.89, + "success": true + }] + })) + .await; + + let client = DaemonClient::new(&socket); + let cfg = config(socket, false); + + let result = dispatch_tool( + &client, + &cfg, + "colibri_get_task", + &json!({"task_id": "tc99"}), + ) + .await + .unwrap(); + + let result_val: serde_json::Value = serde_json::to_value(&result).unwrap(); + let text = result_val["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("tc99")); + assert!(text.contains("claude-sonnet-4")); + assert!(text.contains("0.89")); +}