This commit is contained in:
2026-02-25 08:24:05 +01:00
parent 914d608d35
commit 3997f3feee
7 changed files with 504 additions and 105 deletions

View File

@@ -273,7 +273,7 @@ final class EmailHandlerService {
---
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
Please provide a complete, well-formatted response to this email. Write the reply directly — do not wrap it in code fences or markdown blocks.
"""
return prompt
@@ -299,7 +299,8 @@ final class EmailHandlerService {
- Be professional and courteous
- Keep responses concise and relevant
- Use proper email etiquette
- Format your response using Markdown (it will be converted to HTML)
- Format your response using Markdown (bold, lists, headings are welcome)
- Do NOT wrap your response in code fences or ```html blocks — write the email body directly
- If you need information from files, you have read-only access via MCP tools
- Never claim to write, modify, or delete files (read-only access)
- Sign emails appropriately
@@ -412,27 +413,46 @@ final class EmailHandlerService {
}
private func markdownToHTML(_ markdown: String) -> String {
// Basic markdown to HTML conversion
// TODO: Use proper markdown parser for production
var html = markdown
var text = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
// Paragraphs
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
html = "<p>\(html)</p>"
// Strip outer code fence wrapping the entire response
// Models sometimes wrap their reply in ```html ... ``` or ``` ... ```
let lines = text.components(separatedBy: "\n")
if lines.count >= 2 {
let first = lines[0].trimmingCharacters(in: .whitespaces)
let last = lines[lines.count - 1].trimmingCharacters(in: .whitespaces)
if (first == "```" || first.hasPrefix("```")) && last == "```" {
text = lines.dropFirst().dropLast().joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// Strip any remaining fenced code blocks (preserve content, remove fences)
text = text.replacingOccurrences(
of: #"```[a-z]*\n([\s\S]*?)```"#,
with: "$1",
options: .regularExpression
)
// Bold
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
text = text.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
// Italic
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
// Italic (avoid matching bold's leftover *)
text = text.replacingOccurrences(of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#, with: "<em>$1</em>", options: .regularExpression)
// Inline code
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
text = text.replacingOccurrences(of: #"`([^`\n]+)`"#, with: "<code>$1</code>", options: .regularExpression)
// Line breaks
html = html.replacingOccurrences(of: "\n", with: "<br>")
// Split into paragraphs on double newlines, wrap each in <p>
let paragraphs = text.components(separatedBy: "\n\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { para in
let withBreaks = para.replacingOccurrences(of: "\n", with: "<br>")
return "<p>\(withBreaks)</p>"
}
return html
return paragraphs.joined(separator: "\n")
}
// MARK: - Error Handling

View File

@@ -112,6 +112,18 @@ class MCPService {
private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.shared
// MARK: - Bash Approval State
struct PendingBashCommand: Identifiable {
let id = UUID()
let command: String
let workingDirectory: String
}
private(set) var pendingBashCommand: PendingBashCommand? = nil
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var bashSessionApproved: Bool = false
// MARK: - Tool Schema Generation
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
@@ -220,6 +232,21 @@ class MCPService {
tools.append(contentsOf: paperlessService.getToolSchemas())
}
// Add bash_execute tool when bash is enabled
if settings.bashEnabled {
let workDir = settings.bashWorkingDirectory
let timeout = settings.bashTimeout
let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : ""
tools.append(makeTool(
name: "bash_execute",
description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)",
properties: [
"command": prop("string", "The shell command to execute")
],
required: ["command"]
))
}
// Add web_search tool when online mode is active
// (OpenRouter handles search natively via :online model suffix, so excluded here)
if onlineMode {
@@ -346,6 +373,20 @@ class MCPService {
}
return copyFile(source: source, destination: destination)
case "bash_execute":
guard settings.bashEnabled else {
return ["error": "Bash execution is disabled. Enable it in Settings > MCP."]
}
guard let command = args["command"] as? String, !command.isEmpty else {
return ["error": "Missing required parameter: command"]
}
let workDir = settings.bashWorkingDirectory
if settings.bashRequireApproval {
return await executeBashWithApproval(command: command, workingDirectory: workDir)
} else {
return await runBashCommand(command, workingDirectory: workDir)
}
case "web_search":
let query = args["query"] as? String ?? ""
guard !query.isEmpty else {
@@ -706,6 +747,113 @@ class MCPService {
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
// MARK: - Bash Execution
private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] {
// If the user already approved all commands for this session, skip the UI
if bashSessionApproved {
return await runBashCommand(command, workingDirectory: workingDirectory)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
self.pendingBashContinuation = continuation
}
}
}
func approvePendingBashCommand(forSession: Bool = false) {
guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return }
pendingBashCommand = nil
pendingBashContinuation = nil
if forSession {
bashSessionApproved = true
}
Task.detached(priority: .userInitiated) {
let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory)
cont.resume(returning: result)
}
}
func denyPendingBashCommand() {
guard pendingBashCommand != nil else { return }
pendingBashCommand = nil
pendingBashContinuation?.resume(returning: ["error": "User denied command execution"])
pendingBashContinuation = nil
}
func resetBashSessionApproval() {
bashSessionApproved = false
}
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
let timeoutSeconds = settings.bashTimeout
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
Log.mcp.info("bash_execute: \(command)")
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", command]
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue {
process.currentDirectoryURL = URL(fileURLWithPath: workDir)
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
var timedOut = false
let timeoutItem = DispatchWorkItem {
if process.isRunning {
timedOut = true
process.terminate()
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem)
do {
try process.run()
process.waitUntilExit()
} catch {
timeoutItem.cancel()
Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)")
continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"])
return
}
timeoutItem.cancel()
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let exitCode = Int(process.terminationStatus)
Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)")
var result: [String: Any] = ["exit_code": exitCode]
if !stdout.isEmpty {
let maxOut = 20_000
result["stdout"] = stdout.count > maxOut
? String(stdout.prefix(maxOut)) + "\n... (output truncated)"
: stdout
}
if !stderr.isEmpty {
result["stderr"] = String(stderr.prefix(5_000))
}
if timedOut {
result["timed_out"] = true
result["note"] = "Command terminated after \(timeoutSeconds)s timeout"
}
continuation.resume(returning: result)
}
}
}
// MARK: - Gitignore Support
/// Reload gitignore rules for all allowed folders

View File

@@ -447,6 +447,40 @@ class SettingsService {
return !key.isEmpty
}
// MARK: - Bash Execution Settings
var bashEnabled: Bool {
get { cache["bashEnabled"] == "true" }
set {
cache["bashEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashEnabled", value: String(newValue))
}
}
var bashRequireApproval: Bool {
get { cache["bashRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["bashRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashRequireApproval", value: String(newValue))
}
}
var bashWorkingDirectory: String {
get { cache["bashWorkingDirectory"] ?? "~" }
set {
cache["bashWorkingDirectory"] = newValue
DatabaseService.shared.setSetting(key: "bashWorkingDirectory", value: newValue)
}
}
var bashTimeout: Int {
get { cache["bashTimeout"].flatMap(Int.init) ?? 30 }
set {
cache["bashTimeout"] = String(newValue)
DatabaseService.shared.setSetting(key: "bashTimeout", value: String(newValue))
}
}
// MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool {