Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Sources/AgentNotch/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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()
Expand All @@ -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()
Expand Down
34 changes: 28 additions & 6 deletions Sources/AgentNotch/Infrastructure/VercelMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var tracker = DeploymentTracker()
private var simulationTracker = DeploymentTracker()
Expand All @@ -27,14 +29,15 @@ 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 }
slug = (Bundle.main.object(forInfoDictionaryKey: "VercelIntegrationSlug") as? String) ?? "agent-notch"
}

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) {
Expand All @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -163,14 +176,23 @@ 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
var result: CFTypeRef?
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 {
Expand Down
21 changes: 0 additions & 21 deletions Sources/AgentNotch/UI/AccentLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
}
2 changes: 1 addition & 1 deletion Sources/AgentNotch/UI/NotchRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
22 changes: 2 additions & 20 deletions Sources/AgentNotch/UI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Tests/AgentNotchTests/SessionListViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
2 changes: 1 addition & 1 deletion docs/vercel-deployment-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/vercel-notch-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/vercel-security-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading