diff --git a/README.md b/README.md index a1663fc..e517b14 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,13 @@ summary to stdout; `--write-summaries ~/.evalops/agentd/activity` writes an encrypted `.agentdbatch` files remain unreadable without the configured local batch key, and raw OCR is not copied into the summary layer. +For local agent context, run `agentd mcp` as a stdio MCP server. It exposes +three local tools: `agentd_device_snapshot` for redacted device/permission and +privacy-policy status, `agentd_activity_recent` for sanitized recent activity +from JSON batches, and `agentd_collect_diagnostics` for writing the same +Chronicle-style activity artifacts to a caller-provided local directory. The +MCP surface never returns raw frames or encrypted fallback batches. + `scripts/mock_chronicle.py` provides a strict local mock Chronicle and Secret Broker harness. CI validates the golden fixtures in `Tests/Fixtures/chronicle` so request-shape drift is explicit until generated `chronicle.v1` Swift types @@ -404,6 +411,7 @@ are available. Sources/agentd/ main.swift # NSApplication + AppController boot ChronicleControl.swift # RegisterDevice/Heartbeat + policy response client + AgentdMCP.swift # Local stdio MCP server for redacted device context ActivitySummary.swift # Sanitized local activity summaries/resources Diagnostics.swift # Redacted local report generation PauseState.swift # Manual/scheduled/policy pause precedence diff --git a/Sources/agentd/AgentdMCP.swift b/Sources/agentd/AgentdMCP.swift new file mode 100644 index 0000000..1e514b3 --- /dev/null +++ b/Sources/agentd/AgentdMCP.swift @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +struct AgentdMCPPermissionStatus: Codable, Equatable, Sendable { + let accessibilityTrusted: Bool + let screenCaptureTrusted: Bool + let menuSummary: String +} + +struct AgentdMCPPrivacyStatus: Codable, Equatable, Sendable { + let allowedBundleCount: Int + let deniedBundleCount: Int + let deniedPathPrefixCount: Int + let pauseTitlePatternCount: Int + let captureAllDisplays: Bool + let selectedDisplayIds: [UInt32] +} + +struct AgentdMCPLocalBatchStats: Codable, Equatable, Sendable { + let fileCount: Int + let bytes: Int64 + + init(fileCount: Int, bytes: Int64) { + self.fileCount = fileCount + self.bytes = bytes + } + + init(_ stats: LocalBatchStats) { + self.fileCount = stats.fileCount + self.bytes = stats.bytes + } +} + +struct AgentdMCPDeviceSnapshot: Codable, Equatable, Sendable { + let generatedAt: Date + let appVersion: String + let deviceId: String + let organizationId: String + let mode: String + let endpoint: String + let permissions: AgentdMCPPermissionStatus + let localBatchStats: AgentdMCPLocalBatchStats + let privacy: AgentdMCPPrivacyStatus +} + +struct AgentdMCPDiagnosticsResult: Codable, Equatable, Sendable { + let instructionsPath: String + let resourcePaths: [String] +} + +protocol AgentdMCPRuntime { + func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot + func activityRecent(options: ActivityOptions) async throws -> ActivitySummary + func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws + -> AgentdMCPDiagnosticsResult +} + +struct SystemAgentdMCPRuntime: AgentdMCPRuntime { + func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot { + let config = ConfigStore.load() + let permissions = await MainActor.run { + PermissionSnapshot.current(promptForAccessibility: false) + } + let submitter = try Submitter( + endpoint: config.endpoint, + localOnly: true, + authMode: .none, + maxBatchBytes: config.maxBatchBytes, + maxBatchAgeDays: config.maxBatchAgeDays, + deviceId: config.deviceId, + encryptLocalBatches: config.encryptLocalBatches + ) + let batchStats = await submitter.localBatchStats() + + return AgentdMCPDeviceSnapshot( + generatedAt: Date(), + appVersion: Bundle.main.appVersion, + deviceId: config.deviceId, + organizationId: config.organizationId, + mode: config.localOnly ? "local-only" : "managed", + endpoint: DiagnosticsReport.redactEndpoint(config.endpoint), + permissions: AgentdMCPPermissionStatus( + accessibilityTrusted: permissions.accessibilityTrusted, + screenCaptureTrusted: permissions.screenCaptureTrusted, + menuSummary: permissions.menuSummary + ), + localBatchStats: AgentdMCPLocalBatchStats(batchStats), + privacy: AgentdMCPPrivacyStatus( + allowedBundleCount: config.allowedBundleIds.count, + deniedBundleCount: config.deniedBundleIds.count, + deniedPathPrefixCount: config.deniedPathPrefixes.count, + pauseTitlePatternCount: config.pauseWindowTitlePatterns.count, + captureAllDisplays: config.captureAllDisplays, + selectedDisplayIds: config.selectedDisplayIds + ) + ) + } + + func activityRecent(options: ActivityOptions) async throws -> ActivitySummary { + try await ActivitySummary.run(options: options) + } + + func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws + -> AgentdMCPDiagnosticsResult + { + let summary = try await ActivitySummary.run(options: options) + let resource = try ActivitySummaryArtifacts.write(summary, root: outputDirectory) + return AgentdMCPDiagnosticsResult( + instructionsPath: outputDirectory.appendingPathComponent("instructions.md").path, + resourcePaths: [resource.path] + ) + } + +} + +struct AgentdMCPServer { + private let runtime: AgentdMCPRuntime + + init(runtime: AgentdMCPRuntime = SystemAgentdMCPRuntime()) { + self.runtime = runtime + } + + func handle(_ data: Data) async throws -> Data { + let request: AgentdMCPRequest + do { + request = try AgentdMCPRequest(data: data) + } catch { + return try errorResponse( + id: AgentdMCPRequest.bestEffortId(from: data), + code: AgentdMCPError.jsonRPCCode(for: error), + message: AgentdMCPError.message(for: error) + ) + } + + do { + switch request.method { + case "initialize": + return try response( + id: request.id, + result: [ + "protocolVersion": "2025-06-18", + "capabilities": ["tools": ["listChanged": false]], + "serverInfo": ["name": "agentd-local", "version": Bundle.main.appVersion], + ]) + case "notifications/initialized": + return Data() + case "tools/list": + return try response(id: request.id, result: ["tools": Self.toolCatalog()]) + case "tools/call": + return try await callTool(request) + default: + return try errorResponse(id: request.id, code: -32601, message: "method not found") + } + } catch { + return try errorResponse( + id: request.id, + code: AgentdMCPError.jsonRPCCode(for: error), + message: AgentdMCPError.message(for: error) + ) + } + } + + private func callTool(_ request: AgentdMCPRequest) async throws -> Data { + guard let params = request.params, + let name = params["name"] as? String + else { + return try errorResponse(id: request.id, code: -32602, message: "tools/call requires name") + } + let arguments = params["arguments"] as? [String: Any] ?? [:] + + switch name { + case "agentd_device_snapshot": + return try await toolResponse(id: request.id, value: runtime.deviceSnapshot()) + case "agentd_activity_recent": + let options = try activityOptions(from: arguments) + return try await toolResponse(id: request.id, value: runtime.activityRecent(options: options)) + case "agentd_collect_diagnostics": + let options = try activityOptions(from: arguments) + let outputDirectory = outputDirectory(from: arguments) + return try await toolResponse( + id: request.id, + value: runtime.collectDiagnostics(options: options, outputDirectory: outputDirectory) + ) + default: + return try errorResponse(id: request.id, code: -32602, message: "unknown tool '\(name)'") + } + } + + private func toolResponse(id: Any?, value: T) async throws -> Data { + let text = try Self.jsonString(value) + return try response( + id: id, + result: [ + "content": [ + [ + "type": "text", + "mimeType": "application/json", + "text": text, + ] + ], + "isError": false, + ]) + } + + private func activityOptions(from arguments: [String: Any]) throws -> ActivityOptions { + var raw: [String] = [] + if let window = arguments["window"] as? String { + raw += ["--window", window] + } + if let since = arguments["since"] { + raw += ["--since", String(describing: since)] + } + if let batchDirectory = arguments["batch_dir"] as? String { + raw += ["--batch-dir", batchDirectory] + } + return try ActivityOptions.parse(raw) + } + + private func outputDirectory(from arguments: [String: Any]) -> URL { + if let path = arguments["out_dir"] as? String, !path.isEmpty { + return URL(fileURLWithPath: path, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".evalops/agentd/mcp-diagnostics", isDirectory: true) + } + + private func response(id: Any?, result: [String: Any]) throws -> Data { + try Self.jsonData([ + "jsonrpc": "2.0", + "id": id ?? NSNull(), + "result": result, + ]) + } + + private func errorResponse(id: Any?, code: Int, message: String) throws -> Data { + try Self.jsonData([ + "jsonrpc": "2.0", + "id": id ?? NSNull(), + "error": ["code": code, "message": message], + ]) + } + + private static func toolCatalog() -> [[String: Any]] { + [ + [ + "name": "agentd_device_snapshot", + "description": + "Return a redacted local device snapshot including agentd mode, permissions, privacy policy counts, and queued local batch stats.", + "inputSchema": ["type": "object", "additionalProperties": false, "properties": [:]], + "annotations": ["title": "Device Snapshot", "readOnlyHint": true], + ], + [ + "name": "agentd_activity_recent", + "description": + "Summarize recent local agentd activity from persisted redacted batch JSON without returning raw frames.", + "inputSchema": [ + "type": "object", + "additionalProperties": false, + "properties": [ + "window": ["type": "string", "enum": ["10m", "6h", "24h"]], + "since": ["type": "number"], + "batch_dir": ["type": "string"], + ], + ], + "annotations": ["title": "Recent Activity", "readOnlyHint": true], + ], + [ + "name": "agentd_collect_diagnostics", + "description": + "Write Chronicle-style local activity summary artifacts for support/debugging and return their paths.", + "inputSchema": [ + "type": "object", + "required": ["out_dir"], + "additionalProperties": false, + "properties": [ + "window": ["type": "string", "enum": ["10m", "6h", "24h"]], + "since": ["type": "number"], + "batch_dir": ["type": "string"], + "out_dir": ["type": "string"], + ], + ], + "annotations": ["title": "Collect Diagnostics", "readOnlyHint": false], + ], + ] + } + + private static func jsonString(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return String(decoding: try encoder.encode(value), as: UTF8.self) + } + + private static func jsonData(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + + Data([0x0A]) + } + + static func fallbackErrorResponse(_ error: Error) -> Data { + let object: [String: Any] = [ + "jsonrpc": "2.0", + "id": NSNull(), + "error": [ + "code": AgentdMCPError.jsonRPCCode(for: error), + "message": AgentdMCPError.message(for: error), + ], + ] + return (try? jsonData(object)) + ?? Data( + #"{"error":{"code":-32000,"message":"internal error"},"id":null,"jsonrpc":"2.0"}"#.utf8 + ) + + Data([0x0A]) + } +} + +struct AgentdMCPRequest { + let id: Any? + let method: String + let params: [String: Any]? + + init(data: Data) throws { + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data) + } catch { + throw AgentdMCPError.parseError + } + guard let root = object as? [String: Any], + let method = root["method"] as? String + else { + throw AgentdMCPError.invalidRequest + } + self.id = root["id"] + self.method = method + self.params = root["params"] as? [String: Any] + } + + static func bestEffortId(from data: Data) -> Any? { + guard let object = try? JSONSerialization.jsonObject(with: data), + let root = object as? [String: Any] + else { + return nil + } + return root["id"] + } +} + +enum AgentdMCPError: Error, LocalizedError { + case parseError + case invalidRequest + + var errorDescription: String? { + switch self { + case .parseError: + return "parse error" + case .invalidRequest: + return "invalid MCP JSON-RPC request" + } + } + + static func jsonRPCCode(for error: Error) -> Int { + if let mcpError = error as? AgentdMCPError { + switch mcpError { + case .parseError: + return -32700 + case .invalidRequest: + return -32600 + } + } + if let diagnosticError = error as? DiagnosticCLIError { + switch diagnosticError { + case .usage: + return -32602 + default: + break + } + } + return -32000 + } + + static func message(for error: Error) -> String { + let message = error.localizedDescription + return message.isEmpty ? "internal error" : message + } +} + +enum AgentdMCPStdio { + static func run(server: AgentdMCPServer = AgentdMCPServer()) async -> Int32 { + while let line = readLine(strippingNewline: true) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + do { + let response = try await server.handle(Data(trimmed.utf8)) + if !response.isEmpty { + FileHandle.standardOutput.write(response) + } + } catch { + FileHandle.standardOutput.write(AgentdMCPServer.fallbackErrorResponse(error)) + FileHandle.standardError.write(Data("agentd mcp: \(error.localizedDescription)\n".utf8)) + } + } + return 0 + } +} diff --git a/Sources/agentd/DiagnosticCLI.swift b/Sources/agentd/DiagnosticCLI.swift index 08d05de..be74a9e 100644 --- a/Sources/agentd/DiagnosticCLI.swift +++ b/Sources/agentd/DiagnosticCLI.swift @@ -109,7 +109,7 @@ enum DiagnosticProbeRunner { enum DiagnosticCLI { static let handledCommands = [ "list-displays", "capture-once", "capture-worker-once", "capture-worker-stream", "selftest", - "activity", "help", "--help", "-h", + "activity", "mcp", "help", "--help", "-h", ] static func shouldHandle(_ arguments: [String]) -> Bool { @@ -137,6 +137,8 @@ enum DiagnosticCLI { case .selftest: let payload = await SelftestDiagnostics.run() try writeJSON(payload, to: nil) + case .mcp: + return await AgentdMCPStdio.run() case .activity(let options): let payload = try await ActivitySummary.run(options: options) if let summaryRoot = options.summaryRoot { @@ -199,12 +201,14 @@ enum DiagnosticCLI { agentd list-displays agentd capture-once [--display-id ID] [--no-ocr] [--out PATH] agentd activity [--since HOURS] [--window 10m|6h|24h] [--format json|markdown] [--batch-dir PATH] [--write-summaries PATH] + agentd mcp agentd selftest Diagnostic commands emit redacted JSON and never start the menu-bar app. capture-once uses the normal privacy filters, SecretScrubber, and OCR pipeline. activity summarizes locally persisted JSON batches without reading encrypted batch files. --write-summaries writes Chronicle-style instructions.md and resources/*.md locally. + mcp starts a local JSON-RPC stdio MCP server with redacted device/context tools. """ } @@ -217,6 +221,7 @@ enum DiagnosticCommand: Equatable { case captureWorkerStream(CaptureStreamOptions) case selftest case activity(ActivityOptions) + case mcp static func parse(_ arguments: [String]) throws -> DiagnosticCommand { guard let command = arguments.first else { return .help } @@ -236,6 +241,9 @@ enum DiagnosticCommand: Equatable { case "selftest": guard tail.isEmpty else { throw DiagnosticCLIError.usage("selftest takes no flags") } return .selftest + case "mcp": + guard tail.isEmpty else { throw DiagnosticCLIError.usage("mcp takes no flags") } + return .mcp case "activity": return .activity(try ActivityOptions.parse(tail)) default: diff --git a/Sources/agentd/Diagnostics.swift b/Sources/agentd/Diagnostics.swift index 9b9f6e9..2e0d77d 100644 --- a/Sources/agentd/Diagnostics.swift +++ b/Sources/agentd/Diagnostics.swift @@ -185,9 +185,10 @@ enum DiagnosticsReport { return redact(value) } - private static func redactEndpoint(_ endpoint: URL) -> String { + static func redactEndpoint(_ endpoint: URL) -> String { var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) components?.query = nil + components?.fragment = nil components?.user = nil components?.password = nil return components?.url?.absoluteString ?? "[redacted]" diff --git a/Tests/agentdTests/AgentdMCPTestSupport.swift b/Tests/agentdTests/AgentdMCPTestSupport.swift new file mode 100644 index 0000000..56639d7 --- /dev/null +++ b/Tests/agentdTests/AgentdMCPTestSupport.swift @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import XCTest + +@testable import agentd + +final class AgentdMCPRuntimeStub: AgentdMCPRuntime { + var deviceSnapshot = AgentdMCPDeviceSnapshot( + generatedAt: Date(timeIntervalSince1970: 0), + appVersion: "test", + deviceId: "device_test", + organizationId: "org_test", + mode: "local-only", + endpoint: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch", + permissions: AgentdMCPPermissionStatus( + accessibilityTrusted: true, + screenCaptureTrusted: true, + menuSummary: "Ready" + ), + localBatchStats: AgentdMCPLocalBatchStats(fileCount: 0, bytes: 0), + privacy: AgentdMCPPrivacyStatus( + allowedBundleCount: 0, + deniedBundleCount: 0, + deniedPathPrefixCount: 0, + pauseTitlePatternCount: 0, + captureAllDisplays: true, + selectedDisplayIds: [] + ) + ) + var activitySummary = ActivitySummaryTests.summary(batchDirectory: URL(fileURLWithPath: "/tmp")) + var diagnosticsResult = AgentdMCPDiagnosticsResult( + instructionsPath: "/tmp/instructions.md", + resourcePaths: ["/tmp/resources/activity.md"] + ) + private(set) var requestedActivity: ActivityOptions? + private(set) var requestedDiagnostics: ActivityOptions? + private(set) var requestedDiagnosticsOutDir: URL? + + func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot { + deviceSnapshot + } + + func activityRecent(options: ActivityOptions) async throws -> ActivitySummary { + requestedActivity = options + return activitySummary.replacing( + batchDirectory: options.batchDirectory.path, + windowLabel: options.windowLabel + ) + } + + func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws + -> AgentdMCPDiagnosticsResult + { + requestedDiagnostics = options + requestedDiagnosticsOutDir = outputDirectory + return diagnosticsResult + } +} + +func jsonData(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) +} + +func jsonObject(_ data: Data) throws -> [String: Any] { + let decoded = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(decoded as? [String: Any]) +} + +func mcpText(_ data: Data) throws -> String { + let root = try jsonObject(data) + let result = try XCTUnwrap(root["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + return try XCTUnwrap(content.first?["text"] as? String) +} + +extension ActivitySummaryTests { + static func summary( + batchDirectory: URL, + windowLabel: String = "24h", + windows: [ActivityWindowSummary] = [] + ) -> ActivitySummary { + ActivitySummary( + generatedAt: Date(timeIntervalSince1970: 1_000), + since: Date(timeIntervalSince1970: 0), + until: Date(timeIntervalSince1970: 1_000), + staleAfter: Date(timeIntervalSince1970: 1_600), + windowLabel: windowLabel, + batchDirectory: batchDirectory.path, + batchCount: 0, + nonemptyBatchCount: 0, + frameCount: 0, + sourceBatchIds: [], + displayIds: [], + droppedCounts: DropCounts(secret: 0, duplicate: 0, deniedApp: 0, deniedPath: 0), + droppedReasonCounts: [:], + apps: [], + windows: windows, + artifacts: [] + ) + } +} + +extension ActivitySummary { + func replacing(batchDirectory: String, windowLabel: String) -> ActivitySummary { + ActivitySummary( + generatedAt: generatedAt, + since: since, + until: until, + staleAfter: staleAfter, + windowLabel: windowLabel, + batchDirectory: batchDirectory, + batchCount: batchCount, + nonemptyBatchCount: nonemptyBatchCount, + frameCount: frameCount, + sourceBatchIds: sourceBatchIds, + displayIds: displayIds, + droppedCounts: droppedCounts, + droppedReasonCounts: droppedReasonCounts, + apps: apps, + windows: windows, + artifacts: artifacts + ) + } +} diff --git a/Tests/agentdTests/DiagnosticCLITests.swift b/Tests/agentdTests/DiagnosticCLITests.swift index 87f5fb7..3f4f9be 100644 --- a/Tests/agentdTests/DiagnosticCLITests.swift +++ b/Tests/agentdTests/DiagnosticCLITests.swift @@ -14,9 +14,220 @@ final class DiagnosticCLITests: XCTestCase { XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "capture-worker-stream"])) XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "selftest"])) XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "activity"])) + XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "mcp"])) XCTAssertFalse(DiagnosticCLI.shouldHandle(["agentd", "--local-only"])) } + func testMcpInitializeAndToolsListExposeLocalContextTools() async throws { + let runtime = AgentdMCPRuntimeStub() + let server = AgentdMCPServer(runtime: runtime) + + let initialize = try await server.handle( + jsonData( + [ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": [ + "protocolVersion": "2025-06-18", + "capabilities": [:], + "clientInfo": ["name": "codex-test", "version": "dev"], + ], + ])) + let initializeRoot = try jsonObject(initialize) + + XCTAssertEqual(initializeRoot["jsonrpc"] as? String, "2.0") + XCTAssertEqual(initializeRoot["id"] as? Int, 1) + let initializeResult = try XCTUnwrap(initializeRoot["result"] as? [String: Any]) + XCTAssertEqual(initializeResult["protocolVersion"] as? String, "2025-06-18") + + let tools = try await server.handle( + jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"])) + let toolsRoot = try jsonObject(tools) + let toolsResult = try XCTUnwrap(toolsRoot["result"] as? [String: Any]) + let toolList = try XCTUnwrap(toolsResult["tools"] as? [[String: Any]]) + let names = Set(toolList.compactMap { $0["name"] as? String }) + + XCTAssertEqual( + names, + ["agentd_device_snapshot", "agentd_activity_recent", "agentd_collect_diagnostics"] + ) + let annotationsByName = Dictionary( + uniqueKeysWithValues: try toolList.map { tool in + ( + try XCTUnwrap(tool["name"] as? String), + try XCTUnwrap(tool["annotations"] as? [String: Any]) + ) + } + ) + XCTAssertEqual(annotationsByName["agentd_device_snapshot"]?["readOnlyHint"] as? Bool, true) + XCTAssertEqual(annotationsByName["agentd_activity_recent"]?["readOnlyHint"] as? Bool, true) + XCTAssertEqual(annotationsByName["agentd_collect_diagnostics"]?["readOnlyHint"] as? Bool, false) + } + + func testMcpResponsesAreSingleLineJSONRPCMessages() async throws { + let server = AgentdMCPServer(runtime: AgentdMCPRuntimeStub()) + + let response = try await server.handle( + jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"])) + + XCTAssertEqual(response.filter { $0 == 0x0A }.count, 1) + XCTAssertEqual(response.last, 0x0A) + } + + func testMcpErrorsReturnJSONRPCResponses() async throws { + let server = AgentdMCPServer(runtime: AgentdMCPRuntimeStub()) + + let invalidParams = try await server.handle( + jsonData([ + "jsonrpc": "2.0", + "id": "bad-window", + "method": "tools/call", + "params": [ + "name": "agentd_activity_recent", + "arguments": ["window": "bad"], + ], + ])) + let invalidParamsRoot = try jsonObject(invalidParams) + let invalidParamsError = try XCTUnwrap(invalidParamsRoot["error"] as? [String: Any]) + XCTAssertEqual(invalidParamsRoot["id"] as? String, "bad-window") + XCTAssertEqual(invalidParamsError["code"] as? Int, -32602) + + let parseError = try await server.handle(Data("{".utf8)) + let parseErrorRoot = try jsonObject(parseError) + let parseErrorBody = try XCTUnwrap(parseErrorRoot["error"] as? [String: Any]) + XCTAssertTrue(parseErrorRoot["id"] is NSNull) + XCTAssertEqual(parseErrorBody["code"] as? Int, -32700) + } + + func testMcpActivityRecentReturnsRedactedActivitySummary() async throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let runtime = AgentdMCPRuntimeStub() + runtime.activitySummary = ActivitySummaryTests.summary( + batchDirectory: root, + windows: [ + ActivityWindowSummary( + appName: "Google Chrome", + bundleId: "com.google.Chrome", + windowTitle: "Review EvalOps", + documentPath: "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1", + frameCount: 3, + firstSeenAt: Date(timeIntervalSince1970: 100), + lastSeenAt: Date(timeIntervalSince1970: 120) + ) + ] + ) + let server = AgentdMCPServer(runtime: runtime) + + let response = try await server.handle( + jsonData([ + "jsonrpc": "2.0", + "id": "activity", + "method": "tools/call", + "params": [ + "name": "agentd_activity_recent", + "arguments": ["window": "6h", "batch_dir": root.path], + ], + ])) + let text = try mcpText(response) + let decoded = try jsonObject(Data(text.utf8)) + + XCTAssertEqual(decoded["windowLabel"] as? String, "6h") + XCTAssertEqual(decoded["batchDirectory"] as? String, root.path) + let windows = try XCTUnwrap(decoded["windows"] as? [[String: Any]]) + XCTAssertEqual( + windows.first?["documentPath"] as? String, + "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1" + ) + XCTAssertEqual(runtime.requestedActivity?.windowLabel, "6h") + XCTAssertEqual(runtime.requestedActivity?.batchDirectory.path, root.path) + } + + func testMcpCollectDiagnosticsWritesActivityArtifactsAndReturnsPaths() async throws { + let root = try temporaryDirectory() + let out = try temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: out) + } + let runtime = AgentdMCPRuntimeStub() + runtime.diagnosticsResult = AgentdMCPDiagnosticsResult( + instructionsPath: out.appendingPathComponent("instructions.md").path, + resourcePaths: [out.appendingPathComponent("resources/activity-24h.md").path] + ) + let server = AgentdMCPServer(runtime: runtime) + + let response = try await server.handle( + jsonData([ + "jsonrpc": "2.0", + "id": "diag", + "method": "tools/call", + "params": [ + "name": "agentd_collect_diagnostics", + "arguments": ["batch_dir": root.path, "out_dir": out.path, "window": "24h"], + ], + ])) + let decoded = try jsonObject(Data(try mcpText(response).utf8)) + + XCTAssertEqual( + decoded["instructionsPath"] as? String, + out.appendingPathComponent("instructions.md").path + ) + XCTAssertEqual( + decoded["resourcePaths"] as? [String], + [out.appendingPathComponent("resources/activity-24h.md").path] + ) + XCTAssertEqual(runtime.requestedDiagnostics?.batchDirectory.path, root.path) + XCTAssertEqual(runtime.requestedDiagnosticsOutDir?.path, out.path) + } + + func testMcpDeviceSnapshotReportsRedactedLocalStatus() async throws { + let runtime = AgentdMCPRuntimeStub() + runtime.deviceSnapshot = AgentdMCPDeviceSnapshot( + generatedAt: Date(timeIntervalSince1970: 0), + appVersion: "0.2.0", + deviceId: "device_1", + organizationId: "evalops", + mode: "managed", + endpoint: "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch", + permissions: AgentdMCPPermissionStatus( + accessibilityTrusted: true, + screenCaptureTrusted: false, + menuSummary: "Needs Screen Recording" + ), + localBatchStats: AgentdMCPLocalBatchStats(fileCount: 2, bytes: 42), + privacy: AgentdMCPPrivacyStatus( + allowedBundleCount: 3, + deniedBundleCount: 1, + deniedPathPrefixCount: 2, + pauseTitlePatternCount: 4, + captureAllDisplays: true, + selectedDisplayIds: [] + ) + ) + let server = AgentdMCPServer(runtime: runtime) + + let response = try await server.handle( + jsonData([ + "jsonrpc": "2.0", + "id": "snapshot", + "method": "tools/call", + "params": [ + "name": "agentd_device_snapshot", + "arguments": [:], + ], + ])) + let decoded = try jsonObject(Data(try mcpText(response).utf8)) + + XCTAssertEqual(decoded["deviceId"] as? String, "device_1") + XCTAssertEqual(decoded["mode"] as? String, "managed") + XCTAssertEqual(decoded["endpoint"] as? String, runtime.deviceSnapshot.endpoint) + XCTAssertFalse((decoded["endpoint"] as? String ?? "").contains("?")) + let privacy = try XCTUnwrap(decoded["privacy"] as? [String: Any]) + XCTAssertEqual(privacy["deniedPathPrefixCount"] as? Int, 2) + } + func testCaptureOnceParserAcceptsSafeFlags() throws { let command = try DiagnosticCommand.parse([ "capture-once", "--display-id", "42", "--no-ocr", "--out", "/tmp/agentd.json", diff --git a/Tests/agentdTests/DiagnosticsTests.swift b/Tests/agentdTests/DiagnosticsTests.swift index e5104b6..c589918 100644 --- a/Tests/agentdTests/DiagnosticsTests.swift +++ b/Tests/agentdTests/DiagnosticsTests.swift @@ -127,4 +127,14 @@ final class DiagnosticsTests: XCTestCase { XCTAssertTrue(report.contains("Capture all displays: false")) XCTAssertTrue(report.contains("| 42 | 3024x1964 | 2.00 | true | 12 | 1 |")) } + + func testRedactEndpointDropsUserInfoQueryAndFragment() { + let endpoint = URL( + string: "https://user:password@chronicle.example.com/submit?token=secret#fragment-secret")! + + XCTAssertEqual( + DiagnosticsReport.redactEndpoint(endpoint), + "https://chronicle.example.com/submit" + ) + } }