test: cover exec approval prompt gating

This commit is contained in:
Peter Steinberger
2026-01-22 10:00:55 +00:00
parent e389bd478b
commit 72455b902f
6 changed files with 111 additions and 18 deletions

View File

@@ -475,8 +475,8 @@ enum ExecApprovalsStore {
private static func mergeAgents( private static func mergeAgents(
current: ExecApprovalsAgent, current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
) -> ExecApprovalsAgent { {
var seen = Set<String>() var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = [] var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) { func append(_ entry: ExecAllowlistEntry) {
@@ -486,8 +486,12 @@ enum ExecApprovalsStore {
seen.insert(key) seen.insert(key)
allowlist.append(entry) allowlist.append(entry)
} }
for entry in current.allowlist ?? [] { append(entry) } for entry in current.allowlist ?? [] {
for entry in legacy.allowlist ?? [] { append(entry) } append(entry)
}
for entry in legacy.allowlist ?? [] {
append(entry)
}
return ExecApprovalsAgent( return ExecApprovalsAgent(
security: current.security ?? legacy.security, security: current.security ?? legacy.security,

View File

@@ -63,26 +63,38 @@ final class ExecApprovalsGatewayPrompter {
let mode = AppStateStore.shared.connectionMode let mode = AppStateStore.shared.connectionMode
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let recentlyActive = self.isRecentlyActive(mode: mode, thresholdSeconds: 120) return Self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: Self.lastInputSeconds(),
thresholdSeconds: 120)
}
if let session = requestSession, !session.isEmpty { private static func shouldPresent(
if let active = activeSession, !active.isEmpty { mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int) -> Bool
{
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
if let session = requested, !session.isEmpty {
if let active, !active.isEmpty {
return active == session return active == session
} }
return recentlyActive return recentlyActive
} }
if let active = activeSession, !active.isEmpty { if let active, !active.isEmpty {
return true return true
} }
return mode == .local return mode == .local
} }
private func isRecentlyActive(mode: AppState.ConnectionMode, thresholdSeconds: Int) -> Bool {
guard let seconds = Self.lastInputSeconds() else { return mode == .local }
return seconds <= thresholdSeconds
}
private static func lastInputSeconds() -> Int? { private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
@@ -90,3 +102,22 @@ final class ExecApprovalsGatewayPrompter {
return Int(seconds.rounded()) return Int(seconds.rounded())
} }
} }
#if DEBUG
extension ExecApprovalsGatewayPrompter {
static func _testShouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int = 120) -> Bool
{
self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: lastInputSeconds,
thresholdSeconds: thresholdSeconds)
}
}
#endif

View File

@@ -560,13 +560,13 @@ actor GatewayEndpointStore {
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet":
return tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto": case "auto":
return "127.0.0.1" "127.0.0.1"
case "custom": case "custom":
return customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
return "127.0.0.1" "127.0.0.1"
} }
} }
} }

View File

@@ -192,7 +192,8 @@ extension MenuSessionsInjector {
let headerItem = NSMenuItem() let headerItem = NSMenuItem()
headerItem.tag = self.tag headerItem.tag = self.tag
headerItem.isEnabled = false headerItem.isEnabled = false
let statusText = self.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) let statusText = self
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,

View File

@@ -0,0 +1,56 @@
import Testing
@testable import Clawdbot
@Suite
@MainActor
struct ExecApprovalsGatewayPrompterTests {
@Test func sessionMatchPrefersActiveSession() {
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: " main ",
requestSession: "main",
lastInputSeconds: nil)
#expect(matches)
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: "other",
requestSession: "main",
lastInputSeconds: 0)
#expect(!mismatched)
}
@Test func sessionFallbackUsesRecentActivity() {
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 10,
thresholdSeconds: 120)
#expect(recent)
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: "main",
lastInputSeconds: 200,
thresholdSeconds: 120)
#expect(!stale)
}
@Test func defaultBehaviorMatchesMode() {
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .local,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(local)
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
mode: .remote,
activeSession: nil,
requestSession: nil,
lastInputSeconds: 400)
#expect(!remote)
}
}

View File

@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing