diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96c9788..21c7d58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Test run: swift test + - name: Validate Chronicle contract fixtures + run: python3 scripts/mock_chronicle.py --self-test Tests/Fixtures/chronicle + - name: Lint Swift formatting run: xcrun swift-format lint --strict --recursive Sources Tests Package.swift diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 74a0063..609e873 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -98,7 +98,7 @@ jobs: - name: Record checksums run: | set -euo pipefail - shasum -a 256 "dist/EvalOps agentd.app/Contents/MacOS/agentd" "dist/agentd.zip" | tee dist/SHA256SUMS + shasum -a 256 "dist/EvalOps agentd.app/Contents/MacOS/agentd" "dist/agentd.zip" "dist/update-channel.json" | tee dist/SHA256SUMS codesign -dvvv --entitlements :- "dist/EvalOps agentd.app" > dist/codesign.txt 2>&1 spctl -a -t exec -vv "dist/EvalOps agentd.app" > dist/spctl.txt 2>&1 @@ -110,6 +110,7 @@ jobs: dist/EvalOps agentd.app dist/agentd.zip dist/SHA256SUMS + dist/update-channel.json dist/codesign.txt dist/spctl.txt if-no-files-found: error diff --git a/README.md b/README.md index b2b2c08..ce1abc3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ This is the desktop component of the work tracked in - Captures the active display via `ScreenCaptureKit` at an adaptive 0.2–1 fps; input idle time drops cadence to `idleFps` and activity restores - `captureFps`. + `captureFps`. Frames include display id, scale, and main-display metadata for + multi-display diagnostics. - Reads `(bundleId, windowTitle, documentPath)` per frame via the Accessibility API and `NSWorkspace`. - Runs Apple Vision OCR on-device. @@ -22,6 +23,9 @@ This is the desktop component of the work tracked in Stripe markers — match → frame dropped, never partial-redacted. - Per-app allow/deny list and per-path deny list. - Window-title pause patterns (Zoom, FaceTime, 1Password…). +- Scheduled pause windows from managed policy pause capture for meetings, + interviews, private/focus blocks, or Platform-driven policy windows; manual + pause always wins over automatic resume. - Secret scanning covers OCR text, window titles, and document paths before a frame is batched. - OCR text is scrubbed at full length, then capped to `maxOcrTextChars` @@ -44,7 +48,7 @@ This is the desktop component of the work tracked in without requiring an app restart. Local hard-deny safety rails remain fail-closed even when a remote policy allows a bundle or path. - Menu-bar UI: pause/resume (`⌃⌥⌘P`), flush now (`⌃⌥⌘F`), reveal batches dir, - quit. + diagnostics report (`⌃⌥⌘D`), delete queued batches, launch-at-login, quit. ## Build @@ -52,6 +56,7 @@ This is the desktop component of the work tracked in swift build swift run agentd # foreground; menu-bar item appears swift test +python3 scripts/mock_chronicle.py --self-test Tests/Fixtures/chronicle scripts/package_app.sh # release .app bundle with hardened runtime signing scripts/permission_smoke.sh --no-launch # generate permission-smoke evidence template ``` @@ -87,6 +92,9 @@ version/checksum/codesign evidence in `dist/permission-smoke-report.md`, and opens the app unless `--no-launch` is supplied. Use it for the hardware-backed Screen Recording and Accessibility permission smoke. +Launch-at-login uses native `SMAppService.mainApp` from the menu bar; agentd +does not install LaunchAgent plists. + ## Configuration agentd reads and writes `~/.evalops/agentd/config.json`. Important defaults: @@ -95,6 +103,9 @@ agentd reads and writes `~/.evalops/agentd/config.json`. Important defaults: - `captureFps: 1.0` - `idleFps: 0.2` - `idleThresholdSeconds: 60` +- `adaptiveOcrMinChars: 1024` +- `adaptiveOcrBackpressureThreshold: 8` +- `adaptiveOcrBacklogBytes: 67108864` - `batchIntervalSeconds: 30` - `maxFramesPerBatch: 24` - `maxOcrTextChars: 4096` @@ -166,9 +177,10 @@ Secret Broker mode, and local permission preflight metadata. Every 30 seconds it calls `Heartbeat` with pending in-memory frames plus local fallback batch count and bytes. `RegisterDevice` and `Heartbeat` responses may include a `CapturePolicy`; agentd applies allowlist, denylist, path-deny, pause-window, -batch interval, and max-frame settings at runtime. Server `PAUSED` capture mode -stops capture until a later policy resumes it, while manual user pause still -wins locally. +scheduled pause windows, batch interval, and max-frame settings at runtime. +Server `PAUSED` capture mode stops capture until a later policy resumes it. +Manual user pause wins over scheduled pause, and scheduled pause wins over +server policy pause for visible menu/diagnostic state. Encrypted local batches use the `.agentdbatch` extension. The encryption key is created or loaded from Keychain service `dev.evalops.agentd.local-batch-key`, @@ -176,13 +188,20 @@ accounted by `deviceId`, and is never written to `config.json` or the batch directory. Retention sweeps apply to both plaintext `.json` batches and encrypted `.agentdbatch` batches. +Diagnostics reports are written under `~/.evalops/agentd/diagnostics/` with +`0o600` permissions. They summarize permissions, policy, queue pressure, local +batches, and last submit health without OCR text or raw payloads. + +`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 +are available. + ## What's next - Consume generated `chronicle.v1` Swift types when the platform SDK publishes them ([evalops/platform#1078](https://github.com/evalops/platform/issues/1078)). -- Calendar / Zoom auto-pause via NATS subject - `chronicle.policy.pause` (siphon-fed). - Hardware-backed permission-flow smoke test for Screen Recording and Accessibility prompts. @@ -192,6 +211,9 @@ encrypted `.agentdbatch` batches. Sources/agentd/ main.swift # NSApplication + AppController boot ChronicleControl.swift # RegisterDevice/Heartbeat + policy response client + Diagnostics.swift # Redacted local report generation + PauseState.swift # Manual/scheduled/policy pause precedence + LaunchAtLoginController.swift # Native SMAppService login item toggle Config.swift # ~/.evalops/agentd/config.json CaptureService.swift # SCStream pipeline WindowContext.swift # AX + NSWorkspace probe diff --git a/Sources/agentd/CaptureService.swift b/Sources/agentd/CaptureService.swift index bad79c1..9d8d098 100644 --- a/Sources/agentd/CaptureService.swift +++ b/Sources/agentd/CaptureService.swift @@ -11,6 +11,8 @@ struct CapturedFrame: Sendable { let timestamp: Date let cgImage: CGImage let displayId: CGDirectDisplayID + let displayScale: Double? + let mainDisplay: Bool } actor CaptureService: NSObject { @@ -55,6 +57,8 @@ actor CaptureService: NSObject { let output = FrameOutput( ciContext: ciContext, displayId: display.displayID, + displayScale: Self.displayScale(display.displayID), + mainDisplay: display.displayID == CGMainDisplayID(), onFrame: onFrame, onFrameDropped: onFrameDropped ) @@ -91,20 +95,35 @@ actor CaptureService: NSObject { "capture fps update failed: \(error.localizedDescription, privacy: .public)") } } + + private static func displayScale(_ displayId: CGDirectDisplayID) -> Double? { + let pixelWidth = CGDisplayPixelsWide(displayId) + guard pixelWidth > 0 else { return nil } + let boundsWidth = CGDisplayBounds(displayId).width + guard boundsWidth > 0 else { return nil } + return Double(pixelWidth) / Double(boundsWidth) + } } private final class FrameOutput: NSObject, SCStreamOutput, @unchecked Sendable { let ciContext: CIContext let displayId: CGDirectDisplayID + let displayScale: Double? + let mainDisplay: Bool let dispatcher: BufferedFrameDispatcher init( - ciContext: CIContext, displayId: CGDirectDisplayID, + ciContext: CIContext, + displayId: CGDirectDisplayID, + displayScale: Double?, + mainDisplay: Bool, onFrame: @escaping @Sendable (CapturedFrame) async -> Void, onFrameDropped: @escaping @Sendable () async -> Void ) { self.ciContext = ciContext self.displayId = displayId + self.displayScale = displayScale + self.mainDisplay = mainDisplay self.dispatcher = BufferedFrameDispatcher(onFrame: onFrame, onDropped: onFrameDropped) } @@ -117,7 +136,13 @@ private final class FrameOutput: NSObject, SCStreamOutput, @unchecked Sendable { else { return } let ci = CIImage(cvPixelBuffer: pixelBuffer) guard let cg = ciContext.createCGImage(ci, from: ci.extent) else { return } - let frame = CapturedFrame(timestamp: Date(), cgImage: cg, displayId: displayId) + let frame = CapturedFrame( + timestamp: Date(), + cgImage: cg, + displayId: displayId, + displayScale: displayScale, + mainDisplay: mainDisplay + ) dispatcher.yield(frame) } } diff --git a/Sources/agentd/ChronicleControl.swift b/Sources/agentd/ChronicleControl.swift index 5136951..63db500 100644 --- a/Sources/agentd/ChronicleControl.swift +++ b/Sources/agentd/ChronicleControl.swift @@ -32,6 +32,7 @@ struct CapturePolicy: Sendable, Codable, Equatable { var cloudConsolidationTier: String var minBatchIntervalSeconds: Int var maxFramesPerBatch: Int + var scheduledPauseWindows: [ScheduledPauseWindow] var sourcePolicyRef: String init( @@ -45,6 +46,7 @@ struct CapturePolicy: Sendable, Codable, Equatable { cloudConsolidationTier: String = "", minBatchIntervalSeconds: Int = 0, maxFramesPerBatch: Int = 0, + scheduledPauseWindows: [ScheduledPauseWindow] = [], sourcePolicyRef: String = "" ) { self.policyVersion = policyVersion @@ -57,6 +59,7 @@ struct CapturePolicy: Sendable, Codable, Equatable { self.cloudConsolidationTier = cloudConsolidationTier self.minBatchIntervalSeconds = minBatchIntervalSeconds self.maxFramesPerBatch = maxFramesPerBatch + self.scheduledPauseWindows = scheduledPauseWindows self.sourcePolicyRef = sourcePolicyRef } @@ -71,6 +74,7 @@ struct CapturePolicy: Sendable, Codable, Equatable { case cloudConsolidationTier case minBatchIntervalSeconds case maxFramesPerBatch + case scheduledPauseWindows case sourcePolicyRef } @@ -100,11 +104,22 @@ struct CapturePolicy: Sendable, Codable, Equatable { forKey: .minBatchIntervalSeconds ) ?? 0, maxFramesPerBatch: try container.decodeIfPresent(Int.self, forKey: .maxFramesPerBatch) ?? 0, + scheduledPauseWindows: try container.decodeIfPresent( + [ScheduledPauseWindow].self, + forKey: .scheduledPauseWindows + ) ?? [], sourcePolicyRef: try container.decodeIfPresent(String.self, forKey: .sourcePolicyRef) ?? "" ) } } +struct ScheduledPauseWindow: Sendable, Codable, Equatable { + var id: String + var reason: String + var startsAt: Date + var endsAt: Date +} + struct ChronicleDevice: Sendable, Codable, Equatable { var deviceId: String var organizationId: String @@ -162,12 +177,32 @@ struct HeartbeatRequest: Sendable, Codable, Equatable { var organizationId: String var pendingFrameCount: Int var pendingBytes: Int64 + var paused: Bool + var pauseReason: String? enum CodingKeys: String, CodingKey { case deviceId case organizationId case pendingFrameCount case pendingBytes + case paused + case pauseReason + } + + init( + deviceId: String, + organizationId: String, + pendingFrameCount: Int, + pendingBytes: Int64, + paused: Bool = false, + pauseReason: String? = nil + ) { + self.deviceId = deviceId + self.organizationId = organizationId + self.pendingFrameCount = pendingFrameCount + self.pendingBytes = pendingBytes + self.paused = paused + self.pauseReason = pauseReason } func encode(to encoder: Encoder) throws { @@ -176,6 +211,8 @@ struct HeartbeatRequest: Sendable, Codable, Equatable { try container.encode(organizationId, forKey: .organizationId) try container.encode(pendingFrameCount, forKey: .pendingFrameCount) try container.encode(String(pendingBytes), forKey: .pendingBytes) + try container.encode(paused, forKey: .paused) + try container.encodeIfPresent(pauseReason, forKey: .pauseReason) } } @@ -283,7 +320,9 @@ actor ChronicleControlClient { if responseBody.isEmpty { return try JSONDecoder().decode(responseType, from: Data("{}".utf8)) } - return try JSONDecoder().decode(responseType, from: responseBody) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(responseType, from: responseBody) } } @@ -313,6 +352,7 @@ enum ChronicleControlError: Error, LocalizedError, Equatable { func encodeChronicleControlRequest(_ request: T) throws -> Data { let enc = JSONEncoder() + enc.dateEncodingStrategy = .iso8601 enc.outputFormatting = [.sortedKeys] return try enc.encode(request) } diff --git a/Sources/agentd/Config.swift b/Sources/agentd/Config.swift index ad7720a..a8c5588 100644 --- a/Sources/agentd/Config.swift +++ b/Sources/agentd/Config.swift @@ -73,6 +73,9 @@ struct AgentConfig: Codable, Sendable { var maxBatchAgeDays: Double var idleThresholdSeconds: Double var idlePollSeconds: Double + var adaptiveOcrMinChars: Int + var adaptiveOcrBackpressureThreshold: Int + var adaptiveOcrBacklogBytes: Int64 var localOnly: Bool var encryptLocalBatches: Bool var auth: AuthMode @@ -100,6 +103,9 @@ struct AgentConfig: Codable, Sendable { case maxBatchAgeDays case idleThresholdSeconds case idlePollSeconds + case adaptiveOcrMinChars + case adaptiveOcrBackpressureThreshold + case adaptiveOcrBacklogBytes case localOnly case encryptLocalBatches case auth @@ -127,6 +133,9 @@ struct AgentConfig: Codable, Sendable { maxBatchAgeDays: Double = 7, idleThresholdSeconds: Double = 60, idlePollSeconds: Double = 5, + adaptiveOcrMinChars: Int = 1024, + adaptiveOcrBackpressureThreshold: Int = 8, + adaptiveOcrBacklogBytes: Int64 = 64 * 1024 * 1024, localOnly: Bool, encryptLocalBatches: Bool? = nil, auth: AuthMode = .none, @@ -152,6 +161,9 @@ struct AgentConfig: Codable, Sendable { self.maxBatchAgeDays = maxBatchAgeDays self.idleThresholdSeconds = idleThresholdSeconds self.idlePollSeconds = idlePollSeconds + self.adaptiveOcrMinChars = adaptiveOcrMinChars + self.adaptiveOcrBackpressureThreshold = adaptiveOcrBackpressureThreshold + self.adaptiveOcrBacklogBytes = adaptiveOcrBacklogBytes self.localOnly = localOnly self.encryptLocalBatches = encryptLocalBatches ?? (!localOnly || secretBroker != nil) self.auth = auth @@ -242,6 +254,13 @@ struct AgentConfig: Codable, Sendable { idleThresholdSeconds = try container.decodeIfPresent(Double.self, forKey: .idleThresholdSeconds) ?? 60 idlePollSeconds = try container.decodeIfPresent(Double.self, forKey: .idlePollSeconds) ?? 5 + adaptiveOcrMinChars = + try container.decodeIfPresent(Int.self, forKey: .adaptiveOcrMinChars) ?? 1024 + adaptiveOcrBackpressureThreshold = + try container.decodeIfPresent(Int.self, forKey: .adaptiveOcrBackpressureThreshold) ?? 8 + adaptiveOcrBacklogBytes = + try container.decodeIfPresent(Int64.self, forKey: .adaptiveOcrBacklogBytes) + ?? 64 * 1024 * 1024 localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? true auth = try container.decodeIfPresent(AuthMode.self, forKey: .auth) ?? .none secretBroker = try container.decodeIfPresent(SecretBrokerConfig.self, forKey: .secretBroker) @@ -272,6 +291,10 @@ struct AgentConfig: Codable, Sendable { try container.encode(maxBatchAgeDays, forKey: .maxBatchAgeDays) try container.encode(idleThresholdSeconds, forKey: .idleThresholdSeconds) try container.encode(idlePollSeconds, forKey: .idlePollSeconds) + try container.encode(adaptiveOcrMinChars, forKey: .adaptiveOcrMinChars) + try container.encode( + adaptiveOcrBackpressureThreshold, forKey: .adaptiveOcrBackpressureThreshold) + try container.encode(adaptiveOcrBacklogBytes, forKey: .adaptiveOcrBacklogBytes) try container.encode(localOnly, forKey: .localOnly) try container.encode(encryptLocalBatches, forKey: .encryptLocalBatches) try container.encode(auth, forKey: .auth) @@ -309,9 +332,9 @@ struct AgentConfig: Codable, Sendable { ] static let defaultPauseWindowPatterns: [String] = [ - "Zoom Meeting", "FaceTime", "Google Meet", + "Zoom Meeting", "Meet - ", "meet.google.com", "FaceTime", "Google Meet", "1Password", "Bitwarden", "Keychain Access", - "Private", "Incognito", + "Private Browsing", "Private", "Incognito", ] static func fallback() -> AgentConfig { @@ -332,6 +355,9 @@ struct AgentConfig: Codable, Sendable { maxBatchAgeDays: 7, idleThresholdSeconds: 60, idlePollSeconds: 5, + adaptiveOcrMinChars: 1024, + adaptiveOcrBackpressureThreshold: 8, + adaptiveOcrBacklogBytes: 64 * 1024 * 1024, localOnly: true, encryptLocalBatches: false, auth: .none, diff --git a/Sources/agentd/Diagnostics.swift b/Sources/agentd/Diagnostics.swift new file mode 100644 index 0000000..d4376a1 --- /dev/null +++ b/Sources/agentd/Diagnostics.swift @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +struct DiagnosticsSnapshot: Sendable { + let generatedAt: Date + let appVersion: String + let captureState: String + let permissions: PermissionSnapshot + let config: AgentConfig + let policyVersion: String? + let policySource: String? + let controlError: String? + let pendingStats: PendingFrameStats + let localBatchStats: LocalBatchStats + let localBatches: [LocalBatchSummary] + let lastSubmitResult: String? +} + +enum DiagnosticsReport { + static func markdown(_ snapshot: DiagnosticsSnapshot) -> String { + var lines: [String] = [] + lines.append("# agentd diagnostics") + lines.append("") + lines.append("- Generated: \(iso(snapshot.generatedAt))") + lines.append("- App version: \(snapshot.appVersion)") + lines.append("- Capture state: \(snapshot.captureState)") + lines.append("- Accessibility trusted: \(snapshot.permissions.accessibilityTrusted)") + lines.append("- Screen capture preflight: \(snapshot.permissions.screenCaptureTrusted)") + lines.append("- Mode: \(snapshot.config.localOnly ? "local-only" : "managed")") + lines.append("- Secret Broker: \(snapshot.config.secretBroker == nil ? "disabled" : "enabled")") + lines.append("- Endpoint: \(redactEndpoint(snapshot.config.endpoint))") + lines.append("- Policy version: \(snapshot.policyVersion ?? "none")") + lines.append("- Policy source: \(redact(snapshot.policySource ?? "none"))") + lines.append("- Last control error: \(redact(snapshot.controlError ?? "none"))") + lines.append("- Pending in-memory frames: \(snapshot.pendingStats.frameCount)") + lines.append("- Pending in-memory bytes: \(snapshot.pendingStats.estimatedBytes)") + lines.append("- Queued local batches: \(snapshot.localBatchStats.fileCount)") + lines.append("- Queued local bytes: \(snapshot.localBatchStats.bytes)") + lines.append("- Last submit result: \(snapshot.lastSubmitResult ?? "unknown")") + lines.append("") + lines.append("## Capture Policy") + lines.append("") + lines.append("- Allowed bundles: \(snapshot.config.allowedBundleIds.count)") + lines.append("- Denied bundles: \(snapshot.config.deniedBundleIds.count)") + lines.append( + "- Denied path prefixes: \(snapshot.config.deniedPathPrefixes.map(redactPath).joined(separator: ", "))" + ) + lines.append( + "- Pause title patterns: \(snapshot.config.pauseWindowTitlePatterns.map(redact).joined(separator: ", "))" + ) + lines.append("- Batch interval seconds: \(snapshot.config.batchIntervalSeconds)") + lines.append("- Max frames per batch: \(snapshot.config.maxFramesPerBatch)") + lines.append("- Max OCR text chars: \(snapshot.config.maxOcrTextChars)") + lines.append("- Adaptive OCR min chars: \(snapshot.config.adaptiveOcrMinChars)") + lines.append("") + lines.append("## Queued Batches") + lines.append("") + if snapshot.localBatches.isEmpty { + lines.append("No queued local batches.") + } else { + lines.append("| Batch | Modified | Bytes | Encrypted |") + lines.append("| --- | --- | ---: | --- |") + for batch in snapshot.localBatches { + lines.append( + "| \(redact(batch.batchId)) | \(iso(batch.modified)) | \(batch.bytes) | \(batch.encrypted) |" + ) + } + } + lines.append("") + lines.append( + "OCR text, secrets, document paths, bearer tokens, and full endpoint query strings are omitted." + ) + lines.append("") + return lines.joined(separator: "\n") + } + + static func write(_ snapshot: DiagnosticsSnapshot, directory: URL) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent( + "diagnostics-\(fileTimestamp(snapshot.generatedAt)).md") + try markdown(snapshot).write(to: url, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return url + } + + static func redact(_ value: String) -> String { + guard !value.isEmpty else { return value } + if SecretScrubber.evaluate(value) != .clean { + return "[redacted]" + } + return value.replacingOccurrences(of: NSHomeDirectory(), with: "~") + } + + static func redactPath(_ value: String) -> String { + if value.contains("/") || value.hasPrefix(".") { + return value.replacingOccurrences(of: NSHomeDirectory(), with: "~") + } + return redact(value) + } + + private static func redactEndpoint(_ endpoint: URL) -> String { + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) + components?.query = nil + components?.user = nil + components?.password = nil + return components?.url?.absoluteString ?? "[redacted]" + } + + private static func iso(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private static func fileTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: date) + } +} diff --git a/Sources/agentd/LaunchAtLoginController.swift b/Sources/agentd/LaunchAtLoginController.swift new file mode 100644 index 0000000..9b5bb40 --- /dev/null +++ b/Sources/agentd/LaunchAtLoginController.swift @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import ServiceManagement + +enum LaunchAtLoginController { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static func setEnabled(_ enabled: Bool) throws { + if enabled { + if SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } + } else if SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } + } +} diff --git a/Sources/agentd/MenuBarController.swift b/Sources/agentd/MenuBarController.swift index 2d1f2c7..8b1d1de 100644 --- a/Sources/agentd/MenuBarController.swift +++ b/Sources/agentd/MenuBarController.swift @@ -8,21 +8,31 @@ final class MenuBarController: NSObject { private let statusItem: NSStatusItem private let menu = NSMenu() private var aboutItem: NSMenuItem? + private var launchAtLoginItem: NSMenuItem? private var paused: Bool = false private let onPauseToggle: @Sendable (Bool) -> Void private let onFlushNow: @Sendable () -> Void private let onOpenBatchesDir: @Sendable () -> Void + private let onOpenDiagnostics: @Sendable () -> Void + private let onDeleteQueuedBatches: @Sendable () -> Void + private let onLaunchAtLoginToggle: @Sendable (Bool) -> Void private let onQuit: @Sendable () -> Void init( onPauseToggle: @escaping @Sendable (Bool) -> Void, onFlushNow: @escaping @Sendable () -> Void, onOpenBatchesDir: @escaping @Sendable () -> Void, + onOpenDiagnostics: @escaping @Sendable () -> Void, + onDeleteQueuedBatches: @escaping @Sendable () -> Void, + onLaunchAtLoginToggle: @escaping @Sendable (Bool) -> Void, onQuit: @escaping @Sendable () -> Void ) { self.onPauseToggle = onPauseToggle self.onFlushNow = onFlushNow self.onOpenBatchesDir = onOpenBatchesDir + self.onOpenDiagnostics = onOpenDiagnostics + self.onDeleteQueuedBatches = onDeleteQueuedBatches + self.onLaunchAtLoginToggle = onLaunchAtLoginToggle self.onQuit = onQuit self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() @@ -56,6 +66,25 @@ final class MenuBarController: NSObject { revealItem.target = self menu.addItem(revealItem) + let diagnosticsItem = NSMenuItem( + title: "Open Diagnostics Report", action: #selector(openDiagnostics), keyEquivalent: "d") + diagnosticsItem.keyEquivalentModifierMask = [.command, .option, .control] + diagnosticsItem.target = self + menu.addItem(diagnosticsItem) + + let deleteItem = NSMenuItem( + title: "Delete Queued Batches", action: #selector(deleteQueuedBatches), keyEquivalent: "") + deleteItem.target = self + menu.addItem(deleteItem) + + menu.addItem(.separator()) + + let launchItem = NSMenuItem( + title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") + launchItem.target = self + launchAtLoginItem = launchItem + menu.addItem(launchItem) + menu.addItem(.separator()) let about = NSMenuItem( @@ -91,6 +120,13 @@ final class MenuBarController: NSObject { @objc private func flush() { onFlushNow() } @objc private func reveal() { onOpenBatchesDir() } + @objc private func openDiagnostics() { onOpenDiagnostics() } + @objc private func deleteQueuedBatches() { onDeleteQueuedBatches() } + @objc private func toggleLaunchAtLogin() { + let next = !(launchAtLoginItem?.state == .on) + launchAtLoginItem?.state = next ? .on : .off + onLaunchAtLoginToggle(next) + } @objc private func quit() { onQuit() } func setStatus(paused: Bool, detail: String, localOnly: Bool, policyVersion: String?) { @@ -109,6 +145,7 @@ final class MenuBarController: NSObject { let mode = localOnly ? "local-only" : "managed" let policy = policyVersion.map { " policy \($0)" } ?? "" aboutItem?.title = "agentd \(Bundle.main.appVersion) — \(mode)\(policy)" + launchAtLoginItem?.state = LaunchAtLoginController.isEnabled ? .on : .off } } diff --git a/Sources/agentd/PauseState.swift b/Sources/agentd/PauseState.swift new file mode 100644 index 0000000..a638c27 --- /dev/null +++ b/Sources/agentd/PauseState.swift @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +enum EffectivePauseState: Sendable, Equatable { + case active + case manual + case scheduled(id: String, reason: String, endsAt: Date) + case policy(reason: String?) + + var paused: Bool { + switch self { + case .active: + return false + case .manual, .scheduled, .policy: + return true + } + } + + var reason: String? { + switch self { + case .active: + return nil + case .manual: + return "manual" + case .scheduled(_, let reason, _): + return "scheduled:\(reason)" + case .policy(let reason): + return reason.map { "policy:\($0)" } ?? "policy" + } + } + + var detail: String { + switch self { + case .active: + return "capturing" + case .manual: + return "paused by user" + case .scheduled(_, let reason, _): + return "paused by schedule: \(reason)" + case .policy(let reason): + return "paused by policy\(reason.map { ": \($0)" } ?? "")" + } + } +} + +struct PauseStateResolver: Sendable { + static func resolve( + userPaused: Bool, + scheduledWindows: [ScheduledPauseWindow], + policyPaused: Bool, + policyReason: String?, + now: Date + ) -> EffectivePauseState { + if userPaused { + return .manual + } + if let window = scheduledWindows.first(where: { $0.startsAt <= now && now < $0.endsAt }) { + return .scheduled(id: window.id, reason: window.reason, endsAt: window.endsAt) + } + if policyPaused { + return .policy(reason: policyReason) + } + return .active + } + + static func nextTransition(after now: Date, scheduledWindows: [ScheduledPauseWindow]) -> Date? { + scheduledWindows + .flatMap { [$0.startsAt, $0.endsAt] } + .filter { $0 > now } + .sorted() + .first + } +} diff --git a/Sources/agentd/Pipeline.swift b/Sources/agentd/Pipeline.swift index 1e3fab9..e1bc733 100644 --- a/Sources/agentd/Pipeline.swift +++ b/Sources/agentd/Pipeline.swift @@ -22,6 +22,9 @@ struct ProcessedFrame: Sendable, Codable { let widthPx: Int let heightPx: Int let bytesPng: Int + let displayId: UInt32 + let displayScale: Double? + let mainDisplay: Bool enum CodingKeys: String, CodingKey { case frameHash @@ -37,6 +40,9 @@ struct ProcessedFrame: Sendable, Codable { case widthPx case heightPx case bytesPng + case displayId + case displayScale + case mainDisplay } init( @@ -52,7 +58,10 @@ struct ProcessedFrame: Sendable, Codable { ocrConfidence: Float, widthPx: Int, heightPx: Int, - bytesPng: Int + bytesPng: Int, + displayId: UInt32 = 0, + displayScale: Double? = nil, + mainDisplay: Bool = false ) { self.frameHash = frameHash self.perceptualHash = perceptualHash @@ -67,6 +76,9 @@ struct ProcessedFrame: Sendable, Codable { self.widthPx = widthPx self.heightPx = heightPx self.bytesPng = bytesPng + self.displayId = displayId + self.displayScale = displayScale + self.mainDisplay = mainDisplay } init(from decoder: Decoder) throws { @@ -94,6 +106,15 @@ struct ProcessedFrame: Sendable, Codable { let raw = try container.decode(String.self, forKey: .bytesPng) bytesPng = Int(raw) ?? 0 } + if let value = try? container.decode(UInt32.self, forKey: .displayId) { + displayId = value + } else if let raw = try? container.decode(String.self, forKey: .displayId) { + displayId = UInt32(raw) ?? 0 + } else { + displayId = 0 + } + displayScale = try container.decodeIfPresent(Double.self, forKey: .displayScale) + mainDisplay = try container.decodeIfPresent(Bool.self, forKey: .mainDisplay) ?? false } func encode(to encoder: Encoder) throws { @@ -113,6 +134,9 @@ struct ProcessedFrame: Sendable, Codable { try container.encode(heightPx, forKey: .heightPx) // Connect-protocol JSON serializes int64/uint64 as strings; keep this wire contract aligned with cmd/chronicle. try container.encode(String(bytesPng), forKey: .bytesPng) + try container.encode(displayId, forKey: .displayId) + try container.encodeIfPresent(displayScale, forKey: .displayScale) + try container.encode(mainDisplay, forKey: .mainDisplay) } } @@ -126,8 +150,77 @@ struct Batch: Sendable, Codable { let repository: String? let startedAt: Date let endedAt: Date + let captureWindow: CaptureWindow let frames: [ProcessedFrame] let droppedCounts: DropCounts + + init( + batchId: String, + deviceId: String, + organizationId: String, + workspaceId: String?, + userId: String?, + projectId: String?, + repository: String?, + startedAt: Date, + endedAt: Date, + captureWindow: CaptureWindow? = nil, + frames: [ProcessedFrame], + droppedCounts: DropCounts + ) { + self.batchId = batchId + self.deviceId = deviceId + self.organizationId = organizationId + self.workspaceId = workspaceId + self.userId = userId + self.projectId = projectId + self.repository = repository + self.startedAt = startedAt + self.endedAt = endedAt + self.captureWindow = captureWindow ?? CaptureWindow(startedAt: startedAt, endedAt: endedAt) + self.frames = frames + self.droppedCounts = droppedCounts + } + + enum CodingKeys: String, CodingKey { + case batchId + case deviceId + case organizationId + case workspaceId + case userId + case projectId + case repository + case startedAt + case endedAt + case captureWindow + case frames + case droppedCounts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let startedAt = try container.decode(Date.self, forKey: .startedAt) + let endedAt = try container.decode(Date.self, forKey: .endedAt) + self.init( + batchId: try container.decode(String.self, forKey: .batchId), + deviceId: try container.decode(String.self, forKey: .deviceId), + organizationId: try container.decode(String.self, forKey: .organizationId), + workspaceId: try container.decodeIfPresent(String.self, forKey: .workspaceId), + userId: try container.decodeIfPresent(String.self, forKey: .userId), + projectId: try container.decodeIfPresent(String.self, forKey: .projectId), + repository: try container.decodeIfPresent(String.self, forKey: .repository), + startedAt: startedAt, + endedAt: endedAt, + captureWindow: try container.decodeIfPresent(CaptureWindow.self, forKey: .captureWindow), + frames: try container.decode([ProcessedFrame].self, forKey: .frames), + droppedCounts: try container.decode(DropCounts.self, forKey: .droppedCounts) + ) + } +} + +struct CaptureWindow: Sendable, Codable, Equatable { + let startedAt: Date + let endedAt: Date } struct DropCounts: Sendable, Codable { @@ -163,6 +256,26 @@ struct DropCounts: Sendable, Codable { } } +struct OcrBudgetController: Sendable, Equatable { + static func maxPersistedCharacters( + config: AgentConfig, + pendingBytes: Int64, + droppedBackpressure: Int + ) -> Int { + guard config.maxOcrTextChars > 0 else { return 0 } + let minChars = max(0, min(config.adaptiveOcrMinChars, config.maxOcrTextChars)) + let underBackpressure = + config.adaptiveOcrBackpressureThreshold > 0 + && droppedBackpressure >= config.adaptiveOcrBackpressureThreshold + let overBacklog = + config.adaptiveOcrBacklogBytes > 0 && pendingBytes >= config.adaptiveOcrBacklogBytes + guard underBackpressure || overBacklog else { + return config.maxOcrTextChars + } + return minChars + } +} + struct DedupWindow: Sendable { private var hashes: [PerceptualHash] = [] let capacity: Int @@ -272,7 +385,13 @@ actor FramePipeline { break } - let ocrText = truncated(ocrResult.text, maxChars: config.maxOcrTextChars) + let pendingBytes = pending.reduce(Int64(0)) { $0 + Int64($1.bytesPng) } + let maxOcrChars = OcrBudgetController.maxPersistedCharacters( + config: config, + pendingBytes: pendingBytes, + droppedBackpressure: droppedBackpressure + ) + let ocrText = truncated(ocrResult.text, maxChars: maxOcrChars) // `bytesPng` is a cheap raw-BGRA size estimate; raw pixels stay on device and are not PNG-encoded. let estimatedBytes = frame.cgImage.width * frame.cgImage.height * 4 let frameHash = sha256Hex( @@ -292,7 +411,10 @@ actor FramePipeline { ocrConfidence: ocrResult.confidence, widthPx: frame.cgImage.width, heightPx: frame.cgImage.height, - bytesPng: estimatedBytes + bytesPng: estimatedBytes, + displayId: frame.displayId, + displayScale: frame.displayScale, + mainDisplay: frame.mainDisplay ) pending.append(processed) diff --git a/Sources/agentd/Submitter.swift b/Sources/agentd/Submitter.swift index a6b3529..8472491 100644 --- a/Sources/agentd/Submitter.swift +++ b/Sources/agentd/Submitter.swift @@ -17,6 +17,7 @@ actor Submitter { private let localBatchCryptor: LocalBatchCryptor? private var isRetryingLocalBatches = false private var retryLocalBatchesNeedsRerun = false + private var lastResultDescription: String? init( endpoint: URL, @@ -111,6 +112,7 @@ actor Submitter { if localOnly { await persistLocal(batch.batchId, data: fallbackData) + lastResultDescription = "persisted local batch \(batch.batchId)" return .persistedLocal } @@ -122,6 +124,7 @@ actor Submitter { "remote submit prepare failed batch=\(batch.batchId, privacy: .public) error=\(error.localizedDescription, privacy: .public) — falling back to local" ) await persistLocal(batch.batchId, data: fallbackData) + lastResultDescription = "persisted local fallback batch \(batch.batchId)" return .persistedLocal } @@ -133,10 +136,12 @@ actor Submitter { "local batch replay submitted=\(replay.submitted, privacy: .public) failed=\(replay.failed, privacy: .public)" ) } + lastResultDescription = "submitted batch \(batch.batchId)" return result } await persistLocal(batch.batchId, data: fallbackData) + lastResultDescription = "persisted local fallback batch \(batch.batchId)" return .persistedLocal } @@ -387,6 +392,40 @@ actor Submitter { ) } + func localBatchSummaries() async -> [LocalBatchSummary] { + localBatchFiles().sorted(by: { $0.modified > $1.modified }).map { + LocalBatchSummary( + batchId: $0.batchId, + fileName: $0.url.lastPathComponent, + modified: $0.modified, + bytes: $0.size, + encrypted: $0.encrypted + ) + } + } + + @discardableResult + func deleteLocalBatches(batchIds: Set? = nil) async -> Int { + var removed = 0 + for file in localBatchFiles() { + if let batchIds, !batchIds.contains(file.batchId) { + continue + } + if (try? FileManager.default.removeItem(at: file.url)) != nil { + removed += 1 + } + } + return removed + } + + func batchDirectoryURL() -> URL { + batchDirectory + } + + func lastSubmitResult() -> String? { + lastResultDescription + } + private func localBatchFiles() -> [LocalBatchFile] { guard let urls = try? FileManager.default.contentsOfDirectory( @@ -451,6 +490,14 @@ struct LocalBatchStats: Sendable, Equatable { let bytes: Int64 } +struct LocalBatchSummary: Sendable, Codable, Equatable { + let batchId: String + let fileName: String + let modified: Date + let bytes: Int64 + let encrypted: Bool +} + protocol HTTPClient: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse) } diff --git a/Sources/agentd/main.swift b/Sources/agentd/main.swift index 8ee4a0a..221f2a8 100644 --- a/Sources/agentd/main.swift +++ b/Sources/agentd/main.swift @@ -15,9 +15,12 @@ final class AppController { private var userPaused = false private var captureRunning = false private var controlState = ChronicleControlState() + private var scheduledPauseWindows: [ScheduledPauseWindow] = [] + private var policySource: String? private var flushTimer: Timer? private var idleTimer: Timer? private var heartbeatTimer: Timer? + private var pauseWindowTimer: Timer? private var idleMode = false init() { @@ -97,10 +100,27 @@ final class AppController { _ = self }, onOpenBatchesDir: { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".evalops/agentd/batches") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - NSWorkspace.shared.activateFileViewerSelecting([dir]) + Task { @MainActor [weak self] in + guard let self else { return } + let dir = await self.submitter.batchDirectoryURL() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + NSWorkspace.shared.activateFileViewerSelecting([dir]) + } + }, + onOpenDiagnostics: { [weak self] in + Task { @MainActor in await self?.openDiagnosticsReport() } + }, + onDeleteQueuedBatches: { [weak self] in + Task { @MainActor in await self?.deleteQueuedBatches() } + }, + onLaunchAtLoginToggle: { enabled in + do { + try LaunchAtLoginController.setEnabled(enabled) + } catch { + Log.app.error( + "launch-at-login update failed: \(error.localizedDescription, privacy: .public)" + ) + } }, onQuit: { Task { @MainActor in NSApp.terminate(nil) } @@ -217,11 +237,14 @@ final class AppController { private func makeHeartbeatRequest() async -> HeartbeatRequest { let pending = await pipeline.pendingStats() let local = await submitter.localBatchStats() + let pauseState = effectivePauseState() return HeartbeatRequest( deviceId: config.deviceId, organizationId: config.organizationId, pendingFrameCount: pending.frameCount + local.fileCount, - pendingBytes: pending.estimatedBytes + local.bytes + pendingBytes: pending.estimatedBytes + local.bytes, + paused: pauseState.paused, + pauseReason: pauseState.reason ) } @@ -234,6 +257,10 @@ final class AppController { if !policy.policyVersion.isEmpty { controlState.lastPolicyVersion = policy.policyVersion } + if !policy.sourcePolicyRef.isEmpty { + policySource = policy.sourcePolicyRef + } + scheduledPauseWindows = policy.scheduledPauseWindows if policy.captureMode == .paused { controlState.serverPaused = true controlState.serverPauseReason = @@ -256,11 +283,13 @@ final class AppController { await capture.updateFps(idleMode ? config.idleFps : config.captureFps) } await reconcileCaptureState() + schedulePauseWindowTimer() updateMenuStatus() } private func reconcileCaptureState() async { - let shouldPause = userPaused || controlState.serverPaused + let pauseState = effectivePauseState() + let shouldPause = pauseState.paused if shouldPause, captureRunning { await capture.stop() captureRunning = false @@ -274,6 +303,71 @@ final class AppController { Log.app.error("capture start failed: \(error.localizedDescription, privacy: .public)") } } + schedulePauseWindowTimer() + updateMenuStatus() + } + + private func effectivePauseState(now: Date = Date()) -> EffectivePauseState { + PauseStateResolver.resolve( + userPaused: userPaused, + scheduledWindows: scheduledPauseWindows, + policyPaused: controlState.serverPaused, + policyReason: controlState.serverPauseReason, + now: now + ) + } + + private func schedulePauseWindowTimer(now: Date = Date()) { + pauseWindowTimer?.invalidate() + guard + let transition = PauseStateResolver.nextTransition( + after: now, + scheduledWindows: scheduledPauseWindows + ) + else { + pauseWindowTimer = nil + return + } + let interval = max(0.25, transition.timeIntervalSince(now) + 0.1) + pauseWindowTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { + [weak self] _ in + Task { @MainActor in await self?.reconcileCaptureState() } + } + } + + private func openDiagnosticsReport() async { + let permissions = PermissionSnapshot.current(promptForAccessibility: false) + let pending = await pipeline.pendingStats() + let localStats = await submitter.localBatchStats() + let localBatches = await submitter.localBatchSummaries() + let lastSubmitResult = await submitter.lastSubmitResult() + let snapshot = DiagnosticsSnapshot( + generatedAt: Date(), + appVersion: Bundle.main.appVersion, + captureState: effectivePauseState().detail, + permissions: permissions, + config: config, + policyVersion: controlState.lastPolicyVersion, + policySource: policySource, + controlError: controlState.lastError, + pendingStats: pending, + localBatchStats: localStats, + localBatches: localBatches, + lastSubmitResult: lastSubmitResult + ) + do { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".evalops/agentd/diagnostics") + let report = try DiagnosticsReport.write(snapshot, directory: dir) + NSWorkspace.shared.activateFileViewerSelecting([report]) + } catch { + Log.app.error("diagnostics report failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func deleteQueuedBatches() async { + let removed = await submitter.deleteLocalBatches() + Log.submit.notice("deleted queued local batches count=\(removed, privacy: .public)") updateMenuStatus() } @@ -292,10 +386,9 @@ final class AppController { private func updateMenuStatus() { let detail: String - if userPaused { - detail = "paused by user" - } else if controlState.serverPaused { - detail = "paused by policy" + let pauseState = effectivePauseState() + if pauseState.paused { + detail = pauseState.detail } else if captureRunning { detail = controlState.registered ? "capturing, registered" : "capturing" } else if let error = controlState.lastError { @@ -304,7 +397,7 @@ final class AppController { detail = "stopped" } menuBar?.setStatus( - paused: userPaused || controlState.serverPaused || !captureRunning, + paused: pauseState.paused || !captureRunning, detail: detail, localOnly: config.localOnly, policyVersion: controlState.lastPolicyVersion diff --git a/Tests/Fixtures/chronicle/broker_submit_batch.json b/Tests/Fixtures/chronicle/broker_submit_batch.json new file mode 100644 index 0000000..e58a4ba --- /dev/null +++ b/Tests/Fixtures/chronicle/broker_submit_batch.json @@ -0,0 +1,6 @@ +{ + "localOnly": false, + "secretBrokerArtifactId": "artifact_1", + "secretBrokerGrantId": "grant_1", + "secretBrokerSessionToken": "broker-session" +} diff --git a/Tests/Fixtures/chronicle/heartbeat_request.json b/Tests/Fixtures/chronicle/heartbeat_request.json new file mode 100644 index 0000000..4004d81 --- /dev/null +++ b/Tests/Fixtures/chronicle/heartbeat_request.json @@ -0,0 +1,8 @@ +{ + "deviceId": "device_1", + "organizationId": "org_1", + "pauseReason": "scheduled:meeting", + "paused": true, + "pendingBytes": "4096", + "pendingFrameCount": 3 +} diff --git a/Tests/Fixtures/chronicle/inline_submit_batch.json b/Tests/Fixtures/chronicle/inline_submit_batch.json new file mode 100644 index 0000000..75a154c --- /dev/null +++ b/Tests/Fixtures/chronicle/inline_submit_batch.json @@ -0,0 +1,45 @@ +{ + "batch": { + "batchId": "batch_fixture", + "captureWindow": { + "endedAt": "2026-04-28T16:01:00Z", + "startedAt": "2026-04-28T16:00:00Z" + }, + "deviceId": "device_1", + "droppedCounts": { + "deniedApp": 0, + "deniedPath": 0, + "droppedBackpressure": 0, + "duplicate": 0, + "secret": 0 + }, + "endedAt": "2026-04-28T16:01:00Z", + "frames": [ + { + "appName": "Code", + "bundleId": "com.microsoft.VSCode", + "bytesPng": "400", + "capturedAt": "2026-04-28T16:00:30Z", + "displayId": 1, + "displayScale": 2, + "documentPath": "/Users/alice/src/platform/proto/chronicle/v1/chronicle.proto", + "frameHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "heightPx": 10, + "mainDisplay": true, + "ocrConfidence": 0.93, + "ocrText": "ChronicleService SubmitBatch", + "ocrTextTruncated": false, + "perceptualHash": "42", + "widthPx": 10, + "windowTitle": "chronicle.proto" + } + ], + "organizationId": "org_1", + "projectId": "project_1", + "repository": "evalops/platform", + "startedAt": "2026-04-28T16:00:00Z", + "userId": "user_1", + "workspaceId": "workspace_1" + }, + "localOnly": true +} diff --git a/Tests/Fixtures/chronicle/malformed_policy.json b/Tests/Fixtures/chronicle/malformed_policy.json new file mode 100644 index 0000000..b53366c --- /dev/null +++ b/Tests/Fixtures/chronicle/malformed_policy.json @@ -0,0 +1,7 @@ +{ + "policy": { + "captureMode": "CAPTURE_MODE_UNSPECIFIED", + "policyVersion": "malformed_missing_required_windows_shape", + "scheduledPauseWindows": "not-an-array" + } +} diff --git a/Tests/Fixtures/chronicle/policy_response.json b/Tests/Fixtures/chronicle/policy_response.json new file mode 100644 index 0000000..aa79070 --- /dev/null +++ b/Tests/Fixtures/chronicle/policy_response.json @@ -0,0 +1,11 @@ +{ + "policy": { + "allowedBundleIds": [ + "com.test.App" + ], + "captureMode": "CAPTURE_MODE_HYBRID", + "maxFramesPerBatch": 8, + "policyVersion": "policy_1", + "scheduledPauseWindows": [] + } +} diff --git a/Tests/Fixtures/chronicle/secret_broker_wrap.json b/Tests/Fixtures/chronicle/secret_broker_wrap.json new file mode 100644 index 0000000..5aca1bd --- /dev/null +++ b/Tests/Fixtures/chronicle/secret_broker_wrap.json @@ -0,0 +1,17 @@ +{ + "capability": "chronicle.frame_batch", + "metadata": { + "batch_id": "batch_fixture", + "device_id": "device_1", + "organization_id": "org_1", + "source": "agentd" + }, + "reason": "agentd Chronicle frame batch", + "resource_ref": "chronicle://org_1/device_1/batch_fixture", + "secret_data": { + "chronicle_frame_batch_json": "{\"batchId\":\"batch_fixture\",\"organizationId\":\"org_1\"}" + }, + "session_token": "broker-session", + "tool": "chronicle.agentd", + "ttl_seconds": 300 +} diff --git a/Tests/Fixtures/chronicle/server_pause_policy.json b/Tests/Fixtures/chronicle/server_pause_policy.json new file mode 100644 index 0000000..bb5dd1c --- /dev/null +++ b/Tests/Fixtures/chronicle/server_pause_policy.json @@ -0,0 +1,15 @@ +{ + "policy": { + "captureMode": "CAPTURE_MODE_PAUSED", + "policyVersion": "policy_pause", + "scheduledPauseWindows": [ + { + "endsAt": "2026-04-28T17:00:00Z", + "id": "meeting_1", + "reason": "customer meeting", + "startsAt": "2026-04-28T16:00:00Z" + } + ], + "sourcePolicyRef": "calendar://meeting_1" + } +} diff --git a/Tests/agentdTests/ChronicleControlTests.swift b/Tests/agentdTests/ChronicleControlTests.swift index 2b6eaf3..c56a748 100644 --- a/Tests/agentdTests/ChronicleControlTests.swift +++ b/Tests/agentdTests/ChronicleControlTests.swift @@ -32,6 +32,7 @@ final class ChronicleControlTests: XCTestCase { XCTAssertEqual(root["organizationId"] as? String, "org_1") XCTAssertEqual(root["pendingFrameCount"] as? Int, 2) XCTAssertEqual(root["pendingBytes"] as? String, "123456") + XCTAssertEqual(root["paused"] as? Bool, false) } func testControlClientRegistersAndHeartbeatsWithBearerAuth() async throws { @@ -61,9 +62,11 @@ final class ChronicleControlTests: XCTestCase { XCTAssertTrue(url.path.hasSuffix("/Heartbeat")) XCTAssertEqual(root["pendingFrameCount"] as? Int, 3) XCTAssertEqual(root["pendingBytes"] as? String, "4096") + XCTAssertEqual(root["paused"] as? Bool, true) + XCTAssertEqual(root["pauseReason"] as? String, "scheduled:meeting") return ( Data( - #"{"device":{"deviceId":"device_1","organizationId":"org_1","paused":true,"pauseReason":"policy"},"policy":{"policyVersion":"p2","captureMode":"CAPTURE_MODE_PAUSED"}}"# + #"{"device":{"deviceId":"device_1","organizationId":"org_1","paused":true,"pauseReason":"policy"},"policy":{"policyVersion":"p2","captureMode":"CAPTURE_MODE_PAUSED","scheduledPauseWindows":[{"id":"meeting_1","reason":"meeting","startsAt":"2026-04-28T16:00:00Z","endsAt":"2026-04-28T17:00:00Z"}]}}"# .utf8), Self.response(for: url, statusCode: 200) ) @@ -92,13 +95,16 @@ final class ChronicleControlTests: XCTestCase { deviceId: "device_1", organizationId: "org_1", pendingFrameCount: 3, - pendingBytes: 4096 + pendingBytes: 4096, + paused: true, + pauseReason: "scheduled:meeting" )) XCTAssertEqual(register.policy?.policyVersion, "p1") XCTAssertEqual(register.policy?.maxFramesPerBatch, 4) XCTAssertEqual(heartbeat.device?.paused, true) XCTAssertEqual(heartbeat.policy?.captureMode, .paused) + XCTAssertEqual(heartbeat.policy?.scheduledPauseWindows.first?.id, "meeting_1") let requestCount = await recorder.count() XCTAssertEqual(requestCount, 2) } diff --git a/Tests/agentdTests/DiagnosticsTests.swift b/Tests/agentdTests/DiagnosticsTests.swift new file mode 100644 index 0000000..b5a5d47 --- /dev/null +++ b/Tests/agentdTests/DiagnosticsTests.swift @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import XCTest + +@testable import agentd + +final class DiagnosticsTests: XCTestCase { + func testDiagnosticsReportRedactsSecretsAndPathsButKeepsQueueSummary() { + let cfg = AgentConfig( + deviceId: "device_1", + organizationId: "org_1", + endpoint: URL( + string: + "https://chronicle.example.com/chronicle.v1.ChronicleService/SubmitBatch?token=secret" + )!, + allowedBundleIds: ["com.test.App"], + deniedBundleIds: AgentConfig.defaultDeniedBundleIds, + deniedPathPrefixes: ["\(NSHomeDirectory())/.ssh", ".aws"], + pauseWindowTitlePatterns: ["prod \(SecretScrubberTests.jwtFixture())"], + captureFps: 1, + idleFps: 0.2, + batchIntervalSeconds: 30, + maxFramesPerBatch: 24, + localOnly: false, + encryptLocalBatches: true, + auth: .bearer(keychainService: "svc", keychainAccount: "acct") + ) + let snapshot = DiagnosticsSnapshot( + generatedAt: Date(timeIntervalSince1970: 1), + appVersion: "1.0.0", + captureState: "paused by schedule: interview", + permissions: PermissionSnapshot(accessibilityTrusted: true, screenCaptureTrusted: false), + config: cfg, + policyVersion: "policy_1", + policySource: "chronicle://policy/\(SecretScrubberTests.jwtFixture())", + controlError: "none", + pendingStats: PendingFrameStats(frameCount: 2, estimatedBytes: 4096), + localBatchStats: LocalBatchStats(fileCount: 1, bytes: 1234), + localBatches: [ + LocalBatchSummary( + batchId: "batch_1", + fileName: "batch_1.agentdbatch", + modified: Date(timeIntervalSince1970: 2), + bytes: 1234, + encrypted: true + ) + ], + lastSubmitResult: "persisted local fallback batch batch_1" + ) + + let report = DiagnosticsReport.markdown(snapshot) + + XCTAssertTrue(report.contains("Queued local batches: 1")) + XCTAssertTrue(report.contains("| batch_1 |")) + XCTAssertTrue( + report.contains("https://chronicle.example.com/chronicle.v1.ChronicleService/SubmitBatch")) + XCTAssertFalse(report.contains("token=secret")) + XCTAssertFalse(report.contains(SecretScrubberTests.jwtFixture())) + XCTAssertTrue(report.contains("[redacted]")) + XCTAssertTrue(report.contains("~/.ssh")) + } +} diff --git a/Tests/agentdTests/PauseStateTests.swift b/Tests/agentdTests/PauseStateTests.swift new file mode 100644 index 0000000..dd34dd6 --- /dev/null +++ b/Tests/agentdTests/PauseStateTests.swift @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import XCTest + +@testable import agentd + +final class PauseStateTests: XCTestCase { + func testManualPauseWinsOverScheduledAndPolicyPause() { + let now = Date(timeIntervalSince1970: 100) + let window = ScheduledPauseWindow( + id: "meeting_1", + reason: "customer meeting", + startsAt: now.addingTimeInterval(-10), + endsAt: now.addingTimeInterval(10) + ) + + let state = PauseStateResolver.resolve( + userPaused: true, + scheduledWindows: [window], + policyPaused: true, + policyReason: "fleet", + now: now + ) + + XCTAssertEqual(state, .manual) + XCTAssertEqual(state.reason, "manual") + } + + func testScheduledPauseWinsOverPolicyAndExpires() { + let now = Date(timeIntervalSince1970: 100) + let window = ScheduledPauseWindow( + id: "meeting_1", + reason: "interview", + startsAt: now.addingTimeInterval(-10), + endsAt: now.addingTimeInterval(10) + ) + + XCTAssertEqual( + PauseStateResolver.resolve( + userPaused: false, + scheduledWindows: [window], + policyPaused: true, + policyReason: "fleet", + now: now + ), + .scheduled(id: "meeting_1", reason: "interview", endsAt: now.addingTimeInterval(10)) + ) + XCTAssertEqual( + PauseStateResolver.resolve( + userPaused: false, + scheduledWindows: [window], + policyPaused: false, + policyReason: nil, + now: now.addingTimeInterval(11) + ), + .active + ) + } + + func testNextScheduledPauseTransitionUsesStartOrEnd() { + let now = Date(timeIntervalSince1970: 100) + let window = ScheduledPauseWindow( + id: "meeting_1", + reason: "meeting", + startsAt: now.addingTimeInterval(30), + endsAt: now.addingTimeInterval(90) + ) + + XCTAssertEqual( + PauseStateResolver.nextTransition(after: now, scheduledWindows: [window]), + now.addingTimeInterval(30) + ) + XCTAssertEqual( + PauseStateResolver.nextTransition( + after: now.addingTimeInterval(40), scheduledWindows: [window]), + now.addingTimeInterval(90) + ) + } +} diff --git a/Tests/agentdTests/PipelineTests.swift b/Tests/agentdTests/PipelineTests.swift index 2874509..431e4b5 100644 --- a/Tests/agentdTests/PipelineTests.swift +++ b/Tests/agentdTests/PipelineTests.swift @@ -114,6 +114,32 @@ final class PipelineTests: XCTestCase { let batch = try XCTUnwrap(batches.first) XCTAssertEqual(batch.frames.count, 2) XCTAssertEqual(batch.frames.first?.bytesPng, 8 * 8 * 4) + XCTAssertEqual(batch.frames.first?.displayId, 1) + XCTAssertEqual(batch.frames.first?.displayScale, 2.0) + XCTAssertTrue(batch.frames.first?.mainDisplay == true) + } + + func testAdaptiveOcrBudgetCapsPersistedTextWhenBackpressureIsHigh() async throws { + let recorder = BatchRecorder() + var cfg = Self.config(maxOcrTextChars: 128) + cfg.adaptiveOcrMinChars = 16 + cfg.adaptiveOcrBackpressureThreshold = 1 + let pipeline = FramePipeline( + config: cfg, + ocr: StubOCR(text: String(repeating: "a", count: 128)) + ) { batch in + await recorder.append(batch) + } + + await pipeline.recordBackpressureDrop() + await pipeline.consume(Self.frame(bits: 0xAAAA_AAAA_AAAA_AAAA), context: Self.context()) + await pipeline.flush() + + let batches = await recorder.snapshot() + let frame = try XCTUnwrap(batches.first?.frames.first) + XCTAssertEqual(frame.ocrText.count, 16) + XCTAssertTrue(frame.ocrTextTruncated) + XCTAssertEqual(batches.first?.droppedCounts.droppedBackpressure, 1) } func testManualFlushEmitsPendingFrame() async throws { @@ -270,7 +296,13 @@ final class PipelineTests: XCTestCase { } static func frame(bits: UInt64) -> CapturedFrame { - CapturedFrame(timestamp: Date(), cgImage: image(bits: bits), displayId: 1) + CapturedFrame( + timestamp: Date(), + cgImage: image(bits: bits), + displayId: 1, + displayScale: 2.0, + mainDisplay: true + ) } static func image(bits: UInt64) -> CGImage { diff --git a/Tests/agentdTests/SubmitterTests.swift b/Tests/agentdTests/SubmitterTests.swift index 47b9442..a870eba 100644 --- a/Tests/agentdTests/SubmitterTests.swift +++ b/Tests/agentdTests/SubmitterTests.swift @@ -45,10 +45,12 @@ final class SubmitterTests: XCTestCase { XCTAssertEqual(encodedBatch["organizationId"] as? String, "org_1") XCTAssertEqual(encodedBatch["projectId"] as? String, "project_1") XCTAssertNil(encodedBatch["orgId"]) + XCTAssertNotNil(encodedBatch["captureWindow"]) let frames = try XCTUnwrap(encodedBatch["frames"] as? [[String: Any]]) XCTAssertEqual(frames.first?["perceptualHash"] as? String, "42") XCTAssertEqual(frames.first?["bytesPng"] as? String, "120000") + XCTAssertEqual(frames.first?["displayId"] as? Int, 0) XCTAssertEqual(frames.first?["ocrTextTruncated"] as? Bool, false) XCTAssertEqual(frames.first?["bundleId"] as? String, "com.microsoft.VSCode") XCTAssertEqual(frames.first?["frameHash"] as? String, String(repeating: "a", count: 64)) diff --git a/docs/chronicle-comparison.md b/docs/chronicle-comparison.md new file mode 100644 index 0000000..539732f --- /dev/null +++ b/docs/chronicle-comparison.md @@ -0,0 +1,32 @@ +# Chronicle Comparison + +This page captures the practical delta between agentd and OpenAI Codex +Chronicle-style local capture. + +| Axis | agentd | Codex Chronicle-style capture | +| --- | --- | --- | +| Primary use | Enterprise/work audit trail for humans and agents | Personal memory for Codex | +| Capture governance | Fleet `CapturePolicy` plus local hard-deny rails | Single-user app policy | +| Secret handling | Content-aware fail-closed scrub before persistence | Window/app identity filter | +| Storage fallback | Encrypted `.agentdbatch` by default in managed modes | Plain sidecars and summaries | +| Broker mode | Optional ASB Secret Broker artifact wrapping | No broker artifact path | +| Summarization | Server/control-plane concern, not device default | LLM summarizer loop | +| Prompt-injection posture | Observed content is not fed to an on-device agent by default | Prompt framing around observed content | +| Release evidence | Developer ID/notarization workflow plus hardware-smoke helper | Signed/notarized app bundle | + +Borrowed ideas worth keeping: + +- material-text-change OCR diffs as a secondary sampler; +- multi-display observability; +- downstream heartbeat-recency checks; +- explicit prompt-injection taxonomies for any future summarizer consumer. + +Things agentd should not copy: + +- shipping frames or OCR text to a third-party LLM provider for summarization; +- window-identity-only privacy controls without content scrub; +- plaintext local memories as the default managed-mode storage; +- audio capture without a stated audit reason; +- update metadata that can advance without the signing/notarization evidence + chain. + diff --git a/docs/contract-harness.md b/docs/contract-harness.md new file mode 100644 index 0000000..b1efd27 --- /dev/null +++ b/docs/contract-harness.md @@ -0,0 +1,37 @@ +# Chronicle Contract Harness + +`scripts/mock_chronicle.py` is the local strict harness for agentd's Chronicle +and Secret Broker HTTP/JSON contracts. + +Run fixture validation: + +```sh +python3 scripts/mock_chronicle.py --self-test Tests/Fixtures/chronicle +``` + +Run a local mock server: + +```sh +python3 scripts/mock_chronicle.py --host 127.0.0.1 --port 8787 +``` + +The mock supports the client-facing Chronicle methods agentd needs for local +simulation: + +- `RegisterDevice` +- `Heartbeat` +- `GetCapturePolicy` +- `SubmitBatch` +- `PauseSession` +- `ResumeSession` +- `AcknowledgeMemory` + +It also supports the Secret Broker `/v1/artifacts:wrap` route used by broker +mode. The harness rejects unknown request fields so generated fixtures act as an +explicit drift gate until agentd can consume generated `chronicle.v1` Swift +types directly. + +Golden fixtures live in `Tests/Fixtures/chronicle/` and cover inline +`SubmitBatch`, broker-wrapped submit, heartbeat pause state, policy responses, +server pause windows, malformed policy input, and Secret Broker wrapping. + diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..f041b92 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,22 @@ +# Local Diagnostics + +agentd exposes a local diagnostics report from the menu bar. The report is a +redacted Markdown file under `~/.evalops/agentd/diagnostics/` and is intended +for dogfood, support, and hardware-smoke evidence without opening raw batch +JSON. + +The report includes: + +- capture state and pause reason; +- Screen Recording and Accessibility permission preflight; +- app version, local-only/managed mode, Secret Broker mode, policy version, and + policy source; +- in-memory frame pressure and queued local batch count/bytes; +- queued batch id, modification time, size, and encryption state. + +The report omits OCR text and raw queued payloads. It strips endpoint query +strings, redacts secret-looking strings, and shortens home-directory paths. + +The menu also includes `Delete Queued Batches`, which removes local plaintext +and encrypted fallback batches from the configured batch directory. + diff --git a/docs/release-update-channel.md b/docs/release-update-channel.md new file mode 100644 index 0000000..dde8229 --- /dev/null +++ b/docs/release-update-channel.md @@ -0,0 +1,23 @@ +# Release And Update Channel + +agentd now uses native `SMAppService.mainApp` for launch-at-login. Users can +toggle it from the menu bar; the app does not install ad hoc LaunchAgent plists. + +The signed update-channel path is intentionally evidence-first: + +1. Package `dist/EvalOps agentd.app`. +2. Sign with Developer ID Application and hardened runtime. +3. Notarize with `notarytool`. +4. Staple with `xcrun stapler`. +5. Validate with `spctl -a -t exec -vv`. +6. Publish `dist/agentd.zip`, `dist/SHA256SUMS`, `dist/codesign.txt`, and + `dist/spctl.txt` as release evidence. +7. Publish update metadata only after the artifact checksum, signing identity, + notarization request id, and Gatekeeper output are recorded. + +Sparkle remains the preferred full auto-update framework once product policy +allows automatic delivery. Until then, the update channel is a signed manual +feed: publish checksummed artifacts and metadata from the notarized workflow, +and never advance update metadata for an artifact that has not passed the same +sign, notarize, staple, and Gatekeeper checks. + diff --git a/docs/secret-scrub.md b/docs/secret-scrub.md new file mode 100644 index 0000000..a7bb1b7 --- /dev/null +++ b/docs/secret-scrub.md @@ -0,0 +1,23 @@ +# Secret Scrub + +agentd treats secret detection as a fail-closed capture rail. A match drops the +frame before persistence or submission; it does not redact and ship a partial +frame. + +The scrubber runs against: + +- OCR text before `maxOcrTextChars` truncation; +- active window title; +- focused document path. + +The pattern families include AWS keys, GCP service-account material, SSH keys, +JWTs, GitHub classic and fine-grained tokens, Google API keys, npm, SendGrid, +DigitalOcean, Azure storage keys, Mailgun, Twilio, Discord, Slack, Anthropic, +OpenAI, Stripe live keys, certificate requests, and generic password/API-key +fields. + +Local hard-deny rails still win over remote policy. Fleet `CapturePolicy` may +add allowlists or denylists, but the built-in password managers, keychain paths, +private/incognito/meeting title pauses, denied paths, and content-aware secret +scrub remain local fail-closed controls. + diff --git a/scripts/mock_chronicle.py b/scripts/mock_chronicle.py new file mode 100755 index 0000000..83dce4c --- /dev/null +++ b/scripts/mock_chronicle.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BUSL-1.1 + +"""Strict local mock for the agentd Chronicle and Secret Broker contracts.""" + +from __future__ import annotations + +import argparse +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +CHRONICLE_METHODS = { + "RegisterDevice": {"deviceId", "organizationId", "workspaceId", "userId", "hostname", "appVersion", "metadata"}, + "Heartbeat": {"deviceId", "organizationId", "pendingFrameCount", "pendingBytes", "paused", "pauseReason"}, + "GetCapturePolicy": {"deviceId", "organizationId"}, + "SubmitBatch": {"batch", "localOnly", "secretBrokerSessionToken", "secretBrokerArtifactId", "secretBrokerGrantId"}, + "PauseSession": {"deviceId", "organizationId", "reason"}, + "ResumeSession": {"deviceId", "organizationId", "reason"}, + "AcknowledgeMemory": {"deviceId", "organizationId", "memoryIds"}, +} + +FRAME_KEYS = { + "frameHash", + "perceptualHash", + "capturedAt", + "bundleId", + "appName", + "windowTitle", + "documentPath", + "ocrText", + "ocrTextTruncated", + "ocrConfidence", + "widthPx", + "heightPx", + "bytesPng", + "displayId", + "displayScale", + "mainDisplay", +} + +BATCH_KEYS = { + "batchId", + "deviceId", + "organizationId", + "workspaceId", + "userId", + "projectId", + "repository", + "startedAt", + "endedAt", + "captureWindow", + "frames", + "droppedCounts", +} + +WRAP_KEYS = { + "session_token", + "tool", + "capability", + "resource_ref", + "ttl_seconds", + "reason", + "secret_data", + "metadata", +} + + +def assert_known(name: str, value: dict[str, Any], allowed: set[str]) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise ValueError(f"{name} has unknown fields: {', '.join(unknown)}") + + +def validate_chronicle(method: str, body: dict[str, Any]) -> None: + if method not in CHRONICLE_METHODS: + raise ValueError(f"unsupported Chronicle method {method}") + assert_known(method, body, CHRONICLE_METHODS[method]) + if method == "SubmitBatch" and isinstance(body.get("batch"), dict): + batch = body["batch"] + assert_known("SubmitBatch.batch", batch, BATCH_KEYS) + if "captureWindow" not in batch: + raise ValueError("SubmitBatch.batch missing captureWindow") + for frame in batch.get("frames", []): + if not isinstance(frame, dict): + raise ValueError("SubmitBatch.batch.frames must contain objects") + assert_known("SubmitBatch.batch.frames[]", frame, FRAME_KEYS) + + +def validate_secret_broker(body: dict[str, Any]) -> None: + assert_known("SecretBroker.wrap", body, WRAP_KEYS) + secret_data = body.get("secret_data", {}) + if "chronicle_frame_batch_json" not in secret_data: + raise ValueError("SecretBroker.wrap missing chronicle_frame_batch_json") + json.loads(secret_data["chronicle_frame_batch_json"]) + + +def load_json(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text()) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object") + return data + + +def self_test(fixtures: Path) -> None: + cases = { + "inline_submit_batch.json": lambda data: validate_chronicle("SubmitBatch", data), + "broker_submit_batch.json": lambda data: validate_chronicle("SubmitBatch", data), + "heartbeat_request.json": lambda data: validate_chronicle("Heartbeat", data), + "policy_response.json": lambda data: None, + "server_pause_policy.json": lambda data: None, + "malformed_policy.json": lambda data: None, + "secret_broker_wrap.json": validate_secret_broker, + } + for name, validator in cases.items(): + validator(load_json(fixtures / name)) + print(f"validated {len(cases)} contract fixtures in {fixtures}") + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + try: + if self.path == "/v1/artifacts:wrap": + validate_secret_broker(body) + response = {"grant_id": "grant_local", "artifact_id": "artifact_local"} + else: + method = self.path.rstrip("/").split("/")[-1] + validate_chronicle(method, body) + response = self.response_for(method) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response).encode()) + except Exception as exc: # pragma: no cover - exercised through manual server use + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": str(exc)}).encode()) + + @staticmethod + def response_for(method: str) -> dict[str, Any]: + if method == "RegisterDevice": + return {"device": {"deviceId": "local", "organizationId": "local", "paused": False}} + if method == "Heartbeat": + return {"policy": {"policyVersion": "mock", "captureMode": "CAPTURE_MODE_HYBRID"}} + if method == "SubmitBatch": + return {"batchId": "mock_batch", "acceptedFrameCount": 1, "droppedFrameCount": 0} + return {} + + +def serve(host: str, port: int) -> None: + server = ThreadingHTTPServer((host, port), Handler) + print(f"mock Chronicle listening on http://{host}:{port}") + server.serve_forever() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", type=Path) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8787) + args = parser.parse_args() + if args.self_test: + self_test(args.self_test) + else: + serve(args.host, args.port) + + +if __name__ == "__main__": + main() diff --git a/scripts/package_app.sh b/scripts/package_app.sh index 7bc0e55..f8d4bee 100755 --- a/scripts/package_app.sh +++ b/scripts/package_app.sh @@ -58,5 +58,21 @@ if [[ "$notarized" == "1" ]] && command -v spctl >/dev/null 2>&1; then spctl -a -t exec -vv "$app_path" fi +zip_sha256="$(shasum -a 256 "$zip_path" | awk '{print $1}')" +app_binary_sha256="$(shasum -a 256 "$app_path/Contents/MacOS/$product" | awk '{print $1}')" +cat > "$dist_dir/update-channel.json" <