Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -46,6 +46,14 @@ public nonisolated struct AgentPaneGestureIntent: Equatable, Sendable {
"_acpmux/permission_respond": ["answers", "annotations"],
]

/// Intent params a method may add that bind the ticket without being part of its pick: a
/// send's chat (`sessionId`). A send ticket bound to a chat is the one ticket an upload of that
/// send may hold (``AgentPaneUserGestures/holdForUpload(_:upload:connection:sessionId:promptId:)``),
/// and only a prompt to that chat redeems it.
public static let bindingKeys: [String: Set<String>] = [
"session/prompt": ["sessionId"],
]

/// How long a ticket waits for its frame: a pick held while a harness starts, or a prompt held
/// while the user answers the folder trust question.
public var lifetime: TimeInterval {
Expand All @@ -54,6 +62,8 @@ public nonisolated struct AgentPaneGestureIntent: Equatable, Sendable {

public var method: String
public var params: [String: AgentPaneJSONScalar]
/// The chat a send ticket is for (``bindingKeys``); nil when the intent names none.
public var sessionId: String?

/// The intent of `transport.gesture`'s params, nil when they break the contract (a missing or
/// unknown method, an unknown or missing intent param, a value that is not a scalar, or any
Expand All @@ -63,9 +73,15 @@ public nonisolated struct AgentPaneGestureIntent: Equatable, Sendable {
let intent = gestureParams["intent"] as? [String: Any], Set(intent.keys) == ["method", "params"],
let method = intent["method"] as? String, let keys = Self.methods[method],
let raw = intent["params"] as? [String: Any], keys.isSubset(of: raw.keys),
Set(raw.keys).subtracting(keys).isSubset(of: Self.objectKeys[method] ?? []) else { return nil }
Set(raw.keys).subtracting(keys).isSubset(of: (Self.objectKeys[method] ?? []).union(Self.bindingKeys[method] ?? []))
else { return nil }
var params: [String: AgentPaneJSONScalar] = [:]
for (key, value) in raw {
if Self.bindingKeys[method]?.contains(key) == true {
guard let session = value as? String, !session.isEmpty else { return nil }
sessionId = session
continue
}
if !keys.contains(key) {
// A question's answers or annotations: an object, by its canonical digest.
guard value is [String: Any], let digest = AcpmuxSecretKey.canonicalDigest(value) else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ extension AgentPaneTransport {
// promptId) and a permission answer (its session, prompt and option) redeem a
// ticket, for their exact pick, into a session of this pane. A ticket is spent even
// when it does not match.
let redeemed = gestures.redeem(ticket, connection: id, pick: facts.pick)
let redeemed = gestures.redeem(ticket, connection: id, pick: facts.pick, sessionId: facts.sessionId)
granted = redeemed && AgentPaneGestureIntent.methods[facts.method ?? ""] != nil
&& facts.sessionId.map(sessions.contains) == true
ticketPick = granted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ import CmuxNextCompat
/// `transport.gesture`: reserves the current gesture for one pick sent later on this connection.
public func reserveGesture(_ intent: AgentPaneGestureIntent) -> String? {
guard socket != nil else { return nil }
// A send ticket bound to a chat only for a chat of this pane.
if let session = intent.sessionId, !sessions.contains(session) { return nil }
// cx-0q7o: a permission answer's ticket only for a prompt of this pane's session that the
// pane was shown, only for an option that prompt offers as an allow, and only right after
// the person's own click (cx-ocdz: a posted click or a keystroke's gesture never answers).
Expand All @@ -320,10 +322,19 @@ import CmuxNextCompat
return gestures.reserve(connection: current, intent: intent)
}

/// `uploads.begin {sessionId, promptId, ticket}`: the upload `upload` of the person's send holds
/// its send ticket (``AgentPaneUserGestures/holdForUpload(_:upload:connection:sessionId:promptId:)``)
/// on this connection, for a chat of this pane. False refuses the upload (`transport.gesture_required`).
public func holdSendTicket(_ ticket: String, upload: String, sessionId: String, promptId: String) -> Bool {
guard socket != nil, sessions.contains(sessionId) else { return false }
return gestures.holdForUpload(ticket, upload: upload, connection: current, sessionId: sessionId, promptId: promptId)
}

/// Closes the connection if it is the current one.
public func close(connection id: Int) {
guard id == current, let socket else { return }
socket.close(code: 1000, reason: "", error: nil)
gestures.clearTickets(connection: id)
localAppToken = nil
promptRelay?.ended(byDaemon: false)
}
Expand All @@ -343,6 +354,7 @@ import CmuxNextCompat
let event = AgentPaneTransportEvent(connection: current, frames: batch.frames, closed: batch.closed)
if batch.closed != nil {
self.socket = nil
gestures.clearTickets(connection: current)
localAppToken = nil
// A connection the daemon closed: the page was told, so its prompts are the page's again
// (Retry), unless Debug Settings agentPane.debug.lostPromptReplay sends them again.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
public import Foundation

/// What `uploads.begin` carries so a file of any size, uploaded after the person's Send, still
/// sends (Leo, 2026-10-26: "I ALREADY SAID WE NEED ARBITARRILY LARGE FILES OF ANY TYPE").
///
/// The transaction:
/// 1. At the Send, the page reserves the send ticket for its prompt and chat:
/// `transport.gesture {intent: {method: "session/prompt", params: {promptId, sessionId}}}`.
/// 2. Each `uploads.begin` carries `{sessionId, promptId, ticket}` beside its file. The host holds
/// the ticket for that upload (``AgentPaneTransport/holdSendTicket(_:upload:sessionId:promptId:)``):
/// the ticket's connection, chat and prompt must be exactly these, and while any of its uploads
/// is in progress its clock stands still. An upload that makes no progress for
/// ``AgentPaneUserGestures/uploadStallLimit`` ends the hold and the ticket.
/// 3. Finish and cancel release the hold (``AgentPaneUserGestures/releaseUpload(_:)``). A newer
/// send's ticket, a prompt that goes out, the page's release and the connection's end drop the
/// ticket, and the uploads that held it are reported (``AgentPaneUserGestures/onUploadHoldsEnded``).
/// 4. The prompt (that `promptId`, to that chat) redeems the ticket once, after every upload
/// released it; no other frame, prompt or chat can.
public nonisolated struct AgentPaneUploadSendTicket: Equatable, Sendable {
public let sessionId: String
public let promptId: String
public let ticket: String

public init(sessionId: String, promptId: String, ticket: String) {
self.sessionId = sessionId
self.promptId = promptId
self.ticket = ticket
}

/// The send ticket of `uploads.begin`'s params; nil when one of the three is missing or empty
/// (the upload is refused: no upload goes without the person's Send).
public init?(beginParams params: [String: Any]?) {
guard let sessionId = params?["sessionId"] as? String, !sessionId.isEmpty,
let promptId = params?["promptId"] as? String, !promptId.isEmpty,
let ticket = params?["ticket"] as? String, !ticket.isEmpty else { return nil }
self.init(sessionId: sessionId, promptId: promptId, ticket: ticket)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,46 @@ import CmuxNextCompat
/// How long a prompt held for the folder trust answer keeps its send's gesture.
public nonisolated static let heldPromptLifetime: TimeInterval = 600

/// How long an upload that holds a send ticket may go without progress
/// (``uploadProgressed(_:)``) before its hold ends and the ticket with it.
public nonisolated static let uploadStallLimit: TimeInterval = 60

struct Ticket {
var connection: Int
var intent: AgentPaneGestureIntent
var at: TimeInterval
/// The uploads of this send in progress (``holdForUpload(_:upload:connection:sessionId:promptId:)``),
/// each with its own last progress.
var uploads: [String: TimeInterval] = [:]
/// While `uploads` is not empty: when the ticket's clock stopped.
var stoppedAt: TimeInterval?

/// Within its lifetime, not counting the time its uploads held it, and not one of them stalled.
func isFresh(_ now: TimeInterval) -> Bool {
guard let stoppedAt else { return now - at <= intent.lifetime }
return stoppedAt - at <= intent.lifetime
&& uploads.values.allSatisfy { now - $0 <= AgentPaneUserGestures.uploadStallLimit }
}
}

private var tickets: [String: Ticket] = [:]

/// The uploads whose send ticket ended while they held it (a newer send's ticket, a prompt that
/// went out, the connection's end, a stall): the uploader cancels them. Finish and cancel
/// (``releaseUpload(_:)``) are not reported.
public var onUploadHoldsEnded: (@MainActor (_ uploads: Set<String>) -> Void)?

/// Removes the tickets `drop` picks, and reports the uploads they held.
private func dropTickets(where drop: (_ key: String, _ ticket: Ticket) -> Bool) {
var ended = Set<String>()
tickets = tickets.filter { entry in
guard drop(entry.key, entry.value) else { return true }
ended.formUnion(entry.value.uploads.keys)
return false
}
if !ended.isEmpty { onUploadHoldsEnded?(ended) }
}

/// Binds the current gesture to one pick the page sends later (a pick held behind a harness
/// switch) on `connection`: consumes the gesture and returns a single-use ticket, nil when
/// there is none. One outstanding ticket per slot per connection (``AgentPaneGestureIntent/slot``):
Expand All @@ -103,8 +135,7 @@ import CmuxNextCompat
func reserve(connection: Int, intent: AgentPaneGestureIntent, paid: Bool) -> String? {
guard paid || consume() else { return nil }
let at = now()
tickets = tickets.filter { at - $0.value.at <= $0.value.intent.lifetime
&& !($0.value.connection == connection && $0.value.intent.slot == intent.slot) }
dropTickets { !$1.isFresh(at) || ($1.connection == connection && $1.intent.slot == intent.slot) }
let ticket = UUID().uuidString
tickets[ticket] = Ticket(connection: connection, intent: intent, at: at)
lastTicket = ticket
Expand All @@ -114,19 +145,76 @@ import CmuxNextCompat
/// Spends `ticket` (even when it does not match) and says whether it allows this frame: the
/// same connection, within its lifetime, and the frame is its pick.
public func redeem(_ ticket: String, connection: Int, method: String?, params: [String: Any]) -> Bool {
redeem(ticket, connection: connection, pick: AgentPaneGesturePick(method: method, params: params))
redeem(ticket, connection: connection, pick: AgentPaneGesturePick(method: method, params: params),
sessionId: params["sessionId"] as? String)
}

/// Spends `ticket`; true when it was this connection's, still fresh, and for exactly `pick`.
public func redeem(_ ticket: String, connection: Int, pick: AgentPaneGesturePick?) -> Bool {
/// Spends `ticket`; true when it was this connection's, still fresh, for exactly `pick`, for
/// `sessionId` when it is bound to a chat, and no upload holds it any more. A prompt ticket bound
/// to no chat is a prompt held for the trust answer or behind a harness switch, whose chat may
/// not exist yet when it is held; it never held an upload (``holdForUpload``), so no file goes
/// with it to another chat (and a chat's uploads resolve only in that chat).
public func redeem(_ ticket: String, connection: Int, pick: AgentPaneGesturePick?, sessionId: String? = nil) -> Bool {
if let seed = redeemSeed(ticket, pick: pick) { return seed }
guard let held = tickets.removeValue(forKey: ticket) else { return false }
return held.connection == connection && now() - held.at <= held.intent.lifetime && held.intent.matches(pick)
guard let held = tickets[ticket] else { return false }
dropTickets { key, _ in key == ticket }
return held.connection == connection && held.isFresh(now()) && held.intent.matches(pick) && held.uploads.isEmpty
&& held.intent.sessionId.map { $0 == sessionId } != false
}

/// Drops every ticket (a reconnect, the end of a harness switch, the page's release).
/// A seeded prompt's ticket stays: it belongs to the pane's one seed, not to a connection.
public func clearTickets() { tickets.removeAll() }
public func clearTickets() { dropTickets { _, _ in true } }

/// Drops the tickets of `connection` (it closed).
public func clearTickets(connection: Int) { dropTickets { $1.connection == connection } }

// MARK: A send ticket held by its uploads

/// `uploads.begin`'s check: `ticket` is the send ticket the person's Send reserved on this
/// `connection` for exactly `promptId` in chat `sessionId` (``AgentPaneGestureIntent/sessionId``),
/// still fresh. Then `upload` holds it: the ticket's clock stands still while any of its
/// uploads is in progress, so a file of any size still sends. Not spent; false refuses the upload.
public func holdForUpload(_ ticket: String, upload: String, connection: Int, sessionId: String, promptId: String) -> Bool {
let at = now()
dropTickets { !$1.isFresh(at) }
guard var held = tickets[ticket], held.connection == connection, held.intent.method == "session/prompt",
held.intent.sessionId == sessionId, held.intent.params == ["promptId": .string(promptId)],
!holdsTicket(upload: upload) else { return false }
if held.uploads.isEmpty { held.stoppedAt = at }
held.uploads[upload] = at
tickets[ticket] = held
return true
}

/// An upload holding a send ticket made progress (a chunk was acknowledged). False when its
/// hold ended: the uploader stops.
public func uploadProgressed(_ upload: String) -> Bool {
let at = now()
dropTickets { !$1.isFresh(at) }
guard let key = tickets.first(where: { $0.value.uploads[upload] != nil })?.key else { return false }
tickets[key]?.uploads[upload] = at
return true
}

/// An upload holding a send ticket finished or was cancelled: its hold ends, and when it was
/// the last, the ticket's clock runs again from where it stopped. False when it held none.
@discardableResult
public func releaseUpload(_ upload: String) -> Bool {
let at = now()
dropTickets { !$1.isFresh(at) }
guard let key = tickets.first(where: { $0.value.uploads[upload] != nil })?.key, var held = tickets[key] else { return false }
held.uploads.removeValue(forKey: upload)
if held.uploads.isEmpty, let stopped = held.stoppedAt {
held.at += at - stopped
held.stoppedAt = nil
}
tickets[key] = held
return true
}

/// Whether `upload` holds a send ticket.
public func holdsTicket(upload: String) -> Bool { tickets.values.contains { $0.uploads[upload] != nil } }

// MARK: A seeded prompt's ticket (cx-k21v gap 4)

Expand Down
Loading
Loading