diff --git a/Sources/AgentNotch/App/AppDelegate.swift b/Sources/AgentNotch/App/AppDelegate.swift index 42d3401..c14eadf 100644 --- a/Sources/AgentNotch/App/AppDelegate.swift +++ b/Sources/AgentNotch/App/AppDelegate.swift @@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var server: HookServer? private var panelController: NotchPanelController? private var statusMenu: StatusMenu? + private let debug = ProcessInfo.processInfo.environment["AGENT_NOTCH_DEBUG"] == "1" func applicationDidFinishLaunching(_ notification: Notification) { store.onSessionFinished = { Sounds.finished($0.agent) } @@ -22,7 +23,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { MainActor.assumeIsolated { store.askRetention = Preferences.yourTurnFor } } - let debug = ProcessInfo.processInfo.environment["AGENT_NOTCH_DEBUG"] == "1" let server = HookServer(store: store, vercel: vercel, debug: debug) do { try server.start() @@ -34,11 +34,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusMenu = StatusMenu(store: store, vercel: vercel) panelController = NotchPanelController(store: store, viewModel: viewModel, vercel: vercel) panelController?.show() - if !debug { vercel.start() } // After launch finishes, so the notch is already on screen behind the dialog. DispatchQueue.main.async { ConnectAgents.promptIfNeeded() } } + /// Polling starts once the app is actually active, so a Keychain prompt has a focused window the user can type into. + func applicationDidBecomeActive(_ notification: Notification) { + guard !debug else { return } + vercel.adoptExistingIfNeeded() + vercel.start() + } + func applicationWillTerminate(_ notification: Notification) { store.resolveAll(decision: .terminal) server?.stop() diff --git a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift index ccf5458..d7a5c47 100644 --- a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift +++ b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift @@ -15,6 +15,8 @@ final class VercelMonitor: ObservableObject { @Published private(set) var deployments: [Deployment] = [] @Published private(set) var error: String? @Published private(set) var connected = false + /// Non-secret mirror of "there is a credential in the Keychain", so the menu never reads the Keychain and macOS never asks for a password just because the menu opened. + @Published private(set) var paired = false private var task: Task? private var tracker = DeploymentTracker() private var simulationTracker = DeploymentTracker() @@ -27,6 +29,7 @@ final class VercelMonitor: ObservableObject { init(shouldNotify: @escaping (Deployment) -> Bool = { _ in false }) { self.shouldNotify = shouldNotify + paired = UserDefaults.standard.bool(forKey: "vercelPaired") if let raw = Bundle.main.object(forInfoDictionaryKey: "VercelWorkerURL") as? String, let url = URL(string: raw), url.scheme == "https", url.host != nil { worker = url } else { worker = nil } @@ -34,7 +37,7 @@ final class VercelMonitor: ObservableObject { } var configured: Bool { worker != nil } - var hasCredentials: Bool { load() != nil } + var hasCredentials: Bool { paired } var active: Bool { connected && deployments.contains { $0.status == .pending || $0.status == .building } } func simulate(_ status: Deployment.Status) { @@ -45,15 +48,23 @@ final class VercelMonitor: ObservableObject { for finished in simulationTracker.update([item]) { Task { await notify(finished) } } } + /// One-time migration: before the `paired` flag existed, the only record of a connection was the Keychain item. + /// Called when the app is active so the prompt, if macOS shows one, has a focused window. + func adoptExistingIfNeeded() { + guard UserDefaults.standard.object(forKey: "vercelPaired") == nil else { return } + markPaired(load() != nil) + } + func start() { - guard let credentials = load() else { return } - task?.cancel() + // Reading the Keychain can raise a system password prompt; only do it once, and only when we know there is an item. + guard task == nil, paired else { return } + guard let credentials = load() else { markPaired(false); return } task = Task { await poll(credentials) } } func connect() { guard configured else { error = "Vercel integration is not configured."; return } - guard !hasCredentials else { start(); return } + guard !paired else { start(); return } task?.cancel() task = Task { do { @@ -73,7 +84,9 @@ final class VercelMonitor: ObservableObject { func disconnect() { task?.cancel() - guard let credentials = load() else { return } + // The user asked for this, so bring the app forward: the Keychain prompt is unusable while the app is not active. + NSApp.activate(ignoringOtherApps: true) + guard let credentials = load() else { paired = false; return } task = Task { do { _ = try await request("disconnect", credentials: credentials, method: "POST") @@ -163,6 +176,7 @@ final class VercelMonitor: ObservableObject { var item = query; item[kSecValueData as String] = data; item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw MonitorError.keychain } } else if status != errSecSuccess { throw MonitorError.keychain } + markPaired(true) } private func load() -> Credentials? { var item = query; item[kSecReturnData as String] = true @@ -170,7 +184,15 @@ final class VercelMonitor: ObservableObject { guard SecItemCopyMatching(item as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return try? JSONDecoder().decode(Credentials.self, from: data) } - private func deleteCredentials() { SecItemDelete(query as CFDictionary) } + private func deleteCredentials() { + SecItemDelete(query as CFDictionary) + markPaired(false) + } + /// Keeps the menu honest without touching the Keychain: the flag is not a secret and never prompts. + private func markPaired(_ value: Bool) { + paired = value + UserDefaults.standard.set(value, forKey: "vercelPaired") + } } struct DeploymentPage: Decodable { diff --git a/Sources/AgentNotch/UI/AccentLine.swift b/Sources/AgentNotch/UI/AccentLine.swift index 1c8eee1..b20cb24 100644 --- a/Sources/AgentNotch/UI/AccentLine.swift +++ b/Sources/AgentNotch/UI/AccentLine.swift @@ -24,24 +24,3 @@ struct AccentLine: View { } } } - -/// An activity indicator, not a percentage of a deployment. -struct DeploymentProgress: View { - let color: Color - let reduceMotion: Bool - - var body: some View { - TimelineView(.animation(paused: reduceMotion)) { context in - let phase = reduceMotion ? 0.5 : context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 - GeometryReader { geometry in - Capsule().fill(color.opacity(0.2)) - .overlay(alignment: .leading) { - Capsule().fill(color) - .frame(width: geometry.size.width * 0.35) - .offset(x: geometry.size.width * (phase * 1.35 - 0.35)) - } - .clipShape(Capsule()) - } - } - } -} diff --git a/Sources/AgentNotch/UI/NotchRootView.swift b/Sources/AgentNotch/UI/NotchRootView.swift index 47c3a51..5f4b894 100644 --- a/Sources/AgentNotch/UI/NotchRootView.swift +++ b/Sources/AgentNotch/UI/NotchRootView.swift @@ -220,7 +220,7 @@ struct NotchRootView: View { } .id(request.id) } else { - SessionListView(sessions: Array(store.sessions.values), deployments: vercel.deployments, reduceMotion: viewModel.reduceMotion, + SessionListView(sessions: Array(store.sessions.values), reduceMotion: viewModel.reduceMotion, stats: store.todayStats, hovered: $viewModel.hoveredSession, contentHeight: $viewModel.listContentHeight) } diff --git a/Sources/AgentNotch/UI/SessionListView.swift b/Sources/AgentNotch/UI/SessionListView.swift index 1ab8258..53e59bc 100644 --- a/Sources/AgentNotch/UI/SessionListView.swift +++ b/Sources/AgentNotch/UI/SessionListView.swift @@ -3,7 +3,6 @@ import SwiftUI struct SessionListView: View { let sessions: [AgentSession] - let deployments: [Deployment] let reduceMotion: Bool /// Today's totals per agent (nil until the agent finishes a turn). let stats: (AgentKind) -> DayStats? @@ -34,7 +33,7 @@ struct SessionListView: View { if contentHeight > 0 { return min(260, contentHeight) } // shortcut: first-frame estimate until the content is measured. let stepRows = sessions.reduce(0) { $0 + (StepList.rows(for: $1.steps)) } - return min(260, CGFloat(groups.count) * 42 + CGFloat(sessions.count) * 54 + CGFloat(stepRows) * 17 + (deployments.isEmpty ? 0 : 24 + CGFloat(deployments.count) * 17)) + return min(260, CGFloat(groups.count) * 42 + CGFloat(sessions.count) * 54 + CGFloat(stepRows) * 17) } private func mood(for items: [AgentSession]) -> MascotMood { @@ -60,7 +59,7 @@ struct SessionListView: View { } var body: some View { - if sessions.isEmpty && deployments.isEmpty { + if sessions.isEmpty { VStack(spacing: 6) { Text(L("No active agents")) .font(.system(size: 12)) @@ -81,23 +80,6 @@ struct SessionListView: View { VStack(alignment: .leading, spacing: 8) { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 10) { - if !deployments.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("VERCEL").font(.system(size: 10, weight: .semibold)) - ForEach(deployments) { deployment in - HStack(spacing: 5) { - Text(([deployment.project, deployment.status.label] + [deployment.repository, deployment.commit.map { String($0.prefix(7)) }, deployment.author].compactMap { $0 }).joined(separator: " ยท ")) - .font(.system(size: 10)).lineLimit(1) - if deployment.status == .building || deployment.status == .pending { - DeploymentProgress(color: deployment.status == .building ? Color(hex: 0x72DCD0) : Color(hex: 0xF4C377), - reduceMotion: reduceMotion) - .frame(width: 40, height: 2) - .accessibilityHidden(true) - } - } - } - } - } ForEach(groups, id: \.0) { agent, items in VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { diff --git a/Tests/AgentNotchTests/SessionListViewTests.swift b/Tests/AgentNotchTests/SessionListViewTests.swift index a2bb702..6080e59 100644 --- a/Tests/AgentNotchTests/SessionListViewTests.swift +++ b/Tests/AgentNotchTests/SessionListViewTests.swift @@ -11,7 +11,7 @@ struct SessionListViewTests { } private func view(_ sessions: [AgentSession]) -> SessionListView { - SessionListView(sessions: sessions, deployments: [], reduceMotion: true, stats: { _ in nil }, + SessionListView(sessions: sessions, reduceMotion: true, stats: { _ in nil }, hovered: .constant(nil), contentHeight: .constant(0)) } diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 39ab4f9..a27e099 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -7,7 +7,7 @@ Show production deployments from every project available to a Vercel integration 1. Build a Cloudflare Worker for the Vercel integration's authorization-code exchange. Keep the client secret and integration tokens server-side. Bind each installation to a desktop-initiated, short-lived, one-time state and proof-of-possession; reject replay, unsolicited callbacks, and untrusted redirects. Define revocation, expiry, storage encryption, and disconnect before exposing endpoints. 2. Register a separate Vercel integration with deployment read access. Complete the redirect URL after the Worker has an authorized deployment destination. The owner must review and accept the Vercel agreement. 3. Add a local connection flow, secure device credentials, production deployment polling with pagination and rate-limit recovery, and visible authentication/network errors. Track installations separately; never mix deployments from different teams. -4. Present active deployments in the existing compact accent line without increasing the collapsed hit area. Show project, repository, commit, author, and real state on hover; treat missing Git metadata as unavailable. Do not imply a percentage or equate a successful build with production promotion. +4. Present active deployments in the existing compact accent line without increasing the collapsed hit area, with no percentage and no implication that a successful build is a production promotion. Deployment history is deliberately not listed in the expanded notch: listing every production deployment buried the agent sessions. Project, repository, commit and author appear in the notification body. 5. Request macOS notification permission and notify once for new terminal transitions, including failures and cancellations. The tracker starts empty on every run, so the initial snapshot and any deployment that finished while the app was closed stay silent; only transitions observed while it runs are announced, with multiple concurrent deployments handled. ## Verification diff --git a/docs/vercel-notch-ux.md b/docs/vercel-notch-ux.md index 4c76401..46a69dc 100644 --- a/docs/vercel-notch-ux.md +++ b/docs/vercel-notch-ux.md @@ -31,9 +31,11 @@ anything the user is looking at, or take focus away from a browser tab. 4. **No percentage, no invented progress.** Keep the indeterminate indicator and keep `reduceMotion` respected. A successful build is not a production promotion; say what happened, not what it implies. -5. **Details on hover only.** Project, repository, commit and author live in the expanded list, - which is also where the indeterminate indicator sits. The collapsed badge is the mascot plus - a state dot. +5. **No deployment history in the list.** The expanded notch is for agent sessions; listing every + production deployment there buried the sessions under metadata. Deployment detail (project, + repository, commit, author) stays in the notification body, and the collapsed badge is the + mascot plus a state dot. If a hover summary is ever wanted, it is one line for active + deployments only, never the history. ## Rules for notifications diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md index ad2761e..096f4ca 100644 --- a/docs/vercel-security-review.md +++ b/docs/vercel-security-review.md @@ -35,6 +35,7 @@ cleared findings. | F11 | Medium | **Personal-account installations failed.** Vercel answers the token exchange with `team_id: null` when the integration is installed on a personal account, and the Worker demanded a string, so the callback returned `502` after a successful authorization. | Fixed: a null or absent team is stored as empty and `teamId` is omitted from Vercel requests. Covered by a test. | | F12 | Low | The Worker called `https://api.vercel.com/v7/deployments`, which is not the endpoint in Vercel's current reference. | Fixed: uses the documented `/v6/deployments`. | | F13 | High | **The callback threw an unhandled exception on the edge.** `fetch(..., { redirect: 'error' })` is accepted by Node but rejected by the Workers runtime ("must be one of follow or manual"), so the token exchange failed with a Cloudflare `1101` page after a successful authorization. The Node test suite could not catch it. | Fixed: `redirect: 'manual'` plus an explicit 3xx check, a `try/catch` that answers `502` with a reason, and logs that state whether each binding is present. Add `wrangler dev` to the test pass so runtime-only rejections surface before deploy. | +| F14 | Low | **Opening the menu asked for the Mac login password.** `hasCredentials` read the Keychain on every menu update, and the app is a non-activating panel, so macOS showed a Keychain prompt with no focused window to type into until it was dismissed. Ad-hoc builds also change code identity on every rebuild, so the stored item's ACL no longer matches. | Fixed: the menu reads a non-secret `paired` flag instead of the Keychain, polling starts only when the app is active, and disconnect activates the app before touching the Keychain. Reconnecting once from a given build binds the item to that build; a rebuilt app may ask once more, which is inherent to ad-hoc signing. | ## Residual risk before a public release