1use crate::registry::{Tool, ToolContext};
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::{json, Value};
16use std::process::Stdio;
17
18pub struct ServiceTool;
20
21impl ServiceTool {
22 pub fn new() -> Self {
24 ServiceTool
25 }
26}
27
28impl Default for ServiceTool {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34const READ_ACTIONS: &[&str] = &["status", "is-active", "is-enabled", "journal", "list"];
35const WRITE_ACTIONS: &[&str] = &[
36 "start", "stop", "restart", "reload", "enable", "disable",
37];
38
39fn valid_unit(unit: &str) -> bool {
41 !unit.is_empty()
42 && unit.len() <= 128
43 && unit
44 .chars()
45 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | ':' | '\\'))
46}
47
48#[async_trait]
49impl Tool for ServiceTool {
50 fn name(&self) -> &str {
51 "service"
52 }
53
54 fn description(&self) -> &str {
55 "Inspect or manage systemd services. Read actions: status, is-active, is-enabled, journal, list. Managing actions (start/stop/restart/reload/enable/disable) require approval. Linux/systemd only."
56 }
57
58 fn parameters(&self) -> Value {
59 json!({
60 "type": "object",
61 "properties": {
62 "action": {
63 "type": "string",
64 "enum": ["status", "is-active", "is-enabled", "journal", "list",
65 "start", "stop", "restart", "reload", "enable", "disable"],
66 "description": "What to do"
67 },
68 "unit": { "type": "string", "description": "Service unit name, e.g. 'nginx' or 'aegis.service' (not needed for 'list')" },
69 "lines": { "type": "integer", "description": "journal only: number of recent log lines (default 50)" }
70 },
71 "required": ["action"]
72 })
73 }
74
75 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
76 let action = args["action"].as_str().unwrap_or("").trim();
77 if action.is_empty() {
78 return Ok("Error: action is required".to_string());
79 }
80 let is_read = READ_ACTIONS.contains(&action);
81 let is_write = WRITE_ACTIONS.contains(&action);
82 if !is_read && !is_write {
83 return Ok(format!("Error: unknown action '{action}'"));
84 }
85
86 let unit = args["unit"].as_str().unwrap_or("").trim().to_string();
88 if action != "list" {
89 if unit.is_empty() {
90 return Ok("Error: 'unit' is required for this action".to_string());
91 }
92 if !valid_unit(&unit) {
93 return Ok("Error: invalid unit name (allowed: alphanumerics . _ - @ :)".to_string());
94 }
95 }
96
97 if is_write
99 && !ctx.approve(&format!("systemctl {action} {unit} — this changes a live service"))
100 {
101 return Ok(format!("Service '{action} {unit}' cancelled: user did not approve."));
102 }
103
104 let (program, cmd_args): (&str, Vec<String>) = match action {
106 "journal" => {
107 let lines = args["lines"].as_u64().unwrap_or(50).min(2000);
108 (
109 "journalctl",
110 vec![
111 "-u".into(),
112 unit.clone(),
113 "-n".into(),
114 lines.to_string(),
115 "--no-pager".into(),
116 "--output".into(),
117 "short-iso".into(),
118 ],
119 )
120 }
121 "list" => (
122 "systemctl",
123 vec![
124 "list-units".into(),
125 "--type=service".into(),
126 "--no-pager".into(),
127 "--no-legend".into(),
128 ],
129 ),
130 other => ("systemctl", vec![other.into(), unit.clone(), "--no-pager".into()]),
131 };
132
133 let output = tokio::time::timeout(
134 std::time::Duration::from_secs(30),
135 tokio::process::Command::new(program)
136 .args(&cmd_args)
137 .stdout(Stdio::piped())
138 .stderr(Stdio::piped())
139 .output(),
140 )
141 .await
142 .map_err(|_| anyhow::anyhow!("{program} timed out"))?;
143
144 let output = match output {
145 Ok(o) => o,
146 Err(e) => {
147 return Ok(format!(
148 "Failed to run {program}: {e}. (This tool needs systemd; it only works on Linux servers using systemctl.)"
149 ));
150 }
151 };
152
153 let stdout = String::from_utf8_lossy(&output.stdout);
154 let stderr = String::from_utf8_lossy(&output.stderr);
155 let code = output.status.code().unwrap_or(-1);
156
157 let mut result = String::new();
160 if !stdout.trim().is_empty() {
161 result.push_str(stdout.trim_end());
162 }
163 if !stderr.trim().is_empty() {
164 if !result.is_empty() {
165 result.push('\n');
166 }
167 result.push_str(&format!("[stderr] {}", stderr.trim_end()));
168 }
169 if result.is_empty() {
170 result = format!("(exit {code}, no output)");
171 }
172 Ok(truncate(&result, 20_000))
174 }
175}
176
177fn truncate(s: &str, max: usize) -> String {
178 if s.len() <= max {
179 s.to_string()
180 } else {
181 let cut: String = s.chars().take(max).collect();
182 format!("{cut}\n... [truncated]")
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn valid_unit_accepts_and_rejects() {
192 assert!(valid_unit("nginx"));
193 assert!(valid_unit("aegis.service"));
194 assert!(valid_unit("getty@tty1.service"));
195 assert!(!valid_unit(""));
196 assert!(!valid_unit("nginx; rm -rf /"));
197 assert!(!valid_unit("a b"));
198 assert!(!valid_unit("$(whoami)"));
199 }
200}