diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/RemoteMachinesSidebar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/RemoteMachinesSidebar.swift index c98a27680bcd..f4b844e81172 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/RemoteMachinesSidebar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/RemoteMachinesSidebar.swift @@ -22,7 +22,6 @@ public import Observation /// per `refreshInterval`, from the lanes' states and titles only (a new step changes nothing). @Observable @MainActor public final class RemoteMachinesSidebar { - public static let shared = RemoteMachinesSidebar() /// Rows shown per page of an unfolded group; Show More adds one. public static let pageSize = 10 /// The longest a lane change waits before the groups show it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxMacApprover.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxMacApprover.swift index 8ed5fcad5160..cac6d2de4627 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxMacApprover.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxMacApprover.swift @@ -19,11 +19,9 @@ import CmuxNextCompat /// - a DEV build without a Team ID (``configure(keychainService:)`` with nil): a software key in /// this process's memory only, new each launch (enroll again after a relaunch; the known DEV gap /// of the person key, identity.md). -/// Before ``configure(keychainService:)``, ``shared`` has no key. +/// Before ``configure(keychainService:)``, there is no key. The app makes one per process (in its +/// ``AgentPanePeerApprover``): the transport signs with it, the host gives its public half to the daemon. public nonisolated final class AcpmuxMacApprover: Sendable { - // lint:allow one key per app process: the transport signs with it, the host gives its public half to the daemon - public static let shared = AcpmuxMacApprover(mode: .unconfigured) - /// The domain of the signed message (acpmux `mac_approvers::DOMAIN`). static let domain = "acpmux-mac-approver-decision-v1" /// How long a signed Allow counts: well under acpmux's `MAX_LIFETIME` (300 s). @@ -75,9 +73,15 @@ public nonisolated final class AcpmuxMacApprover: Sendable { private let state: Mutex private static let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "agent-pane.acpmux") - /// A memory key (tests, and a DEV build's ``shared`` once configured). + /// A memory key (tests). public convenience init() { self.init(mode: .memory) } + /// The app's key, where ``configure(keychainService:)`` says it lives. + public convenience init(keychainService: String?) { + self.init(mode: .unconfigured) + configure(keychainService: keychainService) + } + init(mode: Mode) { state = Mutex(State(mode: mode)) } /// Where the key lives: the Keychain service of a signed build (the Secure Enclave), or nil @@ -151,28 +155,6 @@ public nonisolated final class AcpmuxMacApprover: Sendable { return out } - /// Gives this Mac's acpmux the public key, so `acpmux host enroll` there knows which key to - /// enroll on a peer (`_acpmux/mac_approver_set`, the person only), each time the host connects - /// to that daemon (a restarted daemon holds none: `thisMac` is in its memory only). Off the - /// pane's connect path. - static func register(_ environment: AcpmuxEnvironment) { - // task-owner: one short person connection (5 s deadline at most) per daemon connect - Task { @MainActor in await registerNow(environment) } - } - - @MainActor - private static func registerNow(_ environment: AcpmuxEnvironment) async { - AgentPanePeerApprovals.daemons[environment.socketPath] = ["ACPMUX_HOME": environment.home.path, - "ACPMUX_SOCKET": environment.socketPath] - guard let identity = await shared.loadedIdentity() else { return } - guard let line = await AcpmuxPersonLine.open(environment) else { return } - defer { line.close() } - guard line.isPerson else { return } - let name = await MacName().displayName() - // nil: a daemon too old for peer-route approvals, nothing to give it. - _ = await line.call("_acpmux/mac_approver_set", ["id": identity.id, "publicKey": identity.publicKey, "name": name]) - } - private static func hex(_ data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } /// The Secure Enclave key under `service`, made and stored on first use; nil without an diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudMachineRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudMachineRequest.swift index 9a8310f0ba7e..4e775c1e5dd7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudMachineRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudMachineRequest.swift @@ -77,12 +77,11 @@ public nonisolated enum AgentPaneCloudMachineRequest: Equatable, Sendable { } } -/// The App's side of `cloudMachine.*`: App-wide, like the account it reads. CmuxNextApp sets the -/// closures on ``shared`` at launch; a test uses its own instance. Unset, every method is refused. +/// The App's side of `cloudMachine.*`: App-wide, like the account it reads. CmuxNextApp makes one, +/// sets its closures and hands it to every pane (``AgentPaneModel/cloudMachines``); a test uses its +/// own instance. Unset, every method is refused. @MainActor public final class AgentPaneCloudMachines { - public static let shared = AgentPaneCloudMachines() - /// One Cloud machine as the picker lists it. public struct Machine: Equatable, Sendable { public var id: String @@ -168,29 +167,29 @@ public final class AgentPaneCloudMachines { /// Runs Pause Machine on a running machine of the loaded list; false otherwise. public var pause: (@MainActor (String) -> Bool)? - init() {} + public init() {} } extension AgentPaneCloudMachineRequest { /// The page's reply. Open, Resume and Pause consume one gesture, and only once the App can run them. @MainActor - func reply(_ machines: AgentPaneCloudMachines, gestures: AgentPaneUserGestures) async -> [String: Any] { + func reply(_ machines: AgentPaneCloudMachines?, gestures: AgentPaneUserGestures) async -> [String: Any] { switch self { case .list, .recoveryList: - guard let list = (self == .recoveryList ? machines.recoveryList : machines.list) else { return AgentPaneModel.unsupported(method) } + guard let list = (self == .recoveryList ? machines?.recoveryList : machines?.list) else { return AgentPaneModel.unsupported(method) } let inventory = await list() let rows: [[String: Any]] = inventory.machines.prefix(Self.maximumListed).map { ["id": $0.id, "name": $0.name, "stage": $0.stage] } var value: [String: Any] = ["signedIn": inventory.signedIn, "loaded": inventory.loaded, "machines": rows, "complete": inventory.complete && inventory.machines.count <= Self.maximumListed] if let account = inventory.account { value["account"] = account.map { $0 as Any? ?? NSNull() } } return AgentPaneReply.success(value) - case .open(let id): return run(machines.open, on: id, gestures: gestures) - case .resume(let id): return run(machines.resume, on: id, gestures: gestures) + case .open(let id): return run(machines?.open, on: id, gestures: gestures) + case .resume(let id): return run(machines?.resume, on: id, gestures: gestures) case .attach(let id): - guard let attach = machines.attach else { return AgentPaneModel.unsupported(method) } + guard let attach = machines?.attach else { return AgentPaneModel.unsupported(method) } guard gestures.consume() else { return AgentPaneModel.transportFailure(.gestureRequired) } guard await attach(id) else { return Self.notRun(method) } return AgentPaneReply.success() - case .pause(let id): return run(machines.pause, on: id, gestures: gestures) + case .pause(let id): return run(machines?.pause, on: id, gestures: gestures) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHost.swift index 15d1bfb1967d..bf8ab806c255 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHost.swift @@ -162,15 +162,18 @@ public actor AcpmuxHost: AgentPaneHostProviding { private let daemonStop = AcpmuxDaemonStop() /// Sends the Computer Use endpoint to the daemon (`AcpmuxComputerUse.enroll`; a recorder in tests). private let enrollComputerUse: @Sendable (AcpmuxEnvironment) async -> Void + /// Gets this Mac's approver key to each daemon this host connects (``AgentPanePeerApprover``); nil: none. + private let peerApprover: AgentPanePeerApprover? /// - Parameters: /// - healReplaced: called once per daemon pid when a daemon this /// host does not start (`startsDaemons: false`) runs from a replaced bundle. /// - startOwner: called when nothing listens on the socket of a daemon this host does not /// start: asks its owner to start it; true when a start is on its way. + /// - peerApprover: the app's approver key, given to each daemon this host connects. public init(environment: AcpmuxEnvironment?, startsDaemons: Bool = true, healReplaced: (@Sendable () async -> Bool)? = nil, - startOwner: (@Sendable () async -> Bool)? = nil) { + startOwner: (@Sendable () async -> Bool)? = nil, peerApprover: AgentPanePeerApprover? = nil) { self.resolveEnvironment = { environment } self.computerUse = { [:] } self.offersSessionTools = { environment?.offersSessionTools ?? false } @@ -181,6 +184,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { self.startsDaemons = startsDaemons self.healReplaced = healReplaced self.startOwner = startOwner + self.peerApprover = peerApprover } /// Looks for acpmux (bundled, PATH, install directories) on the actor, @@ -192,10 +196,10 @@ public actor AcpmuxHost: AgentPaneHostProviding { agentPairCap: @escaping @Sendable () -> (messages: Int, windowSeconds: Int) = { (Int(AgentPaneTunables.agentPairCap.value), Int(AgentPaneTunables.agentPairWindow.value)) }, - daemonReady: (@Sendable (String) async -> Void)? = nil) { + daemonReady: (@Sendable (String) async -> Void)? = nil, peerApprover: AgentPanePeerApprover? = nil) { self.init(resolve: resolve, computerUse: computerUse, offersSessionTools: offersSessionTools, agentPairGuard: agentPairGuard, agentPairCap: agentPairCap, daemonReady: daemonReady, - enrollComputerUse: { await AcpmuxComputerUse.enroll($0) }) + enrollComputerUse: { await AcpmuxComputerUse.enroll($0) }, peerApprover: peerApprover) } /// `enrollComputerUse` sends the Computer Use endpoint (a recorder in tests). @@ -207,7 +211,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { (Int(AgentPaneTunables.agentPairCap.value), Int(AgentPaneTunables.agentPairWindow.value)) }, daemonReady: (@Sendable (String) async -> Void)? = nil, - enrollComputerUse: @escaping @Sendable (AcpmuxEnvironment) async -> Void) { + enrollComputerUse: @escaping @Sendable (AcpmuxEnvironment) async -> Void, peerApprover: AgentPanePeerApprover? = nil) { self.resolveEnvironment = resolve self.computerUse = computerUse self.offersSessionTools = offersSessionTools @@ -218,6 +222,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { self.startsDaemons = true self.healReplaced = nil self.startOwner = nil + self.peerApprover = peerApprover } /// The Computer Use endpoint changed (`ComputerUseHelperDaemon.onEndpointChange`: a replaced @@ -320,7 +325,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { // An attached daemon (`CMUX_NEXT_ACPMUX_ATTACH`) is another app's: only look, as the Chief's tabs do. let owned = startsDaemons && !environment.isAttached let startsDaemon = asked && owned - // A lookup waits for a slow daemon's answer (``findOrStart(_:startsDaemon:owned:heal:startOwner:)``): + // A lookup waits for a slow daemon's answer (``findOrStart(_:startsDaemon:owned:heal:startOwner:approver:)``): // every caller shares it, and each one may stop its own wait (its cancel) while the // lookup goes on for the others. if let task = inFlight[startsDaemon] { return try await AcpmuxSharedWait.value(of: task) } @@ -338,13 +343,13 @@ public actor AcpmuxHost: AgentPaneHostProviding { if let healReplaced { heal = { @Sendable (pid: Int32) async -> Bool in await self.healOnce(pid: pid, healReplaced) } } - let startOwner = startOwner + let (startOwner, approver) = (startOwner, peerApprover) let daemonReady = daemonReady // The lookup itself tells `daemonReady`, so a caller that stops waiting (its cancel) never // skips it for the others. let task = Task { let endpoint = try await Self.findOrStart(environment, startsDaemon: startsDaemon, owned: owned, heal: heal, - startOwner: startOwner) + startOwner: startOwner, approver: approver) await daemonReady(daemonSocket) return endpoint } @@ -490,9 +495,11 @@ public actor AcpmuxHost: AgentPaneHostProviding { /// then made every chat reload and the load worse. So the status calls here wait for the /// answer, and a daemon that exits closes the connection, which ends the wait; the pane shows /// that it is connecting meanwhile, and every pane that asks shares this one lookup. + /// - Parameter approver: gets this Mac's approver key to the daemon found or started; nil: none. private static func findOrStart(_ environment: AcpmuxEnvironment, startsDaemon: Bool, owned: Bool, heal: (@Sendable (Int32) async -> Bool)?, - startOwner: (@Sendable () async -> Bool)? = nil) async throws -> AcpmuxWebEndpoint { + startOwner: (@Sendable () async -> Bool)? = nil, + approver: AgentPanePeerApprover? = nil) async throws -> AcpmuxWebEndpoint { // cmux NEXT adopts its old acpmux home before it asks or starts a daemon (cx-is1kf.9). await AcpmuxEnvironment.adoptBuildHomes() // This lookup took a stray daemon over or handed a stale one off: Settings' daemon line @@ -514,7 +521,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { if !environment.isAttached { _ = await AcpmuxPersonKey.enroll(status, environment: environment, mayHandOff: false) await AcpmuxComputerUse.enroll(environment) - AcpmuxMacApprover.register(environment) + approver?.register(environment) } do { return try await Self.verifiedEndpoint(environment) @@ -541,7 +548,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { let stopped = stale ? true : await AcpmuxPersonKey.enroll(status, environment: environment) guard stopped else { await AcpmuxComputerUse.enroll(environment) - AcpmuxMacApprover.register(environment) + approver?.register(environment) return try await Self.verifiedEndpoint(environment) } } catch AcpmuxStatusClient.Failure.unreachable { @@ -582,7 +589,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { _ = await AcpmuxPersonKey.enroll(status, environment: environment, mayHandOff: Self.mayHandOff(after: launched)) await AcpmuxComputerUse.enroll(environment) - AcpmuxMacApprover.register(environment) + approver?.register(environment) } return launched.endpoint } catch AcpmuxDaemonLauncher.Failure.exited { @@ -600,7 +607,7 @@ public actor AcpmuxHost: AgentPaneHostProviding { guard status.serves(home: environment.home) else { throw Self.foreignHome(status, environment) } _ = await AcpmuxPersonKey.enroll(status, environment: environment, mayHandOff: false) await AcpmuxComputerUse.enroll(environment) - AcpmuxMacApprover.register(environment) + approver?.register(environment) return try await Self.verifiedEndpoint(environment) } catch let error as AgentPaneHostError { throw error diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 3b971e09e4de..e913bf3f1120 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -275,6 +275,8 @@ public final class AgentPaneModel { /// The app's one Read aloud voice (``AgentPaneReadAloud``); nil refuses the page's speaker. @ObservationIgnored public var readAloud: AgentPaneReadAloud? + /// The app's Cloud machines for `cloudMachine.*` (``AgentPaneCloudMachines``); nil refuses every method. + @ObservationIgnored public var cloudMachines: AgentPaneCloudMachines? /// Asks the user to confirm a config option that is not free (the view's native sheet). @ObservationIgnored public var onConfirmMode: (@MainActor (_ asked: AgentPaneModeConfirmation, _ answer: @escaping @MainActor (Bool) -> Void) -> Void)? @@ -453,7 +455,7 @@ public final class AgentPaneModel { } case .sandboxState(let wait): return await sandboxRuns.state(wait: wait) - case .cloudMachine(let request): return await request.reply(.shared, gestures: transport.gestures) + case .cloudMachine(let request): return await request.reply(cloudMachines, gestures: transport.gestures) case .cloudCredentialTransfer(let request): guard remoteChat, newTab == nil, sessionId == request.sessionID else { return AgentPaneReply.failure(code: "unavailable", message: "Credential setup is unavailable for this chat.") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePeerApprovals.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePeerApprovals.swift index 0b568c729323..df7bf51c16f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePeerApprovals.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePeerApprovals.swift @@ -1,3 +1,4 @@ +import CmuxNextCompat import Foundation /// Why a peer of this Mac's agent daemon (a chat opened with `--host`) did not count an allow @@ -7,22 +8,54 @@ public nonisolated enum AgentPanePeerApprovals: Sendable, Equatable { case notEnrolled(peer: String) /// The allow was not a one-time allow: only those count from this Mac. case onceOnly +} +/// This app's side of peer-route approvals (``AcpmuxMacApprover``): this Mac's approver key, the +/// local daemons its hosts gave that key to, and what a pane's Enroll this Mac runs. One per app: +/// the app's composition root makes it and hands it to its hosts and panes; a test makes its own. +@MainActor +public final class AgentPanePeerApprover { + /// The key a one-time Allow on a peer's prompt is signed with. + public nonisolated let key: AcpmuxMacApprover /// Enrolls this Mac's approver key on `peer` (`acpmux host approvals enroll PEER`, over the /// person's own ssh to that machine), run against the daemon `daemon` names (its /// `ACPMUX_HOME` and `ACPMUX_SOCKET`: the one this pane talks to, which knows `peer` and holds - /// this Mac's key): nil once enrolled, else what failed, in that command's words. The app sets - /// it at launch; nil offers no Enroll button. - @MainActor public static var enroll: ((_ peer: String, _ daemon: [String: String]) async -> String?)? + /// this Mac's key): nil once enrolled, else what failed, in that command's words. nil offers + /// no Enroll button. + public let enroll: ((_ peer: String, _ daemon: [String: String]) async -> String?)? + /// The `ACPMUX_HOME` and `ACPMUX_SOCKET` of each local daemon this app gave its key to, by + /// socket path: what a pane on that socket enrolls through. + var daemons: [String: [String: String]] = [:] - /// The `ACPMUX_HOME` and `ACPMUX_SOCKET` of each local daemon this app gave its key to - /// (``AcpmuxMacApprover``), by socket path: what a pane on that socket enrolls through. - @MainActor static var daemons: [String: [String: String]] = [:] + public init(key: AcpmuxMacApprover, enroll: ((_ peer: String, _ daemon: [String: String]) async -> String?)? = nil) { + self.key = key + self.enroll = enroll + } /// The daemon a pane on `socket` enrolls through: the one this app registered there, else /// that socket alone. - @MainActor static func daemon(socket: String?) -> [String: String] { + func daemon(socket: String?) -> [String: String] { guard let socket else { return [:] } return daemons[socket] ?? ["ACPMUX_SOCKET": socket] } + + /// Gives this Mac's acpmux the public key, so `acpmux host enroll` there knows which key to + /// enroll on a peer (`_acpmux/mac_approver_set`, the person only), each time the host connects + /// to that daemon (a restarted daemon holds none: `thisMac` is in its memory only). Off the + /// pane's connect path. + nonisolated func register(_ environment: AcpmuxEnvironment) { + // task-owner: one short person connection (5 s deadline at most) per daemon connect + Task { @MainActor in await self.registerNow(environment) } + } + + private func registerNow(_ environment: AcpmuxEnvironment) async { + daemons[environment.socketPath] = ["ACPMUX_HOME": environment.home.path, "ACPMUX_SOCKET": environment.socketPath] + guard let identity = await key.loadedIdentity() else { return } + guard let line = await AcpmuxPersonLine.open(environment) else { return } + defer { line.close() } + guard line.isPerson else { return } + let name = await MacName().displayName() + // nil: a daemon too old for peer-route approvals, nothing to give it. + _ = await line.call("_acpmux/mac_approver_set", ["id": identity.id, "publicKey": identity.publicKey, "name": name]) + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport+PeerAllow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport+PeerAllow.swift index f7e7338d54be..2985e10a95d9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport+PeerAllow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport+PeerAllow.swift @@ -29,7 +29,7 @@ extension AgentPaneTransport { } answers = digest } - guard let decision = await AcpmuxMacApprover.shared.decision( + guard let decision = await peerApprover?.key.decision( instance: context.instance, session: session, permission: permission, toolCall: context.toolCall, option: option, nonce: context.nonce, answers: answers) else { return } guard decisions.context(session: session, permission: permission) == context else { return } @@ -60,7 +60,7 @@ extension AgentPaneTransport { } var signed: [String: [String: any Sendable]] = [:] for (permission, option, context) in members { - guard let proof = await AcpmuxMacApprover.shared.decision( + guard let proof = await peerApprover?.key.decision( instance: context.instance, session: session, permission: permission, toolCall: context.toolCall, option: option, nonce: context.nonce, answers: "") else { return } signed[permission] = ["decisionNonce": context.nonce, "approverDecision": proof] diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift index 5cdc10798166..083f538378c4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift @@ -109,6 +109,9 @@ import CmuxNextCompat /// (peer-route approvals, ``AcpmuxMacApprover``): it has not enrolled this Mac, or the allow /// was not a one-time allow. nil: a later answer counted, and what was said no longer holds. public var onPeerApprovalsOff: (@MainActor (AgentPanePeerApprovals?) -> Void)? + /// This app's approver key and Enroll this Mac (``AgentPanePeerApprover``), from the app's pane + /// factory; nil: a peer's prompt gets no signed allow and the notice offers no Enroll. + public var peerApprover: AgentPanePeerApprover? /// The pane's own roots (``AcpmuxPathPolicy/Scope/roots``), asked at each frame. public var roots: @MainActor () -> [String] = { [] } /// Folders that are roots only when the user picks one by a gesture (the new tab page's scan). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+RemoteApprovals.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+RemoteApprovals.swift index 0ff9399212bf..932ccebac0a2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+RemoteApprovals.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+RemoteApprovals.swift @@ -31,7 +31,7 @@ extension AgentPaneView { peerApprovalsReason = reason switch reason { case .notEnrolled(let peer): - let enroll = AgentPanePeerApprovals.enroll == nil ? nil : (Self.peerApprovalsEnroll, #selector(enrollThisMac(_:))) + let enroll = model.transport.peerApprover?.enroll == nil ? nil : (Self.peerApprovalsEnroll, #selector(enrollThisMac(_:))) showApprovalsNotice(title: Self.peerApprovalsOffTitle(peer), detail: Self.peerApprovalsOffDetail, button: enroll) case .onceOnly: showApprovalsNotice(title: Self.peerApprovalsOnceOnly, detail: nil, button: nil) @@ -99,9 +99,9 @@ extension AgentPaneView { @objc private func enrollThisMac(_ sender: NSButton) { // The daemon this pane talks to now, which named the peer: a reconnect while the command // runs does not change where it enrolls, and a pane with no daemon enrolls nowhere. - guard case .notEnrolled(let peer) = peerApprovalsReason, let enroll = AgentPanePeerApprovals.enroll, + guard case .notEnrolled(let peer) = peerApprovalsReason, let approver = model.transport.peerApprover, let enroll = approver.enroll, let notice = remoteApprovalsNotice, let socket = model.transport.acpmuxSocket else { return } - let daemon = AgentPanePeerApprovals.daemon(socket: socket) + let daemon = approver.daemon(socket: socket) sender.isEnabled = false // task-owner: one enroll per click; the notice it updates belongs to this pane Task { @MainActor [weak self, weak notice, weak sender] in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentRecents/ChatsFeed+Start.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentRecents/ChatsFeed+Start.swift index 3ff6c1623d9e..6130f4b2b5c5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentRecents/ChatsFeed+Start.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentRecents/ChatsFeed+Start.swift @@ -18,7 +18,6 @@ extension ChatsFeed { AgentTurnStateFeed.purged = { AgentChatUploads.removeInBackground(session: $0) } // An acpmux peer that connects later in the launch gets the app's build too. AgentTurnStateFeed.peerSessionChanged = { _ = AcpmuxPeerReconnects.shared.note(peer: $0, stale: $1) } - turns.peerStateChanged = { RemoteMachinesSidebar.shared.peerStateChanged(peer: $0, state: $1) } // its peers' link pushes AgentChatUploads.sweepInBackground() let localHost = (try? services.cloud.localDeviceID()).map(AgentSessionRef.host(installID:)) turns.start(localHost: localHost) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+ChiefHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+ChiefHost.swift index 70ee092b58ea..6b96cc96d339 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+ChiefHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+ChiefHost.swift @@ -76,10 +76,10 @@ struct ChiefHomeAcpmux { /// whether one runs or is starting. A restored Chief chat connects after a relaunch /// without Home being shown (#25570). /// - ownHost: the pane host of the Chief home's own acpmux (its environment) for a Chief - /// on the app's acpmux: the daemon its subagents ran on before the move. + /// on the app's acpmux: the daemon its subagents ran on before the move. `peerApprover`: the app's approver key. init(home: ChiefHome, local: any AgentPaneHostProviding, ensureHost: @escaping @Sendable () async -> Void, startHost: @escaping @Sendable () async -> Bool, - ownHost: @escaping @Sendable (AcpmuxEnvironment?) -> any AgentPaneHostProviding = { AcpmuxHost(environment: $0) }) { + peerApprover: AgentPanePeerApprover? = nil, ownHost: (@Sendable (AcpmuxEnvironment?) -> any AgentPaneHostProviding)? = nil) { let host = Self.host(muxHome: home.muxHome) self.host = host // optchat-chief `brain::parent_tag`: optchat-chief:. @@ -91,7 +91,7 @@ struct ChiefHomeAcpmux { // starting that daemon again when it ended, never to the app's daemon, which lacks the // chat and would say "This chat isn't available" (Lawrence 2026-10-21). if home.usesAppAcpmux { - let own = ownHost(environment) + let own: any AgentPaneHostProviding = ownHost?(environment) ?? AcpmuxHost(environment: environment, peerApprover: peerApprover) let sessionsHome = home.acpmuxHome let router: @Sendable (any AgentPaneHostProviding) -> any AgentPaneHostProviding = { local in AcpmuxChiefSessionRouter(local: local, chief: own, chiefEnvironment: environment, sessionsHome: sessionsHome, parentTag: tag) @@ -108,7 +108,7 @@ struct ChiefHomeAcpmux { let heal = ChiefAcpmuxHeal(home: home, environment: environment, ensureHost: ensureHost) healReplaced = { @Sendable () async -> Bool in await heal.heal() } } - let chief = AcpmuxHost(environment: environment, startsDaemons: false, healReplaced: healReplaced, startOwner: startHost) + let chief = AcpmuxHost(environment: environment, startsDaemons: false, healReplaced: healReplaced, startOwner: startHost, peerApprover: peerApprover) paneHost = chief localRouter = { local in AcpmuxChiefSessionRouter(local: local, chief: chief, chiefEnvironment: environment, parentTag: tag) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+PaneSource.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+PaneSource.swift index fcd152cacacd..bfa8a1a38626 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+PaneSource.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+PaneSource.swift @@ -14,7 +14,7 @@ import Foundation /// the app starts the daemon with no dev origin and no `--dev` /// (``AcpmuxEnvironment/resolve(build:bundledBinDirectory:environment:userHome:uid:isExecutable:launchEnvironment:)``). Only the /// browser dev slot, with no host, still needs both (dev-slot.sh). - static func resolvePane(build: BuildIdentity?, environment: [String: String], showcase: Bool) + static func resolvePane(build: BuildIdentity?, environment: [String: String], showcase: Bool, peerApprover: AgentPanePeerApprover?) -> (source: AgentPaneSource?, host: any AgentPaneHostProviding) { #if DEBUG let allowsDevServer = true @@ -66,7 +66,7 @@ import Foundation let upstream = computerUse.upstream let host = AcpmuxHost(resolve: resolve, computerUse: { computerUse.childEnvironment }, - daemonReady: { socket in await upstream.registerAcpmuxDaemon(acpmuxSocket: socket) }) + daemonReady: { socket in await upstream.registerAcpmuxDaemon(acpmuxSocket: socket) }, peerApprover: peerApprover) // A replaced agent token reaches the running acpmux now, not when a pane reconnects. computerUse.onEndpointChange = { _ in // task-owner: one enrollment per endpoint change; each reads the current endpoint, so the last one wins. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index efd6449cb472..356d854eb0e0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -111,7 +111,7 @@ extension AgentTabStore { /// The agent tabs' view store of `services`, wired to every machine's tree and daemon. static func wired(to services: AppServices) -> AgentTabStore { let tabs = AgentTabStore(build: services.environment.build, registry: services.registry, environment: services.agentPaneEnvironment ?? ProcessInfo.processInfo.environment, showcase: services.environment.showcase, - linkScheme: services.linkScheme, settings: services.settings, openPanel: services.openPanel) + linkScheme: services.linkScheme, settings: services.settings, openPanel: services.openPanel, peerApprover: services.peerApprover) tabs.machineTargets = { [weak services] in // Only the machines of the account signed in now, from a list loaded for it (as the // Cloud machine picker, AgentPaneCloudMachines+App.swift): never a previous account's or @@ -149,7 +149,7 @@ extension AgentTabStore { tabs.unsentDrafts = AgentUnsentDrafts(directory: state) tabs.pageChats.environments = state.map { SavedEnvironments(url: $0.appending(path: "environments.json")) } // The environment picker's Cloud machines: the account's, with Open and Resume (`cloudMachine.*`). - AgentPaneCloudMachines.shared.wire(services) + tabs.cloudMachines.wire(services) // The composer strip's machine dot: the sidebar header's same link state; using a machine // that is down runs its header's Reconnect. tabs.machineLink.state = { [weak tabs, weak services] view in @@ -171,11 +171,11 @@ extension AgentTabStore { await MainActor.run { _ = services?.home.brain.ensure(asked: false) } }, startHost: { [weak services] in await MainActor.run { services?.home.brain.ensureForChat() ?? false } - }) + }, peerApprover: tabs.peerApprover) (tabs.chiefHost, tabs.chiefPaneHost) = (chief.host, chief.paneHost) // A subagent tab made under another Chief home of this Mac attaches there (#25262). tabs.chiefHomes = ChiefHomeDirectory(parents: ChiefHomeDirectory.homeParents(of: home), work: services.work, - environment: { ChiefHomeAcpmux.environment(home: $0) }) + environment: { ChiefHomeAcpmux.environment(home: $0) }, peerApprover: tabs.peerApprover) tabs.localSessionHost = chief.localRouter(tabs.host) // This Mac's stable install id (the Cloud device id): only this host attaches to its acpmux. tabs.blankChatHandler = { [weak services] key in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift index 8603e5f9fee9..57b4a069d156 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift @@ -199,6 +199,9 @@ final class AgentTabStore { private let pageSettings = AgentPanePageSettings() /// The one Read aloud voice every chat page speaks through, and Stop Reading Aloud stops. let readAloud = AgentPaneReadAloud() + /// The Cloud machines each page's environment picker lists (`cloudMachine.*`); the app's peer approver for every host and page. + let cloudMachines = AgentPaneCloudMachines() + let peerApprover: AgentPanePeerApprover? /// The device chats every page's New Tab cards show (``AgentPageChats``). let pageChats = AgentPageChats() weak var actionRegistry: ActionRegistry? @@ -241,8 +244,8 @@ final class AgentTabStore { /// `settings`, when given, is followed for the page settings (``AgentPanePageSettings``); else from `AppServices.settings`. init(build: BuildIdentity?, registry: ActionRegistry, environment: [String: String] = ProcessInfo.processInfo.environment, showcase: Bool = false, linkScheme: String? = nil, settings: SettingsController? = nil, - openPanel: MadeAheadOpenPanel? = nil, defaults: UserDefaults = .standard) { - self.defaults = defaults + openPanel: MadeAheadOpenPanel? = nil, defaults: UserDefaults = .standard, peerApprover: AgentPanePeerApprover? = nil) { + (self.defaults, self.peerApprover) = (defaults, peerApprover) if let data = defaults.data(forKey: Self.adoptionsDefaultsKey), let saved = try? JSONDecoder().decode([String: String].self, from: data) { adoptions = saved @@ -250,7 +253,7 @@ final class AgentTabStore { self.openPanel = openPanel actionRegistry = registry self.linkScheme = linkScheme - let (resolvedSource, resolvedHost) = AgentTabPaneSource.resolvePane(build: build, environment: environment, showcase: showcase) + let (resolvedSource, resolvedHost) = AgentTabPaneSource.resolvePane(build: build, environment: environment, showcase: showcase, peerApprover: peerApprover) host = resolvedHost // Start acpmux while the first pane is loading. The page still owns // the authenticated WebSocket handshake and session selection. Then @@ -546,7 +549,7 @@ final class AgentTabStore { private func makeView(_ model: AgentPaneModel) -> AgentPaneView? { AgentPaneNewTabFrame.shared.pageMade(model) model.linkScheme = linkScheme - model.readAloud = readAloud + (model.readAloud, model.cloudMachines, model.transport.peerApprover) = (readAloud, cloudMachines, peerApprover) guard let source, let view = AgentPaneView(model: model, source: source, renderRate: renderRate, pageHost: AgentPaneTunables.pageHost.value || AgentPaneTunables.chromium.value, openPanel: openPanel) else { return nil } DebugTimings.markLaunch("agent_pane.view_created") view.customization = customization.current diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppDelegate.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppDelegate.swift index 50ef60fc2726..8053cbea0e6b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppDelegate.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppDelegate.swift @@ -121,11 +121,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { PageDescriptor.registerDiffRoot() PageDescriptor.registerFilePageRoots() PageDescriptor.registerAgentActivityRoot() - // Peer-route approvals: where this Mac's approver key lives, before any pane connects. - PeerApprovalsApp.install() DebugTimings.markLaunch("dfl.theme") let services = AppServices(environment: environment, keyWindowSource: { NSApp.keyWindow }, appActiveSource: { NSApp.isActive }, work: launchReads.work, mainWork: mainWork, - launchImages: launchReads.images, relaunchWake: launchReads.wake) + launchImages: launchReads.images, relaunchWake: launchReads.wake, peerApprover: PeerApprovalsApp.approver()) AppProcessRoot.shared.adopt(services) self.services = services #if DEBUG diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift index bd031574c603..602ab29eebdc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices.swift @@ -496,8 +496,9 @@ final class AppServices { /// The app's one set of blocking-work lanes: file I/O, sync system calls and long CPU work run there, /// never on the cooperative pool (PATTERNS P14). Built once here and passed to each consumer's init. let work: BackgroundWork - /// The last quit's agent pages and chat composers, read off the main thread since the launch began. - let launchImages: AgentPaneLaunchImages, relaunchWake: AgentPaneRelaunchWake + /// The last quit's agent pages and chat composers, read off the main thread since the launch began, and + /// this app's peer-route approver key (``PeerApprovalsApp/approver()``, made at launch; nil in tests). + let launchImages: AgentPaneLaunchImages, relaunchWake: AgentPaneRelaunchWake, peerApprover: AgentPanePeerApprover? /// The burst bench's record of refusals, designed no-op Cmd-W presses and terminal grids (`debug.burst`). let burstTrace = BurstTrace() /// The inline VS Code server (`serve-web`) behind VS Code (Inline); starts on first use. @@ -521,9 +522,9 @@ final class AppServices { init(environment: AppEnvironment, keyWindowSource: @escaping @MainActor () -> NSWindow?, appActiveSource: @escaping @MainActor () -> Bool, launchReveal: LaunchReveal = .shared, agentPaneEnvironment: [String: String]? = nil, work: BackgroundWork = BackgroundWork(), mainWork: MainWorkScheduler = MainWorkScheduler(budget: MainFrameBudget()), - launchImages: AgentPaneLaunchImages = AgentPaneLaunchImages(directory: nil), relaunchWake: AgentPaneRelaunchWake = AgentPaneRelaunchWake(file: nil)) { + launchImages: AgentPaneLaunchImages = AgentPaneLaunchImages(directory: nil), relaunchWake: AgentPaneRelaunchWake = AgentPaneRelaunchWake(file: nil), peerApprover: AgentPanePeerApprover? = nil) { self.work = work - (self.launchImages, self.relaunchWake, self.keyWindowSource, self.appActiveSource) = (launchImages, relaunchWake, keyWindowSource, appActiveSource) + (self.launchImages, self.relaunchWake, self.keyWindowSource, self.appActiveSource, self.peerApprover) = (launchImages, relaunchWake, keyWindowSource, appActiveSource, peerApprover) actionBudget = ActionBudgetMonitor(board: mainWork.budget.board) // Before the updater's What's New tracker writes its record: an install that ran before // never sees the Browser tip (AgentPaneFeatureTipRecords.settleInstall). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaletteSourcesBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaletteSourcesBridge.swift index 19416b9726d8..7c332062a5de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaletteSourcesBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaletteSourcesBridge.swift @@ -203,9 +203,9 @@ enum PaletteSourcesBridge { extension AgentChatDestinations { init(services: AppServices) { - let host = services.agentTabs.host + let (host, machines) = (services.agentTabs.host, services.agentTabs.cloudMachines) self.init(localTitle: WorkspaceVerbStrings.thisMac, peers: { try await host.chatPeers() }, cloud: { - await AgentPaneCloudMachines.shared.list?() ?? .init(signedIn: false, loaded: false, machines: []) + await machines.list?() ?? .init(signedIn: false, loaded: false, machines: []) }) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/PeerApprovals+App.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/PeerApprovals+App.swift index 2e0a6534d4d5..96cfbc4753de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/PeerApprovals+App.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/PeerApprovals+App.swift @@ -6,15 +6,15 @@ import Foundation /// Peer-route approvals in the app (acpmux `hub/mac_approvers.rs`): where this Mac's approver key /// lives, and what the pane's Enroll this Mac runs. enum PeerApprovalsApp { - /// A signed build keeps the key in the Secure Enclave (its blob in this app's data-protection - /// Keychain); a DEV build without a Team ID keeps a memory key for this launch. Enroll runs the - /// app's own cmux-tui, `acp host approvals enroll PEER`, which enrolls the key on that machine - /// over the person's ssh. - @MainActor static func install() { + /// The app's one approver (made once at launch, before any pane connects). A signed + /// build keeps the key in the Secure Enclave (its blob in this app's data-protection Keychain); a + /// DEV build without a Team ID keeps a memory key for this launch. Enroll runs the app's own + /// cmux-tui, `acp host approvals enroll PEER`, which enrolls the key on that machine over the person's ssh. + @MainActor static func approver() -> AgentPanePeerApprover { let service = (Bundle.main.bundleIdentifier ?? "com.cmuxterm.app.next") + ".mac-approver" - AcpmuxMacApprover.shared.configure(keychainService: CodeSigningTeam.current() == nil ? nil : service) - guard let binary = try? DaemonLauncher.resolveBinary(bundle: .main, environment: ProcessInfo.processInfo.environment) else { return } - AgentPanePeerApprovals.enroll = { peer, daemon in + let key = AcpmuxMacApprover(keychainService: CodeSigningTeam.current() == nil ? nil : service) + guard let binary = try? DaemonLauncher.resolveBinary(bundle: .main, environment: ProcessInfo.processInfo.environment) else { return .init(key: key) } + return AgentPanePeerApprover(key: key) { peer, daemon in // The person's ssh environment, and the daemon the pane talks to (a DEV or tagged // build's own acpmux home), which knows the peer and holds this Mac's key. let cli = HostCLI(binary: binary) { await SSHService.environment().merging(daemon) { _, own in own } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TopPages/WhatsNewPage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TopPages/WhatsNewPage.swift index 98d0a6562166..56024844b8c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TopPages/WhatsNewPage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TopPages/WhatsNewPage.swift @@ -24,10 +24,9 @@ enum WhatsNewPage { } /// A click on a client-only sidebar item: the Channels entry switches Home to Channels; a - /// Chief subagent row opens its chat; another machine's group folds and its chat rows open. + /// Chief subagent row opens its chat. Other machines' chats draw no rows (`RemoteMachinesSidebar` is not bound). static func activateClientItem(_ id: LayoutItemID, services: AppServices, in state: WindowState?) { if ChiefSidebar.shared.activate(id, registry: services.registry) { return } - if RemoteMachinesSidebar.shared.activate(id) { return } // The Channels entry after Home (cx-frn2y): the one Home view path, as a person's click. if id == SidebarTransientItem.channelsID { _ = services.registry.perform("home.switchView", invocation: .init(arguments: ["view": .string("channels")], origin: .user)) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefHomeDirectory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefHomeDirectory.swift index 2335f7462186..8ad8554a62e1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefHomeDirectory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefHomeDirectory.swift @@ -50,16 +50,15 @@ public actor ChiefHomeDirectory { private var lastScan: ContinuousClock.Instant? private var lookups: [String: Task] = [:] + /// `peerApprover`: the app's approver key, which the default `ownHost` gives each home's daemon it connects. public init(parents: [URL], work: BackgroundWork, rescanInterval: Duration = .seconds(10), - environment: @escaping @Sendable (ChiefHome) -> AcpmuxEnvironment?, - ownHost: @escaping @Sendable (_ environment: AcpmuxEnvironment?, _ startsDaemons: Bool) -> any AgentPaneHostProviding = { - AcpmuxHost(environment: $0, startsDaemons: $1) - }) { + environment: @escaping @Sendable (ChiefHome) -> AcpmuxEnvironment?, peerApprover: AgentPanePeerApprover? = nil, + ownHost: (@Sendable (_ environment: AcpmuxEnvironment?, _ startsDaemons: Bool) -> any AgentPaneHostProviding)? = nil) { self.parents = parents self.work = work self.rescanInterval = rescanInterval self.environment = environment - self.ownHost = ownHost + self.ownHost = ownHost ?? { AcpmuxHost(environment: $0, startsDaemons: $1, peerApprover: peerApprover) } } /// The directories that hold the Chief homes beside `home`: its parent, the Chief root and diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPanePeerApprovalsNoticeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPanePeerApprovalsNoticeTests.swift index 1ff6c0d74d5b..ac21307fd2d0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPanePeerApprovalsNoticeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPanePeerApprovalsNoticeTests.swift @@ -31,8 +31,7 @@ struct AgentPanePeerApprovalsNoticeTests { @Test func aNewReasonReplacesTheNoticeAndACountedAnswerRemovesIt() throws { let (view, directory) = try pane() defer { view.close(); try? FileManager.default.removeItem(at: directory) } - AgentPanePeerApprovals.enroll = { _, _ in nil } - defer { AgentPanePeerApprovals.enroll = nil } + view.model.transport.peerApprover = AgentPanePeerApprover(key: AcpmuxMacApprover()) { _, _ in nil } view.showPeerApprovalsOff(.onceOnly) #expect(view.remoteApprovalsNoticeLines?.lines == [AgentPaneView.peerApprovalsOnceOnly]) @@ -57,8 +56,7 @@ struct AgentPanePeerApprovalsNoticeTests { var asked: [String] = [] var through: [[String: String]] = [] var failure: String? = "ssh: far: no route" - AgentPanePeerApprovals.enroll = { peer, daemon in asked.append(peer); through.append(daemon); return failure } - defer { AgentPanePeerApprovals.enroll = nil } + view.model.transport.peerApprover = AgentPanePeerApprover(key: AcpmuxMacApprover()) { peer, daemon in asked.append(peer); through.append(daemon); return failure } // A pane with no daemon enrolls nowhere. view.showPeerApprovalsOff(.notEnrolled(peer: "far")) @@ -112,10 +110,10 @@ struct AgentPanePeerApprovalsNoticeTests { /// Enroll runs against the daemon this app registered its key with on the pane's socket (a /// DEV or tagged build's own acpmux home), not the default one. @Test func enrollGoesToTheDaemonThePaneTalksTo() { - AgentPanePeerApprovals.daemons["/tmp/a.sock"] = ["ACPMUX_HOME": "/tmp/a", "ACPMUX_SOCKET": "/tmp/a.sock"] - defer { AgentPanePeerApprovals.daemons["/tmp/a.sock"] = nil } - #expect(AgentPanePeerApprovals.daemon(socket: "/tmp/a.sock") == ["ACPMUX_HOME": "/tmp/a", "ACPMUX_SOCKET": "/tmp/a.sock"]) - #expect(AgentPanePeerApprovals.daemon(socket: "/tmp/b.sock") == ["ACPMUX_SOCKET": "/tmp/b.sock"]) - #expect(AgentPanePeerApprovals.daemon(socket: nil).isEmpty) + let approver = AgentPanePeerApprover(key: AcpmuxMacApprover()) + approver.daemons["/tmp/a.sock"] = ["ACPMUX_HOME": "/tmp/a", "ACPMUX_SOCKET": "/tmp/a.sock"] + #expect(approver.daemon(socket: "/tmp/a.sock") == ["ACPMUX_HOME": "/tmp/a", "ACPMUX_SOCKET": "/tmp/a.sock"]) + #expect(approver.daemon(socket: "/tmp/b.sock") == ["ACPMUX_SOCKET": "/tmp/b.sock"]) + #expect(approver.daemon(socket: nil).isEmpty) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneRelayedAllowTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneRelayedAllowTests.swift index 34f3816cf17c..f165b9396306 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneRelayedAllowTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneRelayedAllowTests.swift @@ -342,8 +342,8 @@ struct AgentPaneRelayedAllowTests { /// The local daemon's own run (not the peer's) and a peer's prompt, seen by the page. static func peerChat(_ server: AcpmuxStandInServer, _ inbox: PageInbox, _ transport: AgentPaneTransport) async throws -> Int { - // A DEV build's memory key (the app configures it at launch). - AcpmuxMacApprover.shared.configure(keychainService: nil) + // A DEV build's memory key (the app makes its approver at launch and hands it to each pane). + transport.peerApprover = AgentPanePeerApprover(key: AcpmuxMacApprover(keychainService: nil)) let local = String(repeating: "d", count: 32) server.answer("initialize", with: #"{"protocolVersion":1,"_meta":{"acpmux":{"origin":"local","personInstance":"\#(local)"}}}"#) transport.deliver = { event, done in inbox.deliver(event.frames); done() } @@ -384,7 +384,7 @@ struct AgentPaneRelayedAllowTests { #expect(acpmux["decisionNonce"] as? String == Self.nonce) #expect(acpmux["personDecision"] == nil) let decision = try #require(acpmux["approverDecision"] as? [String: Any]) - let identity = try #require(AcpmuxMacApprover.shared.identity) + let identity = try #require(transport.peerApprover?.key.identity) #expect(decision["approver"] as? String == identity.id) let expires = try #require((decision["expiresAt"] as? NSNumber)?.uint64Value) #expect(expires > before && expires <= before + 300) @@ -600,7 +600,7 @@ struct AgentPaneRelayedAllowTests { let acpmux = try #require((params["_meta"] as? [String: Any])?["acpmux"] as? [String: Any], "an unsigned group allow: \(sent)") let members = try #require(acpmux["approverDecisions"] as? [String: Any]) #expect(Set(members.keys) == ["p1", "p2"]) - let identity = try #require(AcpmuxMacApprover.shared.identity) + let identity = try #require(transport.peerApprover?.key.identity) let key = try P256.Signing.PublicKey(x963Representation: AcpmuxMacApproverTests.bytes(identity.publicKey)) for (permission, toolCall, option, nonce) in [("p1", "call-1", "yes", Self.nonce), ("p2", "call-2", "ok", nonce2)] { let member = try #require(members[permission] as? [String: Any]) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteMachinesSidebarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteMachinesSidebarTests.swift index fff910b47397..093d8e63b60d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteMachinesSidebarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteMachinesSidebarTests.swift @@ -121,8 +121,8 @@ struct RemoteMachinesSidebarTests { /// band holds Home and what the person put there; the agent chats of another machine (here a Cloud VM's eleven /// idle Chief subagents, titled with their prompt) never draw in it. @Test func remoteChatsNeverDrawInTheTopBand() { - // The app's own remote model holds the chats, as the running app's does. - let remote = RemoteMachinesSidebar.shared + // A remote model holds the chats, as one would in the running app. + let remote = RemoteMachinesSidebar(lanes: AgentLaneStore()) let prompt = "You are a subagent of Chief, an AI agent that works for one user" remote.machines = RemoteMachinesSidebar.machines( lanes: (0..<11).map { Self.lane("optchat-sub-2e448bc2-a6\($0)", peer: "chief-subagents-2", since: TimeInterval($0), title: prompt) }