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
@@ -1,4 +1,5 @@
import CmuxNextAgentPane
import CmuxNextCloud
import CmuxNextDaemon
import Foundation

Expand All @@ -22,9 +23,16 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding {
/// The machine's daemon says whether a keyed acpmux restart loses nothing
/// (`agent-session-start-keyed-reason-v1`, cx-0w0jj).
var servesKeyedReason = false
/// A Cloud machine's link owner answer (`CloudMachineLink.ownerLink`), read at each trust call
/// A Cloud machine's link owner answer (``ownerLink(appLink:link:)``), read at each trust call
/// and never kept. Nil for a machine that is not a Cloud machine.
var ownerLink: AgentSessionAttachClient.OwnerLink?

/// The owner answer of the link a Cloud machine's calls go over: the app link's own
/// (``CloudLinkSession/ownerLink``) or the legacy link's (``CloudMachineLink/ownerLink``).
static func ownerLink(appLink: CloudLinkSession?, link: CloudMachineLink?) -> AgentSessionAttachClient.OwnerLink {
if let appLink { return { await appLink.ownerLink } }
return { await link?.ownerLink }
}
/// The keyed restart of the machine's acpmux (an SSH machine, or a paired server on the ssh
/// route); nil where the app has no exec channel of its own (cx-0q7o).
var approvals: AgentPaneRemoteApprovals?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,8 @@ extension AgentTabStore {
services.cloud.auth.isSignedIn && services.cloud.configuration.linkSource == .legacy
// A Cloud machine (the app's own record, never the guest's) starts a chat or asks folder
// trust only on the Cloud API's owner answer on the link the call goes over, checked as
// it is sent, through any daemon (an app-server link has none: no owner word).
let cloudLink: CloudMachineLink?? = services.machines.session(daemon.machineID).map(\.link)
let ownerLink = cloudLink.map { link -> AgentSessionAttachClient.OwnerLink in { await link?.ownerLink } }
// it is sent, through any daemon (the legacy link's or the app link's own answer).
let ownerLink = services.machines.session(daemon.machineID).map { AgentTabRemoteHost.ownerLink(appLink: $0.appLink, link: $0.link) }
host.ownerLink = ownerLink
if started {
// The tab may still be provisional here: each handshake reads its store surface.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ public import Foundation

/// Adapter 1 (contract 2.3, C13b): the Cloud app server's
/// `cloud.machine.connect` answers `{machine, carrier, generation, state:
/// "up", socket}`; `socket` is the carrier's local socket in an owner-only
/// "up", socket, vm_kind?}`; `socket` is the carrier's local socket in an owner-only
/// directory, and each connection to it is one dial of the machine's daemon.
/// `vm_kind` is the Cloud API's owner answer for this connect: only the exact
/// value `owner` says the signed-in caller owns the machine.
/// The app connects only after ``CloudLinkSocketPolicy`` accepts the path.
public struct CloudConnectOpResolver: CloudLinkResolver {
public static let connectOp = "cloud.machine.connect"
Expand All @@ -30,6 +32,23 @@ public struct CloudConnectOpResolver: CloudLinkResolver {
var state: String
var generation: UInt64?
var socket: String?
/// `vm_kind` when it is a string; any other JSON type reads as absent (not the owner),
/// never as a refused connect.
var vmKind: String?

enum CodingKeys: String, CodingKey {
case machine, state, generation, socket
case vmKind = "vm_kind"
}

init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
machine = try c.decode(String.self, forKey: .machine)
state = try c.decode(String.self, forKey: .state)
generation = try c.decodeIfPresent(UInt64.self, forKey: .generation)
socket = try c.decodeIfPresent(String.self, forKey: .socket)
vmKind = try? c.decodeIfPresent(String.self, forKey: .vmKind)
}
}

public func open(_ key: CloudLinkKey, intent: String, origin: CloudLinkOrigin) async throws -> CloudLinkSocket {
Expand All @@ -50,7 +69,7 @@ public struct CloudConnectOpResolver: CloudLinkResolver {
guard answer.state == "up" else { throw CloudLinkError.notUp(state: answer.state) }
guard let socket = answer.socket, !socket.isEmpty else { throw CloudLinkError.invalidAnswer("no socket") }
try check(socket)
return CloudLinkSocket(key: key, path: socket, generation: answer.generation)
return CloudLinkSocket(key: key, path: socket, generation: answer.generation, callerOwnsVM: answer.vmKind == "owner")
}

/// `cloud.machine.disconnect` ends the carrier. A failure leaves nothing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ public struct CloudLinkSocket: Equatable, Sendable {
/// The carrier generation; orders `cloud.link.changed` events. Nil when
/// the source has none.
public let generation: UInt64?
/// This connect's answer said the signed-in caller owns the machine (`vm_kind: "owner"`, the
/// classic attach answer's value; cmux-platform#89). Only for this connect, never saved.
public let callerOwnsVM: Bool

public init(key: CloudLinkKey, path: String, generation: UInt64?) {
public init(key: CloudLinkKey, path: String, generation: UInt64?, callerOwnsVM: Bool = false) {
self.key = key
self.path = path
self.generation = generation
self.callerOwnsVM = callerOwnsVM
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ public actor CloudLinkSession {
}
}

/// The current connect's id while its link is up and its answer said the signed-in caller owns
/// the machine (``CloudLinkSocket/callerOwnsVM``), else nil. A chat start or a trust call reads
/// it before it connects and again as it sends: a different answer means the link ended or a
/// new connect asked the Cloud API again, so an owner answer never carries over to another link
/// (as ``CloudMachineLink/ownerLink`` on the legacy link).
public var ownerLink: Int? {
switch state {
case .ready(let socket), .attached(let socket): socket.callerOwnsVM ? Int(truncatingIfNeeded: attempt) : nil
case .idle, .connecting, .ended: nil
}
}

/// The daemon connection's endpoint: the ticket's socket, once. Throws
/// ``CloudLinkError`` otherwise; a current ticket's second call ends the
/// link, an old ticket changes nothing.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
@testable import CmuxNextApp
@testable import CmuxNextCloud
import Foundation
import Testing

/// cmux-platform#89, part 1: on the Cloud app server link, an app-started chat and a folder-trust
/// call carry the owner word only while that link's own `cloud.machine.connect` answer said the
/// signed-in caller owns the machine (`vm_kind: "owner"`, the classic attach answer's value). Before,
/// the app-link session gave no owner answer at all, so every start and trust there was refused
/// with `agent_session.host_unverified`.
@MainActor
struct CloudAppLinkOwnerAnswerTests {
private static let key = CloudLinkKey(machine: "vm_aaaaaaaaaaaaaaaaaaaa")

private static func answer(_ vmKind: String?) -> Data {
var object: [String: Any] = ["machine": key.machine, "state": "up", "generation": 3, "socket": "/tmp/cmux-link.sock"]
if let vmKind { object["vm_kind"] = vmKind }
return (try? JSONSerialization.data(withJSONObject: object)) ?? Data()
}

private static func resolver(_ vmKind: String?) -> CloudConnectOpResolver {
CloudConnectOpResolver(run: { _, _, _, _ in answer(vmKind) }, check: { _ in })
}

@Test func onlyAnExactOwnerValueMarksTheConnectAsTheCallersOwnMachine() async throws {
#expect(try await Self.resolver("owner").open(Self.key, intent: "i", origin: .user).callerOwnsVM)
for other in [nil, "", "Owner", " owner", "teammate", "team", "guest"] {
let socket = try await Self.resolver(other).open(Self.key, intent: "i", origin: .user)
#expect(!socket.callerOwnsVM, "vm_kind \(other ?? "absent")")
}
}

/// A `vm_kind` that is not a string (a server mid-rollout) is no owner answer, and the machine
/// still connects as it did before the field existed.
@Test(arguments: ["0", "true", "[\"owner\"]", "{\"kind\":\"owner\"}", "null"])
func aVMKindOfAnotherTypeIsNotTheOwnerAndStillConnects(raw: String) async throws {
let body = #"{"machine":"vm_aaaaaaaaaaaaaaaaaaaa","state":"up","generation":3,"socket":"/tmp/cmux-link.sock","vm_kind":"# + raw + "}"
let resolver = CloudConnectOpResolver(run: { _, _, _, _ in Data(body.utf8) }, check: { _ in })
let socket = try await resolver.open(Self.key, intent: "i", origin: .user)
#expect(socket.path == "/tmp/cmux-link.sock")
#expect(!socket.callerOwnsVM)
}

@Test func theOwnerAnswerHoldsOnlyWhileItsOwnConnectIsUp() async throws {
let link = CloudLinkSession(key: Self.key, resolver: Self.resolver("owner"))
#expect(await link.ownerLink == nil, "no connect yet")
let first = try await link.connect(origin: .user)
let id = try #require(await link.ownerLink)
_ = try await link.endpoint(first)
#expect(await link.ownerLink == id, "the attached connection keeps its connect's answer")

// A new connect is another link: a start that read the old id is refused.
_ = try await link.connect(origin: .user)
let second = try #require(await link.ownerLink)
#expect(second != id)

// A down, a revoke, a second endpoint call (the connection dropped) or a pause ends the
// answer with its link.
_ = await link.apply(CloudLinkChange(key: Self.key, state: .down, generation: 3, reason: "eof"))
#expect(await link.ownerLink == nil)
_ = try await link.connect(origin: .user)
#expect(await link.ownerLink != nil)
_ = await link.apply(CloudLinkChange(key: Self.key, state: .revoked, generation: 3, reason: "revoked"))
#expect(await link.ownerLink == nil)
let dropped = try await link.connect(origin: .user)
_ = try await link.endpoint(dropped)
_ = try? await link.endpoint(dropped)
#expect(await link.ownerLink == nil)
_ = try await link.connect(origin: .user)
#expect(await link.ownerLink != nil)
await link.end(reason: "paused")
#expect(await link.ownerLink == nil)
}

/// The closure the tab's chat start and trust calls read (AgentTabs+Wiring): the app link's own
/// answer. AgentSessionAttachClient sends the owner word only when its two reads (before the
/// connection, as the call goes out) give the same non-nil id (AgentSessionStartOwnerLinkTests),
/// so a reconnect or a revoke between them refuses the call.
@Test func theTabsOwnerClosureFollowsTheAppLinkAcrossAReconnectAndARevoke() async throws {
let appLink = CloudLinkSession(key: Self.key, resolver: Self.resolver("owner"))
let ownerLink = AgentTabRemoteHost.ownerLink(appLink: appLink, link: nil)
#expect(await ownerLink() == nil, "not connected: refused")
_ = try await appLink.connect(origin: .user)
let before = try #require(await ownerLink())
#expect(await ownerLink() == before, "the same link: the owner word goes out")
_ = try await appLink.connect(origin: .user)
#expect(await ownerLink() != before, "a reconnect between the reads: refused")
_ = await appLink.apply(CloudLinkChange(key: Self.key, state: .revoked, generation: 3, reason: "revoked"))
#expect(await ownerLink() == nil, "revoked: refused")

let team = CloudLinkSession(key: Self.key, resolver: Self.resolver("team"))
_ = try await team.connect(origin: .user)
#expect(await AgentTabRemoteHost.ownerLink(appLink: team, link: nil)() == nil, "a teammate: refused")
}

@Test func aConnectThatDoesNotSayOwnerGivesNoOwnerAnswer() async throws {
let link = CloudLinkSession(key: Self.key, resolver: Self.resolver(nil))
_ = try await link.connect(origin: .user)
#expect(await link.ownerLink == nil)
}
}
Loading