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
8 changes: 5 additions & 3 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import PackageDescription
// CmuxNextSettings -> Design, Actions (cmux.json load/watch/apply, SettingsSchema)
// CmuxNextSettingsWindow -> Settings, Design, Icons, Actions, Wakeups (Debug Settings, SwiftUI; Settings deep links and the string catalog the React Settings page reads)
// CmuxNextControl -> Actions, Settings, Daemon (app control socket; no UI; Compat/ forwards cmux CLI verbs to cmux-tui)
// CmuxNextCloud -> CMUXAuthCore, CmuxAuthRuntime (Stack auth, /api/vm REST,
// CmuxNextCloud -> CMUXAuthCore, CmuxAuthRuntime, CCmuxAppFFI (Stack auth, /api/vm REST, the Cloud start's Rust decisions,
// WireGuard hub and cmux-tui remote links; no UI, no daemon)
// CmuxNextRemote -> CmuxNextCloud (SSH machines: ssh argv, probe, install, relay policy; no UI, no daemon)
// CmuxNextMobile -> Daemon, CMUXMobileCore, CmuxIrxTransport (phone host; no UI)
Expand Down Expand Up @@ -174,8 +174,8 @@ let daemonSwiftSettings: [SwiftSetting] = [
/// when the FFI sources differ from the pinned source sha.
let appFFI: Target = .binaryTarget(
name: "CCmuxAppFFI",
url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-2b457e203eac4a8d1c5a5478f223241e4a08c1a2/CCmuxAppFFI.xcframework.zip",
checksum: "9115c825073718125519e5ae1e8c13c33cf74ed248cf67dadab0941ce511abb1"
url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-17b75935f08e6ab30b996a3724b92e882c866c6f/CCmuxAppFFI.xcframework.zip",
checksum: "883643bb1ddebcdb736e9e618b8dcad924ec4e95577479093a5b4c6ba01b95b5"
)

// Debug-only product consumed by Prototypes/UiLayerHost. Keeping this leaf
Expand Down Expand Up @@ -1223,6 +1223,8 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
// CloudMoveCLI runs cmux-tui through the daemon's ProcessRunner.
"CmuxNextDaemon",
"CmuxNextWakeups",
// CloudStartCore: the Cloud start's decisions (cmux-cloud-start-core).
"CCmuxAppFFI",
.product(name: "CMUXAuthCore", package: "CMUXAuthCore"),
.product(name: "CmuxInstallAuthCore", package: "CmuxInstallAuthCore"),
.product(name: "CmuxAuthRuntime", package: "CmuxAuthRuntime"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,22 @@ extension AgentPaneCloudStart {
/// /repo/project never uploads /repo, its siblings and its history).
/// The tab's own folder also never when it is the home folder or above it, by its canonical
/// path too (a tab in /Users would copy every home folder's credentials).
///
/// The rule is Rust's (cmux-cloud-start-core `start::uploads`); this reads the disk for it: the
/// canonical and standardized spellings, and the working copy's git folders only when asked.
public nonisolated func uploads(_ folder: CloudMoveFolder, home: String? = NSHomeDirectory()) -> Bool {
guard cwd != nil else {
guard folder.subpath.isEmpty else { return false }
func standard(_ path: String) -> String { (path as NSString).standardizingPath }
var paths: [String] = [], homes: [String] = []
if cwd == nil, folder.subpath.isEmpty {
let canonicalHome = home.flatMap(AgentHome.canonical) ?? home
for path in [folder.root, AgentHome.canonical(folder.root)].compactMap({ $0 })
where AgentHome.isHomeOrAbove(path, home: canonicalHome) || AgentHome.isHomeOrAbove(path, home: home) {
return false
}
return Self.gitFoldersAreInside(folder.root) // its git folders are copied too
paths = [folder.root, AgentHome.canonical(folder.root)].compactMap { $0 }.map(standard)
homes = [canonicalHome, home].compactMap { $0 }.map(standard)
}
switch CloudStartCore().uploads(cwdGiven: cwd != nil, uploadRoot: uploadRoot, root: folder.root, subpath: folder.subpath,
paths: paths, homes: homes) {
case .yes: return true
case .no: return false
case .ifGitFoldersInside: return Self.gitFoldersAreInside(folder.root) // its git folders are copied too
}
return uploadRoot == folder.root
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ final class CloudService {
/// harness login goes only to such a machine (``CloudCredentialTransfer``).
func callerOwnsVM(_ machineID: String) async throws -> Bool {
guard configuration.linkSource == .appServer else {
return try await api.pinningTeam(auth.teamID).attachEndpoint(machineID).vmKind == "owner"
return try await api.pinningTeam(auth.teamID).attachEndpoint(machineID).callerOwnsVM
}
guard let link = machines.session(machineID)?.appLink else { return false }
return await link.ownerLink != nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
/// account, else after one confirm that runs `setup-token` once in a visible local terminal. Every
/// later machine of the account then gets it with no prompt.
///
/// DEV and dogfood only (``CloudCredentialTransfer/passesHarnessLogin(isDogfoodBuild:setting:)``;
/// DEV and dogfood only (``CloudCredentialTransfer/passesHarnessLogin(isDogfoodBuild:defaults:)``;
/// Leo: "IN THE ACTUAL PRODUCTION APP IT MAY BE AGAINST TOS"). The relay alternative, which keeps
/// the token on the Mac, is scoped in cmux#29766.
///
Expand All @@ -21,38 +21,36 @@ import Foundation
/// Never blocks the chat: a machine that is not the person's, a cancelled confirm or a failed install
/// opens the chat anyway, and its turn card still offers recovery (#26148).
extension CloudStartChat {
/// This process's answer to ``CloudCredentialTransfer/passesHarnessLogin(isDogfoodBuild:setting:)``.
/// This process's answer to ``CloudCredentialTransfer/passesHarnessLogin(isDogfoodBuild:defaults:)``.
nonisolated static var passesHarnessLoginHere: Bool {
CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: DevTools.isEnabled, defaults: .standard)
}

/// Gives the start's machine the person's Claude login when this build passes it. Returns
/// whether a login was installed; never throws. `cmux cloud start-session` passes it the same way.
func passHarnessLogin(harness: String?, machine: CloudMachineSession) async -> Bool {
let cloud = services.cloud, provider = CloudHarnessLoginDecision.loginProvider(harness: harness), userID = cloud.auth.user?.id
let cloud = services.cloud, userID = cloud.auth.user?.id
let teamID = cloud.auth.teamID, machineID = machine.machineID
let api = cloud.machineAPI.pinningTeam(teamID)
var decision = CloudHarnessLoginDecision(gate: Self.passesHarnessLoginHere, provider: provider, signedIn: userID != nil)
var decision = CloudHarnessLoginDecision(dogfood: DevTools.isEnabled, setting: CloudCredentialTransfer.passHarnessLoginSetting(.standard),
harness: harness, signedIn: userID != nil)
// Never silent (CloudHarnessLoginDecision): the reason goes to the log and the machine's `cloud.machines` row.
defer {
machine.harnessLogin = decision
cloud.logger.info("cloud start chat: harness login \(decision.summary, privacy: .public)")
}
do {
if decision.gate, provider == .claude, let userID {
if decision.gate, decision.provider == .claude, let userID {
decision.callerOwns = try await cloud.callerOwnsVM(machineID)
if decision.readsMachine {
let kind = try? await api.exec(machineID, command: CloudHarnessLoginDecision.guestKindProbe, timeoutMs: 20_000)
decision.guestKind = kind.flatMap { CloudHarnessLoginDecision.guestKind(stdout: $0.stdout) }
decision.guestStdout = (try? await api.exec(machineID, command: decision.verdict.guestKindProbe, timeoutMs: 20_000))?.stdout
}
// A login the provider rejects (a reused machine's expired token) is replaced like a missing one.
if decision.guestTakesLogin {
let read = await CloudMachineLogin.read(on: api, machineID: machineID, teamID: teamID, userID: userID)
(decision.machineLogin, decision.cached) = (read.login, read.cached)
}
if decision.guestTakesLogin { decision.take(await CloudMachineLogin.read(on: api, machineID: machineID, teamID: teamID, userID: userID)) }
}
let verdict = decision.verdict, provider = decision.provider
guard let provider, let userID else { return false }
switch decision.step {
switch verdict.step {
case .skip: return false
case .install: break
case .confirmThenInstall:
Expand All @@ -63,7 +61,7 @@ extension CloudStartChat {
}
guard cloud.auth.user?.id == userID, cloud.auth.teamID == teamID else { return false }
let transfer = CloudCredentialTransfer(provider: provider, machineID: machineID, teamID: teamID, userID: userID,
allowsCreatorTeamGuest: decision.guestKind == "team", openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false })
allowsCreatorTeamGuest: verdict.guestKind == "team", openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false })
try await transfer.run(api: cloud.machineAPI, currentTeamID: { cloud.auth.teamID }, currentUserID: { cloud.auth.user?.id },
confirmed: true, callerOwnsVM: { try await cloud.callerOwnsVM(machineID) })
decision.outcome = "installed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension CloudStartChat {
/// The account a send began in as cmux-tui keeps its sends: its user and team. A team switch keeps
/// the user signed in, so a send of the previous team never names this team's machine.
static func accountText(_ account: [String?]) -> String {
account.map { $0 ?? "" }.joined(separator: "/")
CloudStartCore().accountText(account)
}

/// The plan's request: the send's key, account and Mac workspace, who is signed in, the machines the account's list named (as the pick
Expand Down Expand Up @@ -61,9 +61,9 @@ extension CloudStartChat {
/// the server's answer for the account signed in now (`listCurrent`): after a team switch the
/// previous team's machines stay in the store until that list lands, and the chat's folder must
/// never be copied to one of them.
static func checkAccount(_ asked: [String?], now: [String?], listCurrent: Bool) throws {
guard asked == now else { throw CloudMove.Failure(message: CloudMoveStrings.startChatAccountChanged) }
guard listCurrent else { throw CloudMove.Failure(message: CloudMoveStrings.startChatListUnread) }
static func checkAccount(_ asked: [String?], now: [String?], listCurrent: Bool) throws { // the rule: cmux-cloud-start-core `start`
guard let check = CloudStartCore().checkAccount(asked, now: now, listCurrent: listCurrent) else { return }
throw CloudMove.Failure(message: check == .accountChanged ? CloudMoveStrings.startChatAccountChanged : CloudMoveStrings.startChatListUnread)
}

/// The machine for the send `start`, connected, and the plan's keys. Only a machine of the list
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,8 @@ struct CloudStartChat {
signedIn: services.cloud.isSignedIn)
}

nonisolated static func refusal(cloudDisabled: Bool, signedIn: Bool) -> String? {
if cloudDisabled { return "cloud_disabled" }
return signedIn ? nil : "not_signed_in"
nonisolated static func refusal(cloudDisabled: Bool, signedIn: Bool) -> String? { // the rule: cmux-cloud-start-core `start`
CloudStartCore().refusal(cloudDisabled: cloudDisabled, signedIn: signedIn)
}

/// Keep the stable refusal code for bridge callers, but give the Cloud
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,12 @@ public struct CloudAttachEndpoint: Sendable, Hashable, Decodable {
public var invitation: Invitation?
/// Only the exact `"owner"`: the link then starts the machine's remote chains at the VM
/// boundary (acpmux `hub/vm_boundary.rs`). Missing, null or any other string keeps them
/// sandboxed; a value of another type fails the attach.
public var callerOwnsVM: Bool { vmKind == "owner" }
/// sandboxed; a value of another type fails the attach. The word's rules are Rust's
/// (cmux-cloud-start-core `vm_kind`).
public var callerOwnsVM: Bool { CloudStartCore().vmKind(vmKind).ownsVm }
/// Only the exact `"team"`: the Cloud API says the machine is a teammate's, so its owner starts
/// chats there. Grants nothing; it only names the refusal (``CloudMachineLink/callerIsTeammate``).
public var callerIsTeammate: Bool { vmKind == "team" }
public var callerIsTeammate: Bool { CloudStartCore().vmKind(vmKind).teammate }
}

public struct CloudEnrollmentApproval: Sendable, Hashable, Decodable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ nonisolated public struct CloudCodexBrokerTarget: Sendable {
isLive: (@Sendable () async throws -> Bool)? = nil) {
let api = api.pinningTeam(scope.teamID)
// Guest files are controlled by the VM and cannot authorize token delivery.
let callerOwnsVM = callerOwnsVM ?? { try await api.attachEndpoint(machineID).vmKind == "owner" }
let callerOwnsVM = callerOwnsVM ?? { try await api.attachEndpoint(machineID).callerOwnsVM }
self.init(machineID: machineID, capability: {
guard try await callerOwnsVM() else {
return Capability(ownerVM: false, protocolVersion: 0, codexExternalAccessToken: false)
Expand Down
107 changes: 107 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextCloud/CloudStartCore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import CCmuxLayoutReducerFFI
public import Foundation

/// The NEXT Cloud start's decisions, asked of Rust (cmux-tui/crates/cmux-cloud-start-core, with
/// their tests) over the app FFI's JSON channel (`cmux_layout_reducer_json`, ops `cloud_*`). The
/// app reads the facts (the policy, the account, the paths, the machine) and acts on the answer;
/// the rules stay in Rust (Leo, 2026-11-02: Cloud logic and its tests belong in Rust, not Swift).
public nonisolated struct CloudStartCore: Sendable {
public init() {}

/// One JSON call of the app FFI; nil when it does not answer.
public func call<Request: Encodable, Reply: Decodable>(_ operation: String, _ request: Request, as: Reply.Type) -> Reply? {
guard let data = try? JSONEncoder().encode(request) else { return nil }
var capacity = 4096
for _ in 0..<2 {
var output = [UInt8](repeating: 0, count: capacity); var outputLength = 0
let code: Int32 = data.withUnsafeBytes { requestBytes in operation.withCString { operationCString in output.withUnsafeMutableBytes { outputBytes in cmux_layout_reducer_json(requestBytes.bindMemory(to: UInt8.self).baseAddress, data.count, operationCString, outputBytes.bindMemory(to: UInt8.self).baseAddress, outputBytes.count, &outputLength) } } }
if code == CMUX_LAYOUT_REDUCER_ERR_BUFFER, outputLength > capacity {
capacity = outputLength
continue
}
guard code == CMUX_LAYOUT_REDUCER_OK, outputLength <= capacity else { return nil }
return try? JSONDecoder().decode(Reply.self, from: Data(output.prefix(outputLength)))
}
return nil
}

// MARK: The start's own checks (`start`)

/// Why no Cloud chat can start at all now (`cloud_disabled`, `not_signed_in`), else nil. An FFI
/// that does not answer refuses as Cloud off, never a start that goes on unchecked.
public func refusal(cloudDisabled: Bool, signedIn: Bool) -> String? {
struct Request: Encodable { var cloudDisabled: Bool, signedIn: Bool }
struct Reply: Decodable { var refusal: String? }
guard let reply = call("cloud_start_refusal", Request(cloudDisabled: cloudDisabled, signedIn: signedIn), as: Reply.self) else {
return "cloud_disabled"
}
return reply.refusal
}

/// The account a send began in as cmux-tui keeps its sends: its user and team, `/`-joined.
public func accountText(_ account: [String?]) -> String {
struct Request: Encodable { var account: [String?] }
struct Reply: Decodable { var text: String }
// The FFI not answering keeps the same spelling, so a send is never filed under another account.
return call("cloud_start_account_text", Request(account: account), as: Reply.self)?.text
?? account.map { $0 ?? "" }.joined(separator: "/")
}

/// Why a send copies or opens nothing now.
public enum AccountCheck: String, Sendable, Equatable, Decodable {
case accountChanged = "account-changed"
case listUnread = "list-unread"
}

/// Nil while a send that began in `asked` may go on with `now` signed in and its list current
/// (`listCurrent`). An FFI that does not answer stops the send as a list not read.
public func checkAccount(_ asked: [String?], now: [String?], listCurrent: Bool) -> AccountCheck? {
struct Request: Encodable { var asked: [String?], now: [String?], listCurrent: Bool }
struct Reply: Decodable { var check: AccountCheck? }
guard let reply = call("cloud_start_check_account", Request(asked: asked, now: now, listCurrent: listCurrent), as: Reply.self) else {
return .listUnread
}
return reply.check
}

/// Whether `root` is the disk's root or the home folder `home` (each as `standardizedFileURL`
/// spells it): never copied to a machine. An FFI that does not answer counts it too broad.
public func isTooBroad(root: String, home: String) -> Bool {
struct Request: Encodable { var root: String, home: String }
struct Reply: Decodable { var tooBroad: Bool }
return call("cloud_folder_too_broad", Request(root: root, home: home), as: Reply.self)?.tooBroad ?? true
}

/// Whether a start may copy a folder.
public enum Uploads: String, Sendable, Equatable, Decodable {
case yes, no
/// Only when every git folder of the root is inside it: the caller reads the disk for that.
case ifGitFoldersInside = "if-git-folders-inside"
}

/// What a start may copy. `paths`: the root and its canonical path; `homes`: the home folder's
/// canonical path and the home folder; each standardized (`standardizingPath`). An FFI that does
/// not answer copies nothing.
public func uploads(cwdGiven: Bool, uploadRoot: String?, root: String, subpath: String, paths: [String], homes: [String]) -> Uploads {
struct Request: Encodable { var cwdGiven: Bool, uploadRoot: String?, root: String, subpath: String, paths: [String], homes: [String] }
struct Reply: Decodable { var uploads: Uploads }
let request = Request(cwdGiven: cwdGiven, uploadRoot: uploadRoot, root: root, subpath: subpath, paths: paths, homes: homes)
return call("cloud_start_uploads", request, as: Reply.self)?.uploads ?? .no
}

// MARK: The `vmKind` word (`vm_kind`)

/// What the Cloud API's caller-relative `vmKind` word grants. Only the exact `owner` owns the
/// machine and starts its link at the VM boundary; only the exact `team` names a teammate's.
public struct VmKind: Sendable, Equatable, Decodable {
public var ownsVm: Bool
public var teammate: Bool
public var linkArguments: [String]
}

/// The meaning of `vmKind`; an FFI that does not answer grants nothing (the sandbox stays).
public func vmKind(_ vmKind: String?) -> VmKind {
struct Request: Encodable { var vmKind: String? }
return call("cloud_vm_kind", Request(vmKind: vmKind), as: VmKind.self) ?? VmKind(ownsVm: false, teammate: false, linkArguments: [])
}
}
Loading
Loading