diff --git a/Makefile b/Makefile index bd723bd..0bb1727 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,24 @@ deploy: test build noc.service systemctl --user enable --now noc systemctl --user restart noc +SUITE := noc +SUITE_DIR := noc +GITEA_VERSION := 1.23 + +deploy-suite: build + ssh $(SUITE) 'mkdir -p ~/bin /data/noc/tools ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true' + scp target/release/noc $(SUITE):~/bin/ + scp config.suite.yaml $(SUITE):/data/noc/config.yaml + scp noc.service.in $(SUITE):/data/noc/ + scp -r tools/ $(SUITE):/data/noc/tools/ + ssh $(SUITE) 'bash -lc "\ + cd /data/noc \ + && sed -e \"s|@REPO@|/data/noc|g\" -e \"s|@PATH@|\$$PATH|g\" noc.service.in > ~/.config/systemd/user/noc.service \ + && systemctl --user daemon-reload \ + && systemctl --user enable --now noc \ + && systemctl --user restart noc \ + && systemctl --user status noc"' + deploy-hera: build ssh $(HERA) 'mkdir -p ~/bin ~/$(HERA_DIR) ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true' scp target/release/noc $(HERA):~/bin/ diff --git a/src/tools.rs b/src/tools.rs index 989ae97..368d8ea 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -198,6 +198,41 @@ pub fn discover_tools() -> serde_json::Value { } } }), + serde_json::json!({ + "type": "function", + "function": { + "name": "run_shell", + "description": "在服务器上执行 shell 命令。可执行任意 bash 命令,支持管道和重定向。超时 60 秒。", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "要执行的 shell 命令"}, + "timeout": {"type": "integer", "description": "超时秒数(默认 60,最大 300)"} + }, + "required": ["command"] + } + } + }), + serde_json::json!({ + "type": "function", + "function": { + "name": "run_python", + "description": "用 uv run 执行 Python 代码。支持 inline dependencies(通过 deps 参数自动安装),无需手动管理虚拟环境。超时 120 秒。", + "parameters": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "要执行的 Python 代码"}, + "deps": { + "type": "array", + "items": {"type": "string"}, + "description": "依赖包列表(如 [\"requests\", \"pandas\"]),会自动通过 uv 安装" + }, + "timeout": {"type": "integer", "description": "超时秒数(默认 120,最大 300)"} + }, + "required": ["code"] + } + } + }), serde_json::json!({ "type": "function", "function": { @@ -360,6 +395,118 @@ pub async fn execute_tool( Err(e) => format!("Error: {e}"), } } + "run_shell" => { + let cmd = args["command"].as_str().unwrap_or(""); + if cmd.is_empty() { + return "Error: command is required".to_string(); + } + let timeout_secs = args["timeout"].as_u64().unwrap_or(60).min(300); + info!(cmd = %cmd, "run_shell"); + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + Command::new("bash") + .args(["-c", cmd]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(), + ) + .await; + match result { + Ok(Ok(out)) => { + let mut s = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.is_empty() { + if !s.is_empty() { + s.push_str("\n[stderr]\n"); + } + s.push_str(&stderr); + } + let exit = out.status.code().unwrap_or(-1); + if s.len() > 8000 { + s = format!("{}...(truncated)", &s[..8000]); + } + if exit != 0 { + s.push_str(&format!("\n[exit={exit}]")); + } + if s.is_empty() { + format!("(exit={exit})") + } else { + s + } + } + Ok(Err(e)) => format!("exec error: {e}"), + Err(_) => format!("timeout after {timeout_secs}s"), + } + } + "run_python" => { + let code = args["code"].as_str().unwrap_or(""); + if code.is_empty() { + return "Error: code is required".to_string(); + } + let timeout_secs = args["timeout"].as_u64().unwrap_or(120).min(300); + let deps = args["deps"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + // Build uv run command with inline script metadata for deps + let script = if deps.is_empty() { + code.to_string() + } else { + let dep_lines: String = deps.iter().map(|d| format!("# \"{d}\",\n")).collect(); + format!( + "# /// script\n# [project]\n# dependencies = [\n{dep_lines}# ]\n# ///\n{code}" + ) + }; + + // Write script to temp file + let tmp = format!("/tmp/noc_py_{}.py", std::process::id()); + if let Err(e) = std::fs::write(&tmp, &script) { + return format!("Failed to write temp script: {e}"); + } + + info!(deps = ?deps, "run_python"); + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + Command::new("uv") + .args(["run", &tmp]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(), + ) + .await; + let _ = std::fs::remove_file(&tmp); + match result { + Ok(Ok(out)) => { + let mut s = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.is_empty() { + if !s.is_empty() { + s.push_str("\n[stderr]\n"); + } + s.push_str(&stderr); + } + let exit = out.status.code().unwrap_or(-1); + if s.len() > 8000 { + s = format!("{}...(truncated)", &s[..8000]); + } + if exit != 0 { + s.push_str(&format!("\n[exit={exit}]")); + } + if s.is_empty() { + format!("(exit={exit})") + } else { + s + } + } + Ok(Err(e)) => format!("exec error: {e} (is uv installed?)"), + Err(_) => format!("timeout after {timeout_secs}s"), + } + } "call_gitea_api" => { let method = args["method"].as_str().unwrap_or("GET").to_uppercase(); let path = args["path"].as_str().unwrap_or("").trim_start_matches('/');