Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
413112f
Add design spec for connector-aware approvals and native notifications
CodeInfinity1 Aug 6, 2026
6180c29
Add implementation plan for connector-aware approvals
CodeInfinity1 Aug 6, 2026
1e1e77b
Use an injectable connected_lookup seam in policy tests
CodeInfinity1 Aug 6, 2026
4c836a6
feat: add read_only and provider_id metadata to Tool
CodeInfinity1 Aug 6, 2026
6a1763b
feat: add session-scoped tool grant store
CodeInfinity1 Aug 6, 2026
3319e26
Fix fail-closed exception handling in grants.py
CodeInfinity1 Aug 6, 2026
4b9c162
feat: make PolicyEngine connector-aware with session grants
CodeInfinity1 Aug 6, 2026
a22fd92
Fix test count typo in Task 3 plan step
CodeInfinity1 Aug 6, 2026
8ae818f
feat: wire connector-aware policy and session grants into the runtime
CodeInfinity1 Aug 6, 2026
cc36d3a
Scope session grants to successful runs and approved arguments
CodeInfinity1 Aug 6, 2026
1270371
Require exact argument match and a live owner for session grants
CodeInfinity1 Aug 6, 2026
cc04e63
Stop the agent daemon when the desktop app quits
CodeInfinity1 Aug 6, 2026
0552aca
feat: add read-only GitHub API tools routed through the connector
CodeInfinity1 Aug 6, 2026
f17400c
Harden GitHub read-tool path guard and trim its OAuth scope
CodeInfinity1 Aug 6, 2026
d92e712
feat: raise native macOS notifications for pending approvals
CodeInfinity1 Aug 6, 2026
6aaa7c4
Fix approval notification delivery, reentrancy, and launch ordering
CodeInfinity1 Aug 6, 2026
d2fae51
Fix Windows self-kill in liveness probe, time-box grants, inform appr…
CodeInfinity1 Aug 6, 2026
f3f384e
Fix Swift 6 isolation crash in requestAuthorization; document Gatekee…
CodeInfinity1 Aug 6, 2026
dbc5e4f
approve notification(mac), migration error fixed, minor bug fixes
CodeInfinity1 Aug 7, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ This project follows a practical release-log style: user-visible capabilities, s

- Renamed Janus to unify agent identity under Stram: Stram is now both the runtime and the agent, not two separately named things. The `stram/janus` package moved to `stram/agent`, `Janus*` classes and identifiers became `Agent*` (e.g. `JanusStore` -> `AgentStore`, `JanusEventRouter` -> `AgentEventRouter`), and REST API fields/routes renamed accordingly (e.g. `janus_memory`/`janus_state` -> `agent_memory`/`agent_state`, `/janus/*` routes -> `/agent/*`). Desktop app nav items and UI labels previously named "Janus" now read "Agent". Any integration relying on the old `janus_*` field names, module paths, or `/janus/*` endpoints must update to the `agent_*` equivalents.

### Fixed

- Existing local agent state now survives the rename. On first start after upgrading, an existing `janus.sqlite3` database (with its WAL sidecars) is carried over to `agent.sqlite3` and the `janus_activations` table is renamed to `agent_activations`, so task contexts, episodes, memory candidates, and activations recorded before the rename are not orphaned behind an empty new database.

## 1.0.0 - First production release

### Highlights
Expand Down
140 changes: 122 additions & 18 deletions apps/macos/Sources/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ final class AppViewModel: ObservableObject {
private var api: AgentAPIClient
private let voiceWakeService = VoiceWakeService()
private var channelListenerTask: Task<Void, Never>?
private var notifiedApprovalTokens: Set<String> = []
private var decidingTokens: Set<String> = []
private var approvalWatchTask: Task<Void, Never>?
private var channelListenerTickRunning = false
private var lastHandledVoiceTranscript = ""
private var isCapturingVoiceTask = false
Expand Down Expand Up @@ -219,6 +222,114 @@ final class AppViewModel: ObservableObject {
}
}

