Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
20f17fb
test(agent-pane): cover trust for explicit folder opens
teamleaderleo Oct 27, 2026
27e857b
fix(agent-pane): trust folders opened by the user
teamleaderleo Oct 27, 2026
dd595e2
fix(agent-pane): serialize automatic folder trust
teamleaderleo Oct 27, 2026
0380f73
fix(agent-pane): await folder trust response
teamleaderleo Oct 27, 2026
ea8294e
fix(agent-pane): retain trust across picker startup
teamleaderleo Oct 27, 2026
d2d5450
fix(agent-pane): keep home picker confirmation
teamleaderleo Oct 27, 2026
c7f6443
fix(agent-pane): complete deferred folder trust before chat start
teamleaderleo Oct 27, 2026
9f37827
test(agent-pane): cover trust before replay and failed sends
teamleaderleo Oct 27, 2026
72f73f6
fix(agent-pane): settle trust before replay and retain failed writes
teamleaderleo Oct 27, 2026
eeab680
test(agent-pane): model picked root before trust send failure
teamleaderleo Oct 27, 2026
7c8f4f8
test(agent-pane): keep journal through refused trust and reconnect
teamleaderleo Oct 27, 2026
14b3e35
fix(agent-pane): retain failed trust and block replay until success
teamleaderleo Oct 27, 2026
20cad48
fix(agent-pane): persist pending folder trust through app restarts
teamleaderleo Oct 27, 2026
cf95f5e
fix(agent-pane): bind queued trust to its daemon and persist at enqueue
teamleaderleo Oct 27, 2026
484d02b
fix(agent-pane): bind picks made after close to the next daemon
teamleaderleo Oct 27, 2026
c718c11
test(agent-pane): observe queued trust on its main actor
teamleaderleo Oct 27, 2026
742341c
test(agent-pane): settle initialization before holding the trust queue
teamleaderleo Oct 27, 2026
a845f39
test(agent-pane): release held queue after closing the fixture
teamleaderleo Oct 27, 2026
413ccbd
Merge feat-cmux-next into fix/sr2-folder-trust
teamleaderleo Oct 28, 2026
f952d7f
Merge branch 'feat-cmux-next' into fix/sr2-folder-trust
teamleaderleo Oct 28, 2026
80b076a
Merge feat-cmux-next into fix/sr2-folder-trust
teamleaderleo Oct 29, 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
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ nonisolated final class AcpmuxPaneSocket: NSObject, URLSessionWebSocketDelegate,
onQueued?(queued)
}

var isClosed: Bool { state.withLock { $0.closed != nil } }

var queuedFrames: Int { state.withLock { $0.inbox.count } }

/// Queues a frame the host made (a refusal) as if the daemon had sent it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ nonisolated final class AcpmuxRequestIds: Sendable {
var entries: [Int: Entry] = [:]
var inFlight: Set<String> = []
/// Told when the daemon answers a relay id (``whenAnswered(_:_:)``).
var answered: [Int: @Sendable () -> Void] = [:]
var answered: [Int: @Sendable (Bool) -> Void] = [:]
}

private let state = Mutex(State())
Expand All @@ -35,6 +35,11 @@ nonisolated final class AcpmuxRequestIds: Sendable {
/// Calls `handler` (off the main thread) when the daemon answers request `id`, with a result
/// or an error; never when the request is cancelled.
func whenAnswered(_ id: Int, _ handler: @escaping @Sendable () -> Void) {
state.withLock { $0.answered[id] = { _ in handler() } }
}

/// A host-owned request's acknowledgement, distinguishing a result from a refusal.
func whenResult(_ id: Int, _ handler: @escaping @Sendable (Bool) -> Void) {
state.withLock { $0.answered[id] = handler }
}

Expand Down Expand Up @@ -69,6 +74,15 @@ nonisolated final class AcpmuxRequestIds: Sendable {
}
}

/// Cancels a host-owned request and wakes its waiter (used when a socket closes before a reply).
func cancelAndNotify(_ id: Int) {
let handler = state.withLock { state -> (@Sendable (Bool) -> Void)? in
state.answered.removeValue(forKey: id)
}
handler?(false)
cancel(id)
}

/// What the socket does with one daemon frame.
nonisolated enum Inbound {
/// To the page: the fresh serialization, the parsed frame (its id the page's) for the
Expand Down Expand Up @@ -101,7 +115,7 @@ nonisolated final class AcpmuxRequestIds: Sendable {
return .page(fresh, object: object, replyTo: nil)
}
guard let relayID = object["id"].flatMap(AcpmuxPaneMethods.rawID).flatMap({ Int($0) }) else { return .drop }
state.withLock { $0.answered.removeValue(forKey: relayID) }?()
state.withLock { $0.answered.removeValue(forKey: relayID) }?(object["error"] == nil && object["result"] != nil)
guard let entry = state.withLock({ state -> Entry? in
guard let entry = state.entries.removeValue(forKey: relayID) else { return nil }
state.inFlight.remove(entry.pageID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ extension AgentPaneModel {
chosenFolder = folder
pickedFolder = nil
_ = beginPick()
let homeFolder = transport.homeFolder
let (picked, home) = await Task.detached(priority: .userInitiated) {
(AcpmuxPathPolicy.canonical(folder), homeFolder.flatMap(AcpmuxPathPolicy.canonical))
}.value
if let picked, !AgentHome.isHomeOrAbove(picked, home: home) {
await transport.grantPicked(picked)
}
return AgentPaneReply.success(["cwd": folder])
case .cancelled:
return AgentPaneReply.success()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,26 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
var at: Date
}

nonisolated struct PendingTrust: Codable, Sendable, Equatable {
var daemon: String
var folder: String
}

private nonisolated struct Document: Codable {
var version = 1
var version = 2
var entries: [Entry]
var pendingTrust: [PendingTrust]

init(entries: [Entry], pendingTrust: [PendingTrust]) {
self.entries = entries
self.pendingTrust = pendingTrust
}

init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
entries = try values.decode([Entry].self, forKey: .entries)
pendingTrust = try values.decodeIfPresent([PendingTrust].self, forKey: .pendingTrust) ?? []
}
}

/// Who holds an entry in this run: the connection (its relay's token) whose page sent it, or
Expand All @@ -72,6 +89,7 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
private nonisolated struct State {
var loaded = false
var entries: [Entry] = []
var pendingTrust: [PendingTrust] = []
var holders: [String: Holder] = [:]
/// Connections that ended: they record and claim nothing more.
var ended: Set<UUID> = []
Expand Down Expand Up @@ -121,6 +139,54 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
return kept
}

/// Trust I/O uses the journal's file queue, never the main actor or cooperative task pool.
func trustIO<T: Sendable>(_ work: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { done in queue.async { done.resume(returning: work()) } }
}

/// Persists a folder whose trust write must complete before journal replay. An empty daemon is
/// used when a native picker completed before this pane had opened its socket; the next local
/// connection adopts that barrier.
func recordPendingTrust(daemon: String?, folder: String) -> Bool {
let key = daemon ?? ""
return change { state in
if !key.isEmpty { state.pendingTrust.removeAll { $0.daemon.isEmpty && $0.folder == folder } }
if !state.pendingTrust.contains(where: { $0.daemon == key && $0.folder == folder }) {
state.pendingTrust.append(PendingTrust(daemon: key, folder: folder))
}
return true // Retry persistence even if a previous write failed.
}
}

/// The first local connection claims picks made before a daemon was known, durably.
func bindPreOpenTrust(daemon: String) -> Bool {
change { state in
guard !state.pendingTrust.isEmpty else { return false }
for index in state.pendingTrust.indices where state.pendingTrust[index].daemon.isEmpty {
state.pendingTrust[index].daemon = daemon
}
return true
}
}

/// Pending trust barriers for a local daemon, including a pre-open native pick.
func pendingTrustFolders(daemon: String) -> [String] {
state.withLock { state in
load(&state)
return state.pendingTrust.filter { $0.daemon.isEmpty || $0.daemon == daemon }.map(\.folder)
}
}

/// Clears a barrier only after acpmux acknowledged the trust write.
func clearPendingTrust(daemon: String?, folder: String) {
let key = daemon ?? ""
change { state in
let before = state.pendingTrust.count
state.pendingTrust.removeAll { $0.folder == folder && $0.daemon == key }
return state.pendingTrust.count != before
}
}

/// Notes that the relay sends prompt `promptId` to `daemon`. True when it sent that id there
/// before in this run: the pane's Retry, which goes as a resend.
func sendsAgain(daemon: String, promptId: String) -> Bool {
Expand Down Expand Up @@ -173,7 +239,7 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
/// so the prompts that page sent are the page's again (Retry); the rest are left over. The
/// change is made at once (no file read on the caller's thread) and written off it.
func connectionEnded(owner: UUID, byDaemon: Bool) {
let snapshot = state.withLock { state -> (Int, [Entry])? in
let snapshot = state.withLock { state -> (Int, [Entry], [PendingTrust])? in
state.ended.insert(owner)
guard state.loaded else { return nil }
var changed = false
Expand All @@ -186,10 +252,10 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
}
guard changed else { return nil }
state.generation += 1
return (state.generation, state.entries)
return (state.generation, state.entries, state.pendingTrust)
}
guard let (generation, entries) = snapshot else { return }
queue.async { [self] in write(entries, generation: generation) }
guard let (generation, entries, pendingTrust) = snapshot else { return }
queue.async { [self] in write(entries, pendingTrust: pendingTrust, generation: generation) }
}

/// The prompts kept now (tests).
Expand All @@ -205,16 +271,18 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
await withCheckedContinuation { (done: CheckedContinuation<Void, Never>) in queue.async { done.resume() } }
}

/// Applies `body` (true when the entries changed) and writes the result when they did.
private func change(_ body: (inout State) -> Bool) {
let snapshot = state.withLock { state -> (Int, [Entry])? in
/// `body` reports mutation; the return value reports persistence success. No mutation is a
/// successful no-op, so opening an empty journal never fails its trust barrier.
@discardableResult
private func change(_ body: (inout State) -> Bool) -> Bool {
let snapshot = state.withLock { state -> (Int, [Entry], [PendingTrust])? in
load(&state)
guard body(&state) else { return nil }
state.generation += 1
return (state.generation, state.entries)
return (state.generation, state.entries, state.pendingTrust)
}
guard let (generation, entries) = snapshot else { return }
write(entries, generation: generation)
guard let (generation, entries, pendingTrust) = snapshot else { return true }
return write(entries, pendingTrust: pendingTrust, generation: generation)
}

/// Reads the file once; an unreadable one is an empty journal.
Expand All @@ -227,18 +295,22 @@ public nonisolated final class AgentPanePromptJournal: Sendable {
return
}
state.entries = Array(document.entries.suffix(Self.maximumEntries))
state.pendingTrust = document.pendingTrust
}

private func write(_ entries: [Entry], generation: Int) {
@discardableResult
private func write(_ entries: [Entry], pendingTrust: [PendingTrust], generation: Int) -> Bool {
written.withLock { last in
guard generation > last else { return }
last = generation
guard generation > last else { return true }
do {
let data = try Self.encoder.encode(Document(entries: entries))
let data = try Self.encoder.encode(Document(entries: entries, pendingTrust: pendingTrust))
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
last = generation
return true
} catch {
Self.logger.error("agent pane prompt journal write failed: \(String(describing: error), privacy: .public)")
return false
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public final class AgentPaneStartFolders {
(AcpmuxPathPolicy.canonical(path), homeFolder.flatMap(AcpmuxPathPolicy.canonical))
}.value
guard let home, picked == home else { return AgentPaneModel.transportFailure(.pathInvalid) }
return Self.answerHome(home, confirm: confirm, transport: transport)
return await Self.answerHome(home, confirm: confirm, transport: transport)
case .aboveHome: return AgentPaneReply.success(["status": "refused", "reason": "root", "cwd": path])
case .agentHome: return AgentPaneReply.success(["status": "ok", "cwd": answer.agentHome ?? path])
case .missing, nil: return AgentPaneModel.transportFailure(.pathInvalid)
Expand All @@ -98,7 +98,7 @@ public final class AgentPaneStartFolders {
return AgentPaneReply.success(["status": "ok", "cwd": folder])
}
guard let home, folder == home else { return AgentPaneReply.success(["status": "refused", "reason": "root", "cwd": folder]) }
return Self.answerHome(home, confirm: confirm, transport: transport)
return await Self.answerHome(home, confirm: confirm, transport: transport)
}

/// A new chat on another machine (cx-1bhjj): that machine's store decides its folder by the
Expand Down Expand Up @@ -138,18 +138,17 @@ public final class AgentPaneStartFolders {
private static func grantPicked(_ path: String, transport: AgentPaneTransport) async {
// The store's spelling is not always the canonical one the relay compares (a symlink).
guard let folder = await Task.detached(operation: { AcpmuxPathPolicy.canonical(path) }).value,
!transport.addedRoots.contains(folder), !transport.roots().contains(folder),
transport.gestures.consume() else { return }
transport.grantPicked(folder)
await transport.grantPicked(folder)
}

/// The home folder the user picked: asked about first; the answer (on its click) makes it a
/// root of this pane, so the chat starts there at once.
private static func answerHome(_ home: String, confirm: Bool, transport: AgentPaneTransport) -> [String: Any] {
private static func answerHome(_ home: String, confirm: Bool, transport: AgentPaneTransport) async -> [String: Any] {
if transport.addedRoots.contains(home) { return AgentPaneReply.success(["status": "ok", "cwd": home]) }
guard confirm else { return AgentPaneReply.success(["status": "confirm", "reason": "home", "cwd": home]) }
guard transport.gestures.consume() else { return AgentPaneModel.transportFailure(.gestureRequired) }
transport.grantPicked(home)
await transport.grantPicked(home, trust: false)
return AgentPaneReply.success(["status": "ok", "cwd": home])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,21 @@ extension AgentPaneTransport {
/// main-actor state, the re-encode and the send. It stops at the first frame that needs the main
/// actor (the gesture record, the sheets, the pane's sessions), which gets only small facts.
func drain() async {
while let entry = pending.first {
while !pending.isEmpty || !pendingHost.isEmpty || (sentFirst && hasPendingTrust) {
if sentFirst, let socket, !socket.isClosed, let ids = requestIds {
flushPendingTrust()
if !pendingHost.isEmpty {
let host = pendingHost.removeFirst()
guard host.connection == current else {
host.reply?.resume(returning: .staleConnection)
continue
}
// A trust write must be acknowledged before any queued chat can start.
await sendHost(host, socket: socket, ids: ids)
continue
}
}
guard let entry = pending.first else { break }
guard !entry.frames.isEmpty else {
finishFirst()
continue
Expand Down Expand Up @@ -197,7 +211,9 @@ extension AgentPaneTransport {
if wasFirst, sentFirst {
let relay = promptRelay
await provePerson(socket: socket, ids: ids)
if self.socket === socket { await relay?.resend(socket: socket, ids: ids) }
// Journal replay bypasses the page send line, so it needs the same trust barrier.
await finishTrustBeforeReplay(socket: socket, ids: ids)
if self.socket === socket, !socket.isClosed { await relay?.resend(socket: socket, ids: ids) }
}
}
draining = false
Expand Down Expand Up @@ -238,12 +254,20 @@ extension AgentPaneTransport {
Self.logger.error("agent pane transport: no person key for this daemon; allows stay refused")
return
}
let frame: [String: Any] = ["jsonrpc": "2.0", "id": ids.hostID(), "method": "_acpmux/person_prove",
let id = ids.hostID()
personProofID = id
let frame: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": "_acpmux/person_prove",
"params": ["proof": proof]]
guard let data = try? JSONSerialization.data(withJSONObject: frame), let text = String(data: data, encoding: .utf8) else { return }
if let error = socket.send(text) {
Self.logger.error("agent pane transport: person proof not sent: \(error.rawValue, privacy: .public)")
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
ids.whenAnswered(id) { continuation.resume() }
if let error = socket.send(text) {
ids.cancel(id)
continuation.resume()
Self.logger.error("agent pane transport: person proof not sent: \(error.rawValue, privacy: .public)")
}
}
if self.socket === socket, personProofID == id { personProofID = nil }
}

/// The most frames one off-main pass takes (so a long line still yields between passes).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ extension AgentPaneTransport {
guard let picked, picked != "/" else { return path }
if let home, AcpmuxPathPolicy.contains(root: picked, path: home) { return path }
grant(picked)
// The native Open panel is an explicit user folder-open action. Record the same choice in
// acpmux so its harness trust gate does not ask a second time for this folder.
await trustPicked(picked)
return path
}
}
Loading
Loading