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 @@ -62,6 +62,10 @@ public actor CloudMachineLinkManager {
/// reconnects with this local fact and does not call the attach endpoint.
private var privateRoutes: [String: String] = [:]
private var privateAddressCandidates: [String: [String]] = [:]
/// The team that owns each machine, captured when its provider was
/// registered. Control-plane calls a link makes name this team, so a link
/// to another team's machine keeps working after the selected team changes.
private var ownerTeams: [String: String] = [:]
private var links: [String: CloudMachineLink] = [:]
private var connecting: [String: Task<CloudMachineLink.Connected, Error>] = [:]
private var browserProxies: [String: CloudBrowserProxyProcess] = [:]
Expand Down Expand Up @@ -150,6 +154,16 @@ public actor CloudMachineLinkManager {
privateRoutes[machineID] = "ws://\(host):1337/v1/link"
}

/// Records the team that owns `machineID`; nil clears it (selected team).
public func setOwnerTeam(_ teamID: String?, for machineID: String) {
ownerTeams[machineID] = teamID.flatMap { $0.isEmpty ? nil : $0 }
}

/// The owning team recorded for `machineID`, if any.
public func ownerTeam(for machineID: String) -> String? {
ownerTeams[machineID]
}

public func privateAddresses(for machineID: String) -> [String] {
privateAddressCandidates[machineID] ?? []
}
Expand Down Expand Up @@ -231,7 +245,8 @@ public actor CloudMachineLinkManager {
let endpoint = try await client.openCmuxRemote(
id: machineID,
deviceFingerprint: nil,
clientCapabilities: capabilities
clientCapabilities: capabilities,
teamID: self.ownerTeam(for: machineID)
)
session = endpoint.session
guard endpoint.trustedCarrier else {
Expand Down Expand Up @@ -368,7 +383,8 @@ public actor CloudMachineLinkManager {
let endpoint = try await client.openCmuxRemote(
id: machineID,
deviceFingerprint: nil,
clientCapabilities: self.resolvedClientCapabilities(clientURL: clientURL)
clientCapabilities: self.resolvedClientCapabilities(clientURL: clientURL),
teamID: self.ownerTeam(for: machineID)
)
guard endpoint.trustedCarrier else {
throw ManagerError.retryLater(String(
Expand Down Expand Up @@ -504,6 +520,7 @@ public actor CloudMachineLinkManager {
public func retainAddresses(machineIDs: Set<String>) {
privateRoutes = privateRoutes.filter { machineIDs.contains($0.key) }
privateAddressCandidates = privateAddressCandidates.filter { machineIDs.contains($0.key) }
ownerTeams = ownerTeams.filter { machineIDs.contains($0.key) }
}

/// Re-sends this Mac's theme to every connected machine (a Ghostty config reload
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

/// Classifies control-plane answers that mean the signed-in user can no longer
/// reach a Cloud machine.
///
/// A permanent loss ends automatic reconnects: retrying cannot succeed until
/// the user regains access, and a pane that keeps retrying would only show a
/// frozen frame. Transient failures (timeouts, 5xx, throttling, transport
/// errors) are never permanent.
///
/// ```swift
/// if CloudMachineAccessLoss(error: error) != nil {
/// provider.noteAccessLost()
/// }
/// ```
public enum CloudMachineAccessLoss: Equatable, Sendable {
/// The machine no longer exists for this user (`404 vm_not_found`).
case notFound
/// The user is no longer allowed to use the machine (`403`), including
/// removal from the machine's team.
case forbidden
/// The machine belongs to an owner the request is not authorized for
/// (`vm_owner_mismatch`, any status).
case ownerMismatch

/// Classifies `error`; nil when it is not a permanent access loss.
///
/// - Parameter error: An error thrown by ``VMClient``.
public init?(error: Error) {
guard case let VMClientError.httpStatus(status, body) = error else { return nil }
self.init(status: status, body: body)
}

/// Classifies an HTTP status and response body; nil when transient.
///
/// - Parameters:
/// - status: The HTTP status code.
/// - body: The response body, whose JSON `error` field carries the code.
public init?(status: Int, body: String) {
let code = Self.errorCode(body)
if code == "vm_owner_mismatch" {
self = .ownerMismatch
} else if status == 403 {
self = .forbidden
} else if status == 404, code == "vm_not_found" {
self = .notFound
} else {
return nil
}
}

private static func errorCode(_ body: String) -> String? {
guard let data = body.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = object["error"] as? String else { return nil }
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ extension VMClient {
/// - command: The command to execute on the VM.
/// - timeoutMs: The guest execution deadline in milliseconds.
/// - expectedTeamScope: Captured authorization scope that must remain current throughout the request.
/// - teamID: The team that owns the VM; nil uses the selected team.
/// - Returns: The command's exit code, standard output, and standard error.
/// - Throws: An authorization, transport, or response error if execution cannot complete.
public func exec(id: String, command: String, timeoutMs: Int = 30_000, expectedTeamScope: AuthenticatedTeamScope? = nil) async throws -> VMExecResult {
public func exec(id: String, command: String, timeoutMs: Int = 30_000, expectedTeamScope: AuthenticatedTeamScope? = nil, teamID: String? = nil) async throws -> VMExecResult {
return try await withOperation(.exec, foreground: true) {
let body: [String: Any] = ["command": command, "timeoutMs": timeoutMs]
let encodedID = try pathSegment(id, fieldName: "vm id")
let (data, http) = try await request(
"POST",
path: "/api/vm/\(encodedID)/exec",
jsonBody: body,
timeoutSeconds: max(1, Double(timeoutMs) / 1000.0 + 5.0), expectedTeamScope: expectedTeamScope
timeoutSeconds: max(1, Double(timeoutMs) / 1000.0 + 5.0), expectedTeamScope: expectedTeamScope,
teamID: teamID
)
try ensureOK(http, data: data)
let obj = try decodeJSONObject(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import Foundation

extension VMClient {
/// All callers share revisioned resource state, including CLI and sidebar reads.
public func stats(id: String) async throws -> VMStats {
/// `teamID` names the machine's owning team (nil: the selected team).
public func stats(id: String, teamID: String? = nil) async throws -> VMStats {
let read = await resourceStats.beginRead(machineID: id)
do {
let stats = try await withOperation(.stats, foreground: false) {
let encodedID = try pathSegment(id, fieldName: "vm id")
let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/stats", timeoutSeconds: 30)
let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/stats", timeoutSeconds: 30, teamID: teamID)
try ensureOK(http, data: data)
return VMStats(json: try decodeJSONObject(data))
}
Expand Down
Loading
Loading