/// Nothing else polls approvals: `refreshApprovals()` only runs on bootstrap, window
/// activation, or a manual refresh, so without this watch a new permission request
/// can sit unseen and the notification would have nothing to fire on.
func startApprovalWatch() {
guard approvalWatchTask == nil else { return }
ApprovalNotifier.shared.configure(
approve: { [weak self] token in
Task { await self?.approve(token: token) }
},
reject: { [weak self] token in
Task { await self?.reject(token: token) }
},
authorizationDenied: { [weak self] in
self?.notice = "Notifications are off, so Stram cannot alert you when a run needs permission. Turn them on in System Settings > Notifications, or watch the Permissions page instead."
}
)
ApprovalNotifier.shared.requestAuthorization()

approvalWatchTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
await self.pollApprovalsForNotification()
// ponytail: fast only while something can produce or clear an approval,
// otherwise back off. A flat 2s poll would hammer the API and the battery
// all day. Stopping outright is wrong — an autonomous tick can park on an
// approval with no UI event to restart the watch.
let interval = self.approvalPollInterval
try? await Task.sleep(for: interval)
}
}
}

func stopApprovalWatch() {
approvalWatchTask?.cancel()
approvalWatchTask = nil
}

private var approvalPollInterval: Duration {
(isSending || !approvals.isEmpty) ? .seconds(2) : .seconds(20)
}

private func pollApprovalsForNotification() async {
let pending: [ApprovalItem]
do {
// Deliberately not `refreshApprovals()`: that swallows errors into an empty
// list, and treating a failed poll as "nothing pending" withdraws live banners
// while the run is still parked (daemon restart, wake from sleep), then
// re-alerts on recovery.
pending = try await api.approvals()
} catch {
return
}
approvals = pending
if selectedApproval == nil {
selectedApproval = pending.first
}
let pendingTokens = Set(pending.map(\.approvalToken))
// Anything decided elsewhere, or whose run was cancelled, is no longer pending:
// withdraw its banner and forget it so a later token reusing the set is unaffected.
for token in notifiedApprovalTokens.subtracting(pendingTokens) {
ApprovalNotifier.shared.withdraw(token: token)
}
notifiedApprovalTokens.formIntersection(pendingTokens)
for approval in pending where !notifiedApprovalTokens.contains(approval.approvalToken)
&& !decidingTokens.contains(approval.approvalToken) {
// Only record it as notified if the notification actually went out: before the
// system grant lands `notify` is a no-op, and a token marked then would never
// be alerted again while it stays pending.
if ApprovalNotifier.shared.notify(approval) {
notifiedApprovalTokens.insert(approval.approvalToken)
}
}
}

func approve(token: String, label: String? = nil) async {
await decideApproval(token: token, label: label, approve: true)
}

func reject(token: String, label: String? = nil) async {
await decideApproval(token: token, label: label, approve: false)
}

private func decideApproval(token: String, label: String?, approve: Bool) async {
let verb = approve ? "Approved" : "Rejected"
let name = label ?? String(token.prefix(8))
// A poll tick that overlaps this decision sees a list fetched before the server
// committed it; without this the token looks pending-and-unnotified and gets a
// fresh banner for a request the user just decided.
decidingTokens.insert(token)
defer { decidingTokens.remove(token) }
do {
if approve {
_ = try await api.approve(token, note: "Approved from Stram Mac.")
} else {
_ = try await api.reject(token, note: "Rejected from Stram Mac.")
}
notice = "\(verb) \(name)."
} catch {
// Already decided on the Permissions page, or the run was cancelled. Say so
// once and forget the token — retrying would just fail again.
notice = "Could not \(approve ? "approve" : "reject") \(name): \(error.localizedDescription)"
}
ApprovalNotifier.shared.withdraw(token: token)
notifiedApprovalTokens.remove(token)
await refreshRuns()
await refreshApprovals()
}

func refreshChannels() async {
do {
channels = try await api.channels()
Expand Down Expand Up @@ -638,10 +749,17 @@ final class AppViewModel: ObservableObject {
return copy
}

/// Stops every child process this app owns. Called on quit as well as manual stop:
/// an orphaned daemon keeps its session alive, and with it the grants that let
/// read-only capture tools run without prompting.
func stopChildProcesses() {
nativeCollectorProcess.stop()
agentProcess.stop()
}

func toggleAgentProcess() async {
if agentProcess.isRunning {
nativeCollectorProcess.stop()
agentProcess.stop()
stopChildProcesses()
status = .offline
return
}
Expand All @@ -668,26 +786,12 @@ final class AppViewModel: ObservableObject {

func approveSelected() async {
guard let selectedApproval else { return }
do {
_ = try await api.approve(selectedApproval.approvalToken, note: "Approved from Stram Mac.")
notice = "Approved \(selectedApproval.toolName)."
await refreshRuns()
await refreshApprovals()
} catch {
notice = error.localizedDescription
}
await approve(token: selectedApproval.approvalToken, label: selectedApproval.toolName)
}

func rejectSelected() async {
guard let selectedApproval else { return }
do {
_ = try await api.reject(selectedApproval.approvalToken, note: "Rejected from Stram Mac.")
notice = "Rejected \(selectedApproval.toolName)."
await refreshRuns()
await refreshApprovals()
} catch {
notice = error.localizedDescription
}
await reject(token: selectedApproval.approvalToken, label: selectedApproval.toolName)
}

func cancelSelectedRun() async {
Expand Down
183 changes: 183 additions & 0 deletions apps/macos/Sources/ApprovalNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import Foundation
import UserNotifications

// File-private so the `nonisolated` delegate callbacks can read them without
// hopping to the main actor.
private let approvalCategoryIdentifier = "APPROVAL"
private let approveActionIdentifier = "APPROVAL_APPROVE"
private let rejectActionIdentifier = "APPROVAL_REJECT"
private let approvalTokenKey = "approval_token"
/// Persisted so a user who denied notifications is told once, not every launch.
private let deniedNoticeShownKey = "StramMac.notificationsDeniedNoticeShown"

/// Raises a native notification with Approve / Reject buttons when a run parks on a
/// permission request, so the user does not have to notice a spinner in chat and then
/// navigate to the Permissions page to unblock the run.
@MainActor
final class ApprovalNotifier: NSObject, UNUserNotificationCenterDelegate {
static let shared = ApprovalNotifier()

private var approveHandler: ((String) -> Void)?
private var rejectHandler: ((String) -> Void)?
private var authorizationDeniedHandler: (() -> Void)?
private var authorized = false
/// A response that launched the app arrives before the view model can wire up the
/// handlers, so hold it here and run it as soon as `configure` supplies them.
private var pendingResponse: (token: String, approve: Bool)?

func configure(
approve: @escaping (String) -> Void,
reject: @escaping (String) -> Void,
authorizationDenied: @escaping () -> Void
) {
approveHandler = approve
rejectHandler = reject
authorizationDeniedHandler = authorizationDenied
if let pendingResponse {
self.pendingResponse = nil
(pendingResponse.approve ? approve : reject)(pendingResponse.token)
}
}

/// `UNUserNotificationCenter.current()` traps when the process has no bundle
/// identifier, which is the case under `swift run`. Stay inert there instead of
/// crashing the dev flow; the built bundle has an identifier.
private var isAvailable: Bool {
Bundle.main.bundleIdentifier != nil
}

/// Must run before app launch finishes: macOS delivers a response that launched the
/// process immediately, and drops it if the delegate is still nil. Registering the
/// category here too keeps the Approve / Reject buttons on notifications that were
/// delivered by a previous launch.
func registerDelegate() {
guard isAvailable else { return }
let center = UNUserNotificationCenter.current()
center.delegate = self

let approve = UNNotificationAction(
identifier: approveActionIdentifier,
title: "Approve",
options: [.authenticationRequired]
)
let reject = UNNotificationAction(
identifier: rejectActionIdentifier,
title: "Reject",
options: [.destructive]
)
center.setNotificationCategories([
UNNotificationCategory(
identifier: approvalCategoryIdentifier,
actions: [approve, reject],
intentIdentifiers: [],
options: []
)
])
}

func requestAuthorization() {
guard isAvailable else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { @Sendable granted, _ in
Task { @MainActor in
ApprovalNotifier.shared.authorized = granted
let defaults = UserDefaults.standard
if granted {
// Told again if permission is revoked later, but not on every launch.
defaults.removeObject(forKey: deniedNoticeShownKey)
} else if !defaults.bool(forKey: deniedNoticeShownKey) {
defaults.set(true, forKey: deniedNoticeShownKey)
// Read from main-actor state rather than captured: the handler is not Sendable.
ApprovalNotifier.shared.authorizationDeniedHandler?()
}
}
}
}

/// Returns whether authorization and the bundle-id check passed, i.e. the request was
/// handed to the system. Delivery itself is asynchronous and not reported here.
func notify(_ approval: ApprovalItem) -> Bool {
guard isAvailable, authorized else { return false }

let content = UNMutableNotificationContent()
content.title = "Stram needs permission"
// displayRisk already reads as "High attention" / "Medium attention".
content.subtitle = "\(approval.displayToolName) - \(approval.displayRisk)"
// The banner's Approve button decides immediately, so it must show what is being
// approved: `reason` is a per-risk-class constant and says nothing about the action.
content.body = Self.body(for: approval)
content.categoryIdentifier = approvalCategoryIdentifier
content.userInfo = [approvalTokenKey: approval.approvalToken]
content.sound = .default

let request = UNNotificationRequest(
identifier: approval.approvalToken,
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
return true
}

/// Request text plus a truncated one-line rendering of the arguments. A notification
/// body is short, so cap it rather than let the system silently clip mid-argument.
private static func body(for approval: ApprovalItem) -> String {
let head = approval.request.isEmpty ? approval.reason : approval.request
guard let input = approval.toolInput, input != .object([:]) else { return head }
var details = input.description
.split(whereSeparator: \.isNewline)
.joined(separator: ", ")
if details.count > 200 {
details = details.prefix(200) + "…"
}
return details.isEmpty ? head : "\(head)\n\(details)"
}

func withdraw(token: String) {
guard isAvailable else { return }
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [token])
center.removePendingNotificationRequests(withIdentifiers: [token])
}

private func handle(token: String, approve: Bool) {
guard let handler = approve ? approveHandler : rejectHandler else {
// This response launched the app: the delegate is registered at launch but the
// view model configures the handlers a moment later. Queue instead of dropping.
pendingResponse = (token, approve)
return
}
handler(token)
}

nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let token = response.notification.request.content.userInfo[approvalTokenKey] as? String
let actionIdentifier = response.actionIdentifier
if let token {
Task { @MainActor in
switch actionIdentifier {
case approveActionIdentifier:
ApprovalNotifier.shared.handle(token: token, approve: true)
case rejectActionIdentifier:
ApprovalNotifier.shared.handle(token: token, approve: false)
default:
break
}
}
}
// Called synchronously: the escaping handler is not Sendable, so it cannot be
// carried into the main-actor hop above.
completionHandler()
}

nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
}
15 changes: 15 additions & 0 deletions apps/macos/Sources/LocalAgentProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,24 @@ final class LocalAgentProcess: ObservableObject {
appendLog("Started local Stram daemon on port \(settings.port).")
}

/// Stops the daemon and waits for it to actually exit.
///
/// The daemon owns the session grants for privacy-sensitive tools and clears them on
/// SIGTERM, so it must be gone — not merely signalled — before we return. Callers
/// include app termination, where nothing runs after us.
func stop() {
guard let process else { return }
process.terminate()
// ponytail: 3s poll instead of waitUntilExit() so a daemon that ignores SIGTERM
// cannot hang quit; SIGKILL is the backstop.
let deadline = Date.now.addingTimeInterval(3)
while process.isRunning, Date.now < deadline {
usleep(50_000)
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
process.waitUntilExit()
}
self.process = nil
isRunning = false
appendLog("Stop requested.")
Expand Down
Loading