c118996746
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge) - Agentic AI loop: LLM plans → tool calls → execution → feedback loop - 15 agent tools: file ops, terminal, git, xcode build, code intel - 7 persistent terminal tools with background session management - Chat sidebar with agent step rendering and auto-apply - NVIDIA NIM API integration (Llama 3.3 70B default) - OpenAI tool_calls format with prompt-based fallback - Code editor with syntax highlighting and multi-tab support - File tree, console view, terminal view - Git integration and workspace management
496 lines
20 KiB
Swift
496 lines
20 KiB
Swift
// TerminalTools.swift
|
|
// CxSwiftAgent — Terminal Execution MCP Tools
|
|
//
|
|
// 7 tools: terminal_exec, terminal_exec_bg, terminal_send, terminal_read,
|
|
// terminal_kill, terminal_list, terminal_env
|
|
//
|
|
// Provides full terminal control: run commands, start background processes,
|
|
// send input, read output, manage processes.
|
|
|
|
import Foundation
|
|
|
|
/// Manages background terminal sessions that persist across tool calls
|
|
actor TerminalSessionManager {
|
|
static let shared = TerminalSessionManager()
|
|
|
|
struct ManagedSession: Sendable {
|
|
let id: String
|
|
let label: String
|
|
let pid: Int32
|
|
let startTime: Date
|
|
let command: String
|
|
}
|
|
|
|
enum SessionError: Error {
|
|
case startFailed(String)
|
|
}
|
|
|
|
private var sessions: [String: BackgroundTerminal] = [:]
|
|
|
|
func create(id: String, label: String, command: String, cwd: String, env: [String: String]) async -> Result<ManagedSession, SessionError> {
|
|
let terminal = BackgroundTerminal()
|
|
let result = await terminal.start(command: command, cwd: cwd, env: env)
|
|
if let pid = result.pid {
|
|
sessions[id] = terminal
|
|
return .success(ManagedSession(id: id, label: label, pid: pid, startTime: Date(), command: command))
|
|
} else {
|
|
return .failure(.startFailed(result.error ?? "Unknown error"))
|
|
}
|
|
}
|
|
|
|
func send(id: String, input: String) -> Bool {
|
|
guard let session = sessions[id] else { return false }
|
|
session.send(input)
|
|
return true
|
|
}
|
|
|
|
func read(id: String, lines: Int) -> String? {
|
|
guard let session = sessions[id] else { return nil }
|
|
return session.getOutput(lastLines: lines)
|
|
}
|
|
|
|
func kill(id: String) -> Bool {
|
|
guard let session = sessions[id] else { return false }
|
|
session.terminate()
|
|
sessions.removeValue(forKey: id)
|
|
return true
|
|
}
|
|
|
|
func isRunning(id: String) -> Bool {
|
|
sessions[id]?.isAlive ?? false
|
|
}
|
|
|
|
func listSessions() -> [ManagedSession] {
|
|
// Can't easily reconstruct ManagedSession without storing metadata,
|
|
// so we store ids and return basic info
|
|
return sessions.compactMap { (id, terminal) in
|
|
ManagedSession(id: id, label: id, pid: terminal.pid, startTime: Date(), command: "")
|
|
}
|
|
}
|
|
|
|
func allIDs() -> [String] {
|
|
Array(sessions.keys)
|
|
}
|
|
}
|
|
|
|
/// A background terminal process with stdin/stdout capture
|
|
final class BackgroundTerminal: @unchecked Sendable {
|
|
private var process: Process?
|
|
private var inputPipe: Pipe?
|
|
private let outputLock = NSLock()
|
|
private var outputLines: [String] = []
|
|
private let maxLines = 1000
|
|
|
|
var pid: Int32 { process?.processIdentifier ?? -1 }
|
|
var isAlive: Bool { process?.isRunning ?? false }
|
|
|
|
func start(command: String, cwd: String, env: [String: String]) async -> (pid: Int32?, error: String?) {
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
proc.arguments = ["-c", command]
|
|
proc.currentDirectoryURL = URL(fileURLWithPath: cwd)
|
|
|
|
var procEnv = ProcessInfo.processInfo.environment
|
|
for (k, v) in env { procEnv[k] = v }
|
|
procEnv["TERM"] = "dumb"
|
|
procEnv.removeValue(forKey: "API_KEY")
|
|
procEnv.removeValue(forKey: "SECRET")
|
|
proc.environment = procEnv
|
|
|
|
let inPipe = Pipe()
|
|
let outPipe = Pipe()
|
|
let errPipe = Pipe()
|
|
proc.standardInput = inPipe
|
|
proc.standardOutput = outPipe
|
|
proc.standardError = errPipe
|
|
|
|
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
|
self?.appendOutput(text)
|
|
}
|
|
errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
|
self?.appendOutput("[stderr] \(text)")
|
|
}
|
|
|
|
do {
|
|
try proc.run()
|
|
process = proc
|
|
inputPipe = inPipe
|
|
return (pid: proc.processIdentifier, error: nil)
|
|
} catch {
|
|
return (pid: nil, error: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func send(_ input: String) {
|
|
guard let pipe = inputPipe, isAlive else { return }
|
|
if let data = (input + "\n").data(using: .utf8) {
|
|
pipe.fileHandleForWriting.write(data)
|
|
}
|
|
}
|
|
|
|
func getOutput(lastLines: Int) -> String {
|
|
outputLock.lock()
|
|
defer { outputLock.unlock() }
|
|
let count = min(lastLines, outputLines.count)
|
|
return outputLines.suffix(count).joined(separator: "\n")
|
|
}
|
|
|
|
func terminate() {
|
|
process?.terminate()
|
|
process = nil
|
|
inputPipe = nil
|
|
}
|
|
|
|
private func appendOutput(_ text: String) {
|
|
outputLock.lock()
|
|
defer { outputLock.unlock() }
|
|
let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
|
outputLines.append(contentsOf: lines)
|
|
if outputLines.count > maxLines {
|
|
outputLines.removeFirst(outputLines.count - maxLines)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TerminalTools {
|
|
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
|
|
|
|
// ── terminal_exec ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_exec",
|
|
description: "Execute a shell command and wait for completion. Returns stdout, stderr, and exit code. Use for commands that finish quickly (< 30s). For long-running processes, use terminal_exec_bg.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["command"],
|
|
"properties": [
|
|
"command": ["type": "string", "description": "Shell command to execute."],
|
|
"timeout": ["type": "integer", "description": "Timeout in seconds (default: 30, max: 120)."],
|
|
"cwd": ["type": "string", "description": "Working directory (default: workspace root)."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(destructiveHint: true)
|
|
) { args in
|
|
guard let command = args["command"] as? String, !command.isEmpty else {
|
|
return err("Missing command")
|
|
}
|
|
|
|
// Security: block dangerous commands
|
|
if let blocked = checkBlocked(command, config: config) {
|
|
return blocked
|
|
}
|
|
|
|
let cwd = config.resolvePath(args["cwd"] as? String ?? ".") ?? config.workspaceRoot
|
|
let timeout = min(args["timeout"] as? Int ?? 30, 120)
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
process.arguments = ["-c", command]
|
|
process.currentDirectoryURL = URL(fileURLWithPath: cwd)
|
|
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["TERM"] = "dumb"
|
|
env.removeValue(forKey: "API_KEY")
|
|
env.removeValue(forKey: "SECRET")
|
|
process.environment = env
|
|
|
|
let pipe = Pipe()
|
|
let errPipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = errPipe
|
|
|
|
do {
|
|
try process.run()
|
|
|
|
let deadline = DispatchTime.now() + .seconds(timeout)
|
|
let group = DispatchGroup()
|
|
group.enter()
|
|
DispatchQueue.global().async {
|
|
process.waitUntilExit()
|
|
group.leave()
|
|
}
|
|
|
|
let result = group.wait(timeout: deadline)
|
|
if result == .timedOut {
|
|
process.terminate()
|
|
continuation.resume(returning: err("Command timed out after \(timeout)s. Use terminal_exec_bg for long-running processes."))
|
|
return
|
|
}
|
|
|
|
let outData = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let output = String(data: outData, encoding: .utf8) ?? ""
|
|
let errOutput = String(data: errData, encoding: .utf8) ?? ""
|
|
|
|
let exitCode = process.terminationStatus
|
|
var text = ""
|
|
if !output.isEmpty { text += output.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
if !errOutput.isEmpty {
|
|
let trimmedErr = errOutput.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
text += text.isEmpty ? trimmedErr : "\n--- stderr ---\n\(trimmedErr)"
|
|
}
|
|
text += "\n[exit code: \(exitCode)]"
|
|
|
|
// Truncate very large output
|
|
if text.count > 8000 {
|
|
let head = String(text.prefix(3500))
|
|
let tail = String(text.suffix(3500))
|
|
text = head + "\n\n... (\(text.count - 7000) chars truncated) ...\n\n" + tail
|
|
}
|
|
|
|
if exitCode != 0 {
|
|
continuation.resume(returning: err(text))
|
|
} else {
|
|
continuation.resume(returning: ok(text))
|
|
}
|
|
} catch {
|
|
continuation.resume(returning: err("Failed to execute: \(error.localizedDescription)"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── terminal_exec_bg ──────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_exec_bg",
|
|
description: "Start a long-running background process (e.g., server, watcher, build). Returns a session ID to read output or send input later.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["command"],
|
|
"properties": [
|
|
"command": ["type": "string", "description": "Shell command to run in background."],
|
|
"label": ["type": "string", "description": "Human-readable label for this session (e.g., 'dev-server')."],
|
|
"cwd": ["type": "string", "description": "Working directory (default: workspace root)."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(destructiveHint: true)
|
|
) { args in
|
|
guard let command = args["command"] as? String, !command.isEmpty else {
|
|
return err("Missing command")
|
|
}
|
|
if let blocked = checkBlocked(command, config: config) {
|
|
return blocked
|
|
}
|
|
|
|
let label = args["label"] as? String ?? String(command.prefix(30))
|
|
let cwd = config.resolvePath(args["cwd"] as? String ?? ".") ?? config.workspaceRoot
|
|
let sessionID = label.lowercased()
|
|
.replacingOccurrences(of: " ", with: "-")
|
|
.replacingOccurrences(of: "/", with: "-")
|
|
.prefix(30)
|
|
.description
|
|
|
|
let mgr = TerminalSessionManager.shared
|
|
let result = await mgr.create(
|
|
id: sessionID,
|
|
label: label,
|
|
command: command,
|
|
cwd: cwd,
|
|
env: [:]
|
|
)
|
|
|
|
switch result {
|
|
case .success(let session):
|
|
// Wait a moment for initial output
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
let initialOutput = await mgr.read(id: sessionID, lines: 10) ?? "(no output yet)"
|
|
return ok("""
|
|
Background process started.
|
|
Session ID: \(session.id)
|
|
PID: \(session.pid)
|
|
Command: \(command)
|
|
|
|
Initial output:
|
|
\(initialOutput)
|
|
|
|
Use terminal_read(id: "\(session.id)") to check output.
|
|
Use terminal_send(id: "\(session.id)", input: "...") to send input.
|
|
Use terminal_kill(id: "\(session.id)") to stop.
|
|
""")
|
|
case .failure(let error):
|
|
return err("Failed to start: \(error)")
|
|
}
|
|
}
|
|
|
|
// ── terminal_send ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_send",
|
|
description: "Send input (text/keystrokes) to a running background terminal session. Use for interactive processes that need input.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["id", "input"],
|
|
"properties": [
|
|
"id": ["type": "string", "description": "Session ID from terminal_exec_bg."],
|
|
"input": ["type": "string", "description": "Text to send (newline appended automatically)."],
|
|
] as [String: Any],
|
|
] as [String: Any]
|
|
) { args in
|
|
guard let id = args["id"] as? String else { return err("Missing session id") }
|
|
guard let input = args["input"] as? String else { return err("Missing input") }
|
|
|
|
let mgr = TerminalSessionManager.shared
|
|
let sent = await mgr.send(id: id, input: input)
|
|
|
|
if sent {
|
|
// Wait for response
|
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
let output = await mgr.read(id: id, lines: 20) ?? "(no output)"
|
|
return ok("Sent input to '\(id)'.\n\nRecent output:\n\(output)")
|
|
} else {
|
|
return err("Session '\(id)' not found or not running. Use terminal_list to see active sessions.")
|
|
}
|
|
}
|
|
|
|
// ── terminal_read ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_read",
|
|
description: "Read recent output from a background terminal session.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": [
|
|
"id": ["type": "string", "description": "Session ID from terminal_exec_bg."],
|
|
"lines": ["type": "integer", "description": "Number of recent lines to read (default: 50, max: 200)."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { args in
|
|
guard let id = args["id"] as? String else { return err("Missing session id") }
|
|
let lineCount = min(args["lines"] as? Int ?? 50, 200)
|
|
|
|
let mgr = TerminalSessionManager.shared
|
|
let running = await mgr.isRunning(id: id)
|
|
|
|
if let output = await mgr.read(id: id, lines: lineCount) {
|
|
let status = running ? "running" : "exited"
|
|
return ok("Session '\(id)' [\(status)]:\n\n\(output)")
|
|
} else {
|
|
return err("Session '\(id)' not found. Use terminal_list to see active sessions.")
|
|
}
|
|
}
|
|
|
|
// ── terminal_kill ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_kill",
|
|
description: "Terminate a background terminal session.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"required": ["id"],
|
|
"properties": [
|
|
"id": ["type": "string", "description": "Session ID to kill."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(destructiveHint: true)
|
|
) { args in
|
|
guard let id = args["id"] as? String else { return err("Missing session id") }
|
|
|
|
let mgr = TerminalSessionManager.shared
|
|
if await mgr.kill(id: id) {
|
|
return ok("Session '\(id)' terminated.")
|
|
} else {
|
|
return err("Session '\(id)' not found.")
|
|
}
|
|
}
|
|
|
|
// ── terminal_list ─────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_list",
|
|
description: "List all active background terminal sessions.",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [:] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { _ in
|
|
let mgr = TerminalSessionManager.shared
|
|
let ids = await mgr.allIDs()
|
|
|
|
if ids.isEmpty {
|
|
return ok("No active background sessions.")
|
|
}
|
|
|
|
var output = "Active sessions:\n"
|
|
for id in ids {
|
|
let running = await mgr.isRunning(id: id)
|
|
let status = running ? "🟢 running" : "⚫ exited"
|
|
output += " • \(id) — \(status)\n"
|
|
}
|
|
return ok(output)
|
|
}
|
|
|
|
// ── terminal_env ──────────────────────────────────────────────
|
|
server.registerTool(
|
|
"terminal_env",
|
|
description: "List environment variables (sensitive values are redacted).",
|
|
inputSchema: [
|
|
"type": "object",
|
|
"properties": [
|
|
"filter": ["type": "string", "description": "Filter by variable name prefix (case-insensitive)."],
|
|
] as [String: Any],
|
|
] as [String: Any],
|
|
annotations: ToolAnnotations(readOnlyHint: true)
|
|
) { args in
|
|
let filter = (args["filter"] as? String)?.lowercased()
|
|
let env = ProcessInfo.processInfo.environment
|
|
|
|
let sensitiveKeys = [
|
|
"api_key", "secret", "password", "token", "private_key",
|
|
"credential", "auth", "nvidia_api", "ngc_api", "aws_secret",
|
|
]
|
|
|
|
var output = ""
|
|
for (key, value) in env.sorted(by: { $0.key < $1.key }) {
|
|
if let f = filter, !key.lowercased().hasPrefix(f) { continue }
|
|
|
|
let isSensitive = sensitiveKeys.contains { key.lowercased().contains($0) }
|
|
let displayValue = isSensitive ? "***REDACTED***" : value
|
|
output += "\(key)=\(displayValue)\n"
|
|
}
|
|
|
|
return ok(output.isEmpty ? "No matching environment variables" : output)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private static func checkBlocked(_ command: String, config: AgentConfig) -> [[String: Any]]? {
|
|
let blocked = [
|
|
"rm -rf /", "rm -rf /*", "mkfs", "dd if=", "shutdown", "reboot",
|
|
"halt", "poweroff", "init 0", "init 6", ":(){", "fork bomb",
|
|
"chmod -R 777 /", "chown -R", "> /dev/sd", "curl | sh", "wget | sh",
|
|
]
|
|
let lowerCmd = command.lowercased()
|
|
for pattern in blocked {
|
|
if lowerCmd.contains(pattern) {
|
|
return err("Command blocked for safety: \(pattern)")
|
|
}
|
|
}
|
|
|
|
if config.sandboxMode {
|
|
let readOnlyPrefixes = [
|
|
"ls", "cat", "head", "tail", "grep", "find", "wc", "file",
|
|
"which", "whoami", "pwd", "echo", "date", "uname", "env",
|
|
"swift --version", "git status", "git log", "git diff",
|
|
]
|
|
let isReadOnly = readOnlyPrefixes.contains { lowerCmd.hasPrefix($0) }
|
|
if !isReadOnly {
|
|
return err("Command not allowed in sandbox mode. Read-only commands only.")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func ok(_ text: String) -> [[String: Any]] {
|
|
[["type": "text", "text": text]]
|
|
}
|
|
|
|
private static func err(_ message: String) -> [[String: Any]] {
|
|
[["type": "text", "text": "Error: \(message)"]]
|
|
}
|
|
}
|