Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -75,9 +73,15 @@ public nonisolated final class AcpmuxMacApprover: Sendable {
private let state: Mutex<State>
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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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,
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) }
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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.")
Expand Down
Loading
Loading