From 2e8856c515e8fab0c316d6108cbd0202bb319c68 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 07:59:06 -0800 Subject: [PATCH] CmuxNext Cloud: the start's checks, the vm-kind word, the machine login reading and the harness login call Rust; the Swift copies and their tests go (step B) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-548f18c0a6d1ebb48240 --- Packages/macOS/CmuxNext/Package.swift | 8 +- .../AgentPaneCloudStart+Cloud.swift | 22 ++-- .../CmuxNextApp/Cloud/CloudService.swift | 2 +- .../Cloud/CloudStartChat+Login.swift | 24 ++-- .../Cloud/CloudStartChat+Machine.swift | 8 +- .../CmuxNextApp/Cloud/CloudStartChat.swift | 5 +- .../CmuxNextCloud/API/CloudMachine.swift | 7 +- .../Broker/CloudCodexBrokerTarget.swift | 2 +- .../CmuxNextCloud/CloudStartCore.swift | 107 ++++++++++++++++ .../Link/CloudConnectOpResolver.swift | 5 +- .../CmuxNextCloud/Link/CloudMachineLink.swift | 2 +- .../CmuxNextCloud/Move/CloudMoveFolder.swift | 7 +- .../CloudCredentialTransfer+Switch.swift | 15 ++- .../CloudHarnessLoginDecision.swift | 114 +++++++++--------- .../CloudMachineLogin.swift | 39 +++--- .../CloudMachineLoginTests.swift | 29 ++--- .../CloudStartChatAppLinkTests.swift | 7 -- .../CloudStartChatLoginTests.swift | 34 +++--- .../CloudStartChatMachineTests.swift | 63 +++------- .../CmuxNextAppTests/CloudVmKindTests.swift | 22 ++-- 20 files changed, 290 insertions(+), 232 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextCloud/CloudStartCore.swift diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 63c4a22ef816..5f35a15f6948 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -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) @@ -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 @@ -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"), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudStart+Cloud.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudStart+Cloud.swift index e218556cdcfd..9c4a8f0e1214 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudStart+Cloud.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCloudStart+Cloud.swift @@ -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 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 4ab54c1b8fd2..94c8da9e7245 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift index 0cacfeaed28a..d58798477ea3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift @@ -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. /// @@ -21,7 +21,7 @@ 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) } @@ -29,30 +29,28 @@ extension CloudStartChat { /// 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: @@ -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" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Machine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Machine.swift index 8d1c9b34646f..5119feccf4ac 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Machine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Machine.swift @@ -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 @@ -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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift index 95891faa77b4..d559e251e042 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift @@ -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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift index 218af9ccd618..b04f6354a9de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine.swift @@ -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 { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Broker/CloudCodexBrokerTarget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Broker/CloudCodexBrokerTarget.swift index e45f0b46d5da..ee36231dd36a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Broker/CloudCodexBrokerTarget.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Broker/CloudCodexBrokerTarget.swift @@ -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) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/CloudStartCore.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/CloudStartCore.swift new file mode 100644 index 000000000000..d9726482fe1c --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/CloudStartCore.swift @@ -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(_ 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: []) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudConnectOpResolver.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudConnectOpResolver.swift index 97fd6a7e0af2..1feb9f12d0d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudConnectOpResolver.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudConnectOpResolver.swift @@ -69,8 +69,9 @@ 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, callerOwnsVM: answer.vmKind == "owner", - callerIsTeammate: answer.vmKind == "team") + let kind = CloudStartCore().vmKind(answer.vmKind) // the word's rules are Rust's (cmux-cloud-start-core `vm_kind`) + return CloudLinkSocket(key: key, path: socket, generation: answer.generation, callerOwnsVM: kind.ownsVm, + callerIsTeammate: kind.teammate) } /// `cloud.machine.disconnect` ends the carrier. A failure leaves nothing diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudMachineLink.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudMachineLink.swift index 22c10eca1168..a63a0522ee00 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudMachineLink.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Link/CloudMachineLink.swift @@ -125,7 +125,7 @@ public actor CloudMachineLink { /// never saved: the link's acpmux peer bridge leases the machine at the VM boundary only for /// this link (cmux-tui `remote_cli/acpmux_peer.rs`). public static func vmKindArguments(_ endpoint: CloudAttachEndpoint) -> [String] { - endpoint.callerOwnsVM ? ["--vm-kind", "owner"] : [] + CloudStartCore().vmKind(endpoint.vmKind).linkArguments } /// Whether the running link's attach answer said the signed-in caller owns the machine diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudMoveFolder.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudMoveFolder.swift index 51f1bc594756..e2253e5d9bec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudMoveFolder.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudMoveFolder.swift @@ -44,10 +44,11 @@ public nonisolated struct CloudMoveFolder: Sendable, Equatable { return "/" + parts.joined(separator: "/") } - /// The home folder or the disk's root: never copied to a machine. + /// The home folder or the disk's root: never copied to a machine. The rule is Rust's + /// (cmux-cloud-start-core `start::too_broad`); this spells both paths as Foundation standardizes them. public var isTooBroad: Bool { - let path = URL(fileURLWithPath: root).standardizedFileURL.path - return path == "/" || path == URL(fileURLWithPath: NSHomeDirectory()).standardizedFileURL.path + CloudStartCore().isTooBroad(root: URL(fileURLWithPath: root).standardizedFileURL.path, + home: URL(fileURLWithPath: NSHomeDirectory()).standardizedFileURL.path) } /// The folder's name on a machine: its own name and a short hash of where it diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer+Switch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer+Switch.swift index ee6cec4a5372..367da8fe3603 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer+Switch.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer+Switch.swift @@ -1,20 +1,23 @@ public import Foundation extension CloudCredentialTransfer { - /// The defaults key of ``passesHarnessLogin(isDogfoodBuild:setting:)``. + /// The defaults key of ``passesHarnessLogin(isDogfoodBuild:defaults:)``. public nonisolated static let passHarnessLoginKey = "cmux.cloud.passHarnessLogin" + /// The `cmux.cloud.passHarnessLogin` setting in `defaults`; nil when unset. + public nonisolated static func passHarnessLoginSetting(_ defaults: UserDefaults) -> Bool? { + defaults.object(forKey: passHarnessLoginKey) as? Bool + } + /// The one switch for every Mac-to-machine login transfer: `cloud.startChat`'s pass, the turn /// card's recovery and the new-workspace setup, Claude's `setup-token` and the Codex broker alike. /// DEV and dogfood only (Leo: "IN THE ACTUAL PRODUCTION APP IT MAY BE AGAINST TOS"). The /// `cmux.cloud.passHarnessLogin` default wins when set (`-bool NO` makes a DEV build behave as /// production); unset, it is on in a dogfood build (the app passes `DevTools.isEnabled`: a Debug /// compile or a NIGHTLY bundle, the NEXT nightly included) and off in release, where the machine - /// signs in on its own instead. - public nonisolated static func passesHarnessLogin(isDogfoodBuild: Bool, setting: Bool?) -> Bool { setting ?? isDogfoodBuild } - - /// ``passesHarnessLogin(isDogfoodBuild:setting:)`` with the setting read from `defaults`. + /// signs in on its own instead. The precedence is Rust's (cmux-cloud-start-core + /// `passes_harness_login`); an FFI that does not answer passes no login. public nonisolated static func passesHarnessLogin(isDogfoodBuild: Bool, defaults: UserDefaults) -> Bool { - passesHarnessLogin(isDogfoodBuild: isDogfoodBuild, setting: defaults.object(forKey: passHarnessLoginKey) as? Bool) + CloudHarnessLoginDecision(dogfood: isDogfoodBuild, setting: passHarnessLoginSetting(defaults), harness: nil, signedIn: false).gate } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudHarnessLoginDecision.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudHarnessLoginDecision.swift index 40336bfb974c..08ea7d84d1a6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudHarnessLoginDecision.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudHarnessLoginDecision.swift @@ -1,4 +1,4 @@ -import CCmuxLayoutReducerFFI +public import CmuxNextCloud public import Foundation /// What a `cloud.startChat` start does about its machine's harness login, and why (the App's @@ -8,8 +8,10 @@ public import Foundation /// `cloud.machines` row (`harness_login`), never a silent skip. /// /// The rules are Rust's (cmux-tui/crates/cmux-cloud-start-core `harness_login`, with their tests), -/// asked over the app FFI (`cloud_harness_login*`); this type holds what the start knows so far and -/// reads the decision back. +/// asked in one app FFI call (`cloud_harness_login`) that reads the raw facts this type holds: the +/// build and its setting, the chat's harness, the guest probe's output and the machine login probe's +/// output. So an FFI that does not answer is the one failure, recorded as ``Reason/ffiFailed``, never +/// an ordinary skip reason (cmux#32416). public nonisolated struct CloudHarnessLoginDecision: Sendable, Equatable { public enum Step: String, Sendable, Equatable, Decodable { case skip, install, confirmThenInstall = "confirm-then-install" } @@ -28,28 +30,55 @@ public nonisolated struct CloudHarnessLoginDecision: Sendable, Equatable { case ffiFailed = "ffi-failed" } - public var gate: Bool - public var provider: CloudCredentialTransfer.Provider? + /// What the machine's login probe answered (``CloudMachineLogin/probe(cachedFingerprint:endpoint:)``). + public struct MachineProbe: Sendable, Equatable, Encodable { + public var exitCode: Int + public var stdout: String + + public init(exitCode: Int, stdout: String) { + (self.exitCode, self.stdout) = (exitCode, stdout) + } + } + + /// A DEV or dogfood build (the app passes `DevTools.isEnabled`). + public var dogfood: Bool + /// The `cmux.cloud.passHarnessLogin` setting; nil when unset. + public var setting: Bool? + /// The chat's harness; nil for none named (the app's default agent, Claude). + public var harness: String? public var signedIn: Bool /// Nil when not asked (an earlier check decided). public var callerOwns: Bool? - /// The guest's `/etc/cmux/vm-kind` ("" when missing); nil when not read or the read failed. - public var guestKind: String? + /// The guest probe's stdout (``Verdict/guestKindProbe``); nil when not run or the exec failed. + public var guestStdout: String? + /// The machine login probe's answer; nil when not run. + public var machineProbe: MachineProbe? + /// The machine's login when known without the probe (an exec that failed reads as `.none`). public var machineLogin: CloudMachineLogin? public var cached: Bool /// What came of it once decided: "installed", "declined" (the confirm), or the transfer's fixed failure text. public var outcome: String? - public init(gate: Bool, provider: CloudCredentialTransfer.Provider?, signedIn: Bool, callerOwns: Bool? = nil, - guestKind: String? = nil, machineLogin: CloudMachineLogin? = nil, cached: Bool = false) { - (self.gate, self.provider, self.signedIn, self.callerOwns) = (gate, provider, signedIn, callerOwns) - (self.guestKind, self.machineLogin, self.cached) = (guestKind, machineLogin, cached) + public init(dogfood: Bool, setting: Bool?, harness: String?, signedIn: Bool) { + (self.dogfood, self.setting, self.harness, self.signedIn) = (dogfood, setting, harness, signedIn) + cached = false } - /// The rules' answer for these inputs. + /// Takes ``CloudMachineLogin/read(on:machineID:teamID:userID:)``'s answer: an exec that failed reads + /// as `.none`, as the probe before it did. + public mutating func take(_ read: (probe: MachineProbe?, cached: Bool)) { + (machineProbe, machineLogin, cached) = (read.probe, read.probe == nil ? CloudMachineLogin.none : nil, read.cached) + } + + /// The rules' answer for these inputs, with the facts resolved. public struct Verdict: Sendable, Equatable, Decodable { public var step: Step public var reason: Reason + public var gate: Bool + public var provider: String? + public var guestKind: String? + public var machineLogin: CloudMachineLogin? + public var guestKindProbe: String public var readsMachine: Bool public var guestTakesLogin: Bool public var skippedTeamMachine: Bool @@ -57,21 +86,30 @@ public nonisolated struct CloudHarnessLoginDecision: Sendable, Equatable { } private struct Request: Encodable { - var gate: Bool, provider: String?, signedIn: Bool, callerOwns: Bool?, guestKind: String? - var machineLogin: String?, cached: Bool, outcome: String? + var dogfood: Bool, setting: Bool?, harness: String, signedIn: Bool, callerOwns: Bool?, guestStdout: String? + var machineProbe: MachineProbe?, machineLogin: String?, cached: Bool, outcome: String? } /// Asks the rules once; read every field off the one answer. public var verdict: Verdict { - let request = Request(gate: gate, provider: provider?.rawValue, signedIn: signedIn, callerOwns: callerOwns, guestKind: guestKind, - machineLogin: machineLogin?.rawValue, cached: cached, outcome: outcome) - return Self.call("cloud_harness_login", request, as: Verdict.self) ?? Verdict( - step: .skip, reason: .ffiFailed, readsMachine: false, guestTakesLogin: false, skippedTeamMachine: false, + // "" is no harness named: the rules read it as the default agent, Claude. + let request = Request(dogfood: dogfood, setting: setting, harness: harness ?? "", signedIn: signedIn, callerOwns: callerOwns, + guestStdout: guestStdout, machineProbe: machineProbe, machineLogin: machineLogin?.rawValue, + cached: cached, outcome: outcome) + return CloudStartCore().call("cloud_harness_login", request, as: Verdict.self) ?? Verdict( + step: .skip, reason: .ffiFailed, gate: false, provider: nil, guestKind: nil, machineLogin: nil, guestKindProbe: "", + readsMachine: false, guestTakesLogin: false, skippedTeamMachine: false, summary: "skip (ffi-failed)" + (outcome.map { " -> \($0)" } ?? "")) } public var step: Step { verdict.step } public var reason: Reason { verdict.reason } + /// Whether this build passes a login (the setting when set, else only DEV and dogfood builds). + public var gate: Bool { verdict.gate } + /// The provider whose login the start passes; nil for a harness with no transfer (pi, opencode). + public var provider: CloudCredentialTransfer.Provider? { verdict.provider.flatMap(CloudCredentialTransfer.Provider.init(rawValue:)) } + /// The guest's `/etc/cmux/vm-kind` as read ("" when missing); nil when not read. + public var guestKind: String? { verdict.guestKind } /// The start passed no personal login because the machine is a team machine (handshake `cloudTeamMachine`). public var skippedTeamMachine: Bool { verdict.skippedTeamMachine } /// Whether the guest takes the caller's login (an `owner` or `team` guest of the caller's own machine). @@ -80,44 +118,4 @@ public nonisolated struct CloudHarnessLoginDecision: Sendable, Equatable { public var readsMachine: Bool { verdict.readsMachine } /// One line for the log and the `cloud.machines` row; no token, path or provider output. public var summary: String { verdict.summary } - - /// The provider whose login a start needs; nil for a harness that has no transfer (pi, opencode). - /// No harness named is the app's default agent, Claude. - public static func loginProvider(harness: String?) -> CloudCredentialTransfer.Provider? { - struct Request: Encodable { var harness: String? } - struct Reply: Decodable { var provider: String? } - return call("cloud_harness_login_provider", Request(harness: harness), as: Reply.self)?.provider - .flatMap(CloudCredentialTransfer.Provider.init(rawValue:)) - } - - /// Prints the guest's kind after a fixed marker, so an exec that ran is told from one that failed. - public static let guestKindProbe: String = { - struct Reply: Decodable { var probe: String } - // An empty probe prints nothing, which reads as an unread kind (`guest-kind-unread`). - return call("cloud_guest_kind_probe", [String: String](), as: Reply.self)?.probe ?? "" - }() - - /// The kind ``guestKindProbe`` printed ("" when the guest has none), or nil for output without the marker. - public static func guestKind(stdout: String) -> String? { - struct Request: Encodable { var stdout: String } - struct Reply: Decodable { var kind: String? } - return call("cloud_guest_kind", Request(stdout: stdout), as: Reply.self)?.kind - } - - /// One JSON call of the app FFI; nil when it does not answer. - private static func call(_ 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 - } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudMachineLogin.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudMachineLogin.swift index c63b47b52ac0..28359f5f522b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudMachineLogin.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudMachineLogin.swift @@ -1,5 +1,4 @@ public import CmuxNextCloud -import CryptoKit public import Foundation /// The Claude login a Cloud machine already holds, as a `cloud.startChat` start reads it before it @@ -14,7 +13,11 @@ public import Foundation /// A `/login` session (Claude refreshes it itself), the cached token already there, a provider that /// cannot be reached and any other answer leave the machine's login alone, so a start never loops on a /// login it just installed; a later rejection is the turn card's recovery (#26148). -public nonisolated enum CloudMachineLogin: String, Sendable, Equatable, CaseIterable { +/// +/// Reading the probe's word, the fingerprint and which logins are replaced are Rust's +/// (cmux-cloud-start-core `machine_login` and `harness_login`, with their tests); this type runs the +/// probe and hands its output to ``CloudHarnessLoginDecision``. +public nonisolated enum CloudMachineLogin: String, Sendable, Equatable, CaseIterable, Decodable { /// No own Claude sign-in on the machine (the image's `cmux_agent_has_own_login` says so). case none /// A `/login` session with a refresh token: Claude renews it itself, so it is never replaced. @@ -26,30 +29,28 @@ public nonisolated enum CloudMachineLogin: String, Sendable, Equatable, CaseIter /// Another token the provider answered 401 for: expired or revoked. case rejected - /// Whether a start gives the machine the account's login, replacing a rejected one. - public var needsLogin: Bool { self == .none || self == .rejected } - /// The provider endpoint the probe asks: an authenticated read that costs no tokens. static let endpoint = "https://api.anthropic.com/v1/models?limit=1" - /// Reads the machine's login (with `api` already pinned to the machine's team) and whether cmux holds - /// this account's login. Never throws: an exec that fails reads as `.none`, as the probe before it did. - public static func read(on api: CloudAPIClient, machineID: String, teamID: String?, userID: String) async -> (login: CloudMachineLogin, cached: Bool) { + /// Runs the probe on the machine (with `api` already pinned to the machine's team) and reads whether + /// cmux holds this account's login. Never throws: `probe` is nil for an exec that failed, which the + /// start reads as `.none`, as the probe before it did. + public static func read(on api: CloudAPIClient, machineID: String, teamID: String?, userID: String) async + -> (probe: CloudHarnessLoginDecision.MachineProbe?, cached: Bool) { let cached = (try? await CloudCredentialLogin.load(provider: .claude, teamID: teamID, userID: userID)) ?? nil - guard let result = try? await api.exec(machineID, command: probe(cachedFingerprint: cached.map(fingerprint)), timeoutMs: 30_000) else { - return (.none, cached != nil) + guard let result = try? await api.exec(machineID, command: probe(cachedFingerprint: cached.flatMap(fingerprint)), timeoutMs: 30_000) else { + return (nil, cached != nil) } - return (parse(exitCode: result.exitCode, stdout: result.stdout), cached != nil) - } - - /// The SHA-256 of a token, hex: what the probe compares on the machine, so no token is sent there to compare. - static func fingerprint(_ token: Data) -> String { - SHA256.hash(data: token).map { String(format: "%02x", $0) }.joined() + return (CloudHarnessLoginDecision.MachineProbe(exitCode: result.exitCode, stdout: result.stdout), cached != nil) } - /// The probe's one word. An unknown answer keeps today's reading of the exit code: 0 a login that stays. - static func parse(exitCode: Int, stdout: String) -> CloudMachineLogin { - Self(rawValue: stdout.trimmingCharacters(in: .whitespacesAndNewlines)) ?? (exitCode == 0 ? .working : .none) + /// The SHA-256 of a token, hex (cmux-cloud-start-core `machine_login::fingerprint`): what the probe + /// compares on the machine, so no token is sent there to compare. Nil when the FFI does not answer; + /// the probe then compares nothing, and a machine with cmux's own token reads as a login that stays. + static func fingerprint(_ token: Data) -> String? { + struct Request: Encodable { var token: [UInt8] } + struct Reply: Decodable { var fingerprint: String } + return CloudStartCore().call("cloud_login_fingerprint", Request(token: Array(token)), as: Reply.self)?.fingerprint } /// Prints one word of ``CloudMachineLogin``. The image's own detection first, with its config sourced as diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineLoginTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineLoginTests.swift index acfa284e0f38..d64f24d01541 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineLoginTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineLoginTests.swift @@ -10,24 +10,10 @@ struct CloudMachineLoginTests { static let token = "sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123456789" static let fresh = "sk-ant-oat01-0123456789zyxwvutsrqponmlkjihgfedcba" - @Test func onlyAMissingOrRejectedLoginIsReplaced() { - #expect(CloudMachineLogin.none.needsLogin) - #expect(CloudMachineLogin.rejected.needsLogin) - #expect(!CloudMachineLogin.session.needsLogin) - #expect(!CloudMachineLogin.cached.needsLogin) - #expect(!CloudMachineLogin.working.needsLogin) - } - - @Test func theProbesWordIsTheAnswerElseItsExitCode() { - for login in CloudMachineLogin.allCases { #expect(CloudMachineLogin.parse(exitCode: 0, stdout: login.rawValue + "\n") == login) } - // An old probe's bare exit code, or noise: 0 is a login that stays, as before. - #expect(CloudMachineLogin.parse(exitCode: 0, stdout: "") == .working) - #expect(CloudMachineLogin.parse(exitCode: 1, stdout: "") == .none) - #expect(CloudMachineLogin.parse(exitCode: 127, stdout: "python3: not found") == .none) - } - - @Test func theProbeCarriesAFingerprintNeverAToken() { - let fingerprint = CloudMachineLogin.fingerprint(Data(Self.token.utf8)) + /// The fingerprint and the probe word's reading are Rust's (cmux-cloud-start-core `machine_login`); + /// the probe's shell text stays here and is run below on a scratch machine. + @Test func theProbeCarriesAFingerprintNeverAToken() throws { + let fingerprint = try #require(CloudMachineLogin.fingerprint(Data(Self.token.utf8))) #expect(fingerprint.count == 64) let probe = CloudMachineLogin.probe(cachedFingerprint: fingerprint) #expect(probe.contains("cmux_agent_has_own_login claude")) @@ -65,14 +51,17 @@ struct CloudMachineLoginTests { /// `execHome`: the exec's own HOME (the classic route's exec runs as root, HOME /root); the /// probe reads `cmux`'s home whatever it is. func probe(cached: String?, endpoint: String, execHome: URL? = nil) async throws -> CloudMachineLogin { - let command = CloudMachineLogin.probe(cachedFingerprint: cached.map { CloudMachineLogin.fingerprint(Data($0.utf8)) }, endpoint: endpoint) + let command = CloudMachineLogin.probe(cachedFingerprint: cached.flatMap { CloudMachineLogin.fingerprint(Data($0.utf8)) }, endpoint: endpoint) .replacingOccurrences(of: "/etc/cmux", with: policy.path) .replacingOccurrences(of: "pwd.getpwnam('cmux').pw_dir", with: CloudCredentialInstaller.quote(home.path)) let ran = try await ProcessRunner.run(executable: URL(fileURLWithPath: "/bin/sh"), arguments: ["-c", command], environment: ["PATH": "/usr/bin:/bin", "HOME": (execHome ?? home).path], timeout: .seconds(20), clock: ContinuousClock()) let out = String(decoding: ran.stdout, as: UTF8.self) #expect(!out.contains("sk-ant")) - return CloudMachineLogin.parse(exitCode: Int(ran.status), stdout: out) + // Read as the start reads it: the probe's output goes into the one decision call. + var decision = CloudHarnessLoginDecision(dogfood: true, setting: nil, harness: nil, signedIn: true) + decision.machineProbe = .init(exitCode: Int(ran.status), stdout: out) + return try #require(decision.verdict.machineLogin) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatAppLinkTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatAppLinkTests.swift index 47d1534a4dea..08f1f07ac9c9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatAppLinkTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatAppLinkTests.swift @@ -16,13 +16,6 @@ import Testing @Suite @MainActor struct CloudStartChatAppLinkTests { - /// The start is refused only for Cloud off by policy or a signed-out account, whatever the link. - @Test func aStartOnTheAppServerLinkIsNotRefused() { - #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: true) == nil) - #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: false) == "not_signed_in") - #expect(CloudStartChat.refusal(cloudDisabled: true, signedIn: true) == "cloud_disabled") - } - /// A machine on the app link goes on to its own state checks: a deleting one is refused as /// deleting, never as a link that can't take a chat. @Test func aMachineOnTheAppLinkIsNotRefusedForItsLink() async { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift index dd7663d0fff9..8406c47d557c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift @@ -8,23 +8,16 @@ import Testing struct CloudStartChatLoginTests { typealias Decision = CloudHarnessLoginDecision - @Test func theSettingWinsElseOnlyDogfoodBuildsPassALogin() { - #expect(CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: true, setting: nil)) - #expect(!CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: false, setting: nil)) - #expect(CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: false, setting: true)) - #expect(CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: true, setting: true)) - // `defaults write ... cmux.cloud.passHarnessLogin -bool NO`: a DEV build behaves as production. - #expect(!CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: true, setting: false)) - #expect(!CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: false, setting: false)) - } - /// The NEXT nightly is dogfood (only the team uses NEXT Cloud), so it passes the login by /// default like a DEV build; the shipping app does not. @Test(arguments: [("com.cmuxterm.app.nightly.cmux-next", false, true), ("com.cmuxterm.app.debug.next", true, true), ("com.cmuxterm.app", false, false), ("com.cmuxterm.app.rc", false, false)]) - func dogfoodBundlesPassALoginByDefault(bundleID: String, isDebugBuild: Bool, passes: Bool) { + func dogfoodBundlesPassALoginByDefault(bundleID: String, isDebugBuild: Bool, passes: Bool) throws { let dogfood = DevTools.isAvailable(bundleID: bundleID, isDebugBuild: isDebugBuild) - #expect(CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: dogfood, setting: nil) == passes) + let suite = "CloudStartChatLoginTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + #expect(CloudCredentialTransfer.passesHarnessLogin(isDogfoodBuild: dogfood, defaults: defaults) == passes) } /// The switch reads `cmux.cloud.passHarnessLogin` from the defaults it is given. @@ -41,20 +34,23 @@ struct CloudStartChatLoginTests { } /// The decision's rules and their cases are Rust's (cmux-cloud-start-core `harness_login`); this - /// checks only that the app reaches them over the app FFI and reads every field back. + /// checks only that the app reaches them in the one call and reads every field back. @Test func theDecisionComesFromTheRustRules() { - let first = Decision(gate: true, provider: .claude, signedIn: true, callerOwns: true, guestKind: "owner", machineLogin: CloudMachineLogin.none) + var first = Decision(dogfood: true, setting: nil, harness: nil, signedIn: true) + first.callerOwns = true + let probe = first.verdict.guestKindProbe + #expect(first.readsMachine && probe.contains("/etc/cmux/vm-kind")) + first.guestStdout = "motd\nvm-kind:owner\n" + first.machineProbe = .init(exitCode: 0, stdout: "none\n") let verdict = first.verdict #expect(verdict.step == .confirmThenInstall && verdict.reason == .confirmFirst) #expect(verdict.readsMachine && verdict.guestTakesLogin && !verdict.skippedTeamMachine) + #expect(first.provider == .claude && first.guestKind == "owner" && verdict.machineLogin == CloudMachineLogin.none && first.gate) #expect(verdict.summary == "confirm-then-install (confirm-first): gate on, provider claude, " + "signed in yes, caller owns yes, guest owner, machine login none, cached no") - var declined = Decision(gate: false, provider: .codex, signedIn: true) + var declined = Decision(dogfood: true, setting: false, harness: "codex", signedIn: true) declined.outcome = "declined" #expect(declined.step == .skip && declined.reason == .buildGateOff && declined.summary.hasSuffix(" -> declined")) - #expect(Decision.loginProvider(harness: nil) == .claude && Decision.loginProvider(harness: "codex") == .codex) - #expect(Decision.loginProvider(harness: "opencode") == nil) - #expect(Decision.guestKindProbe.contains("/etc/cmux/vm-kind")) - #expect(Decision.guestKind(stdout: "motd\nvm-kind:team\n") == "team" && Decision.guestKind(stdout: "") == nil) + #expect(declined.provider == .codex && Decision(dogfood: true, setting: nil, harness: "opencode", signedIn: true).provider == nil) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift index a18ce7023ab5..c72cba347b6c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift @@ -30,18 +30,27 @@ struct CloudStartChatMachineTests { #expect(CloudMachineWorkspace.targetAnchor("pane:1", in: store) == nil) } - /// Router review at 2a315af: a team switch keeps the user signed in and the previous team's - /// machines in the store until the new list lands. A send is kept under the account it was sent - /// in, and copies nothing while the account changed or its list is not current. - @Test func aSendIsKeptUnderTheAccountItBeganInAndChecksItAtEveryStep() throws { + /// The start's checks are Rust's (cmux-cloud-start-core `start`, with their cases): the refusal, the + /// account a send began in and its list, and the folder it may copy. This checks only that the app + /// reaches them and throws or answers in its own terms. + @Test func theStartsChecksComeFromTheRustRules() throws { let teamA: [String?] = [Self.me, "team-a"], teamB: [String?] = [Self.me, "team-b"] - #expect(CloudStartChat.accountText(teamA) == "user-leo/team-a") - #expect(CloudStartChat.accountText(teamA) != CloudStartChat.accountText(teamB)) + #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: true) == nil) + #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: false) == "not_signed_in") + #expect(CloudStartChat.refusal(cloudDisabled: true, signedIn: true) == "cloud_disabled") #expect(CloudStartChat.accountText([Self.me, nil]) == "user-leo/") try CloudStartChat.checkAccount(teamA, now: teamA, listCurrent: true) #expect(throws: CloudMove.Failure.self) { try CloudStartChat.checkAccount(teamA, now: teamB, listCurrent: true) } #expect(throws: CloudMove.Failure.self) { try CloudStartChat.checkAccount(teamA, now: teamA, listCurrent: false) } - #expect(throws: CloudMove.Failure.self) { try CloudStartChat.checkAccount(teamA, now: [nil, nil], listCurrent: true) } + let folder = try #require(CloudMoveFolder(chat: "/Users/me/src/paint")) + var start = try #require(AgentPaneCloudStart(params: ["task": "t", "cwd": "/Users/me/src/paint", "key": UUID().uuidString])) + #expect(!start.uploads(folder)) + start.uploadRoot = folder.root + #expect(start.uploads(folder)) + let tabs = try #require(AgentPaneCloudStart(params: ["task": "t", "key": UUID().uuidString])) + #expect(!tabs.uploads(try #require(CloudMoveFolder(chat: "/Users")), home: "/Users/me")) + #expect(try #require(CloudMoveFolder(chat: "/")).isTooBroad) + #expect(try #require(CloudMoveFolder(chat: NSHomeDirectory())).isTooBroad) } /// Proof run on a fresh staging machine (2026-11-01): its image's older cmux-tui refused the @@ -161,37 +170,6 @@ struct CloudStartChatMachineTests { #expect(ensure["credential_on_stdin"] as? Bool == true) } - /// A folder the page named is copied only as the pane checked it. - @Test func aPageNamedFolderIsCopiedOnlyAsItsCheckedUploadRoot() throws { - let folder = try #require(CloudMoveFolder(chat: "/Users/me/src/paint")) - var start = try #require(AgentPaneCloudStart(params: ["task": "t", "cwd": "/Users/me/src/paint", "key": UUID().uuidString])) - #expect(!start.uploads(folder)) - start.uploadRoot = folder.root - #expect(start.uploads(folder)) - start.uploadRoot = "/Users/me/src/paint/sub" - #expect(!start.uploads(folder)) - // The tab's own folder (no `cwd`): only that folder itself, never a working copy above it. - let tabs = try #require(AgentPaneCloudStart(params: ["task": "t", "key": UUID().uuidString])) - #expect(tabs.uploads(folder)) - let repo = FileManager.default.temporaryDirectory.appendingPathComponent("cn-start-\(UUID().uuidString.prefix(8))") - defer { try? FileManager.default.removeItem(at: repo) } - let project = repo.appendingPathComponent("project") - try FileManager.default.createDirectory(at: repo.appendingPathComponent(".git"), withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) - let inRepo = try #require(CloudMoveFolder(chat: project.path)) - #expect(!inRepo.subpath.isEmpty) - #expect(!tabs.uploads(inRepo)) - let atRoot = try #require(CloudMoveFolder(chat: repo.path)) - #expect(tabs.uploads(atRoot)) - // Never the home folder or a folder above it (a tab in /Users), whatever `isTooBroad` says. - let users = try #require(CloudMoveFolder(chat: "/Users")) - #expect(!tabs.uploads(users, home: "/Users/me")) - let home = try #require(CloudMoveFolder(chat: "/Users/me")) - #expect(!tabs.uploads(home, home: "/Users/me")) - #expect(!tabs.uploads(try #require(CloudMoveFolder(chat: "/")), home: "/Users/me")) - #expect(tabs.uploads(atRoot, home: "/Users/me")) - } - /// Router review at 6beb0f2: with no `cwd` the tab's own folder is copied, and its `.git` file /// can name a repository elsewhere (a linked worktree): that send is refused, like one with `cwd`. @Test func aTabsFolderWhoseGitFolderIsElsewhereIsNeverCopied() throws { @@ -227,15 +205,6 @@ struct CloudStartChatMachineTests { #expect(await CloudService.createTeam(sent: .some("team-c"), requested: request.team) { "team-b" } == "team-c") } - /// Not behind Labs (Leo, 2026-10-30: "that seems dumb. i dont like putting so much stuff in - /// labs"): only Cloud off by policy and a signed-out account refuse. - @Test func aCloudStartIsRefusedOnlyWhenCloudIsOffOrNobodyIsSignedIn() { - #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: true) == nil) - #expect(CloudStartChat.refusal(cloudDisabled: false, signedIn: false) == "not_signed_in") - #expect(CloudStartChat.refusal(cloudDisabled: true, signedIn: true) == "cloud_disabled") - #expect(CloudStartChat.refusal(cloudDisabled: true, signedIn: false) == "cloud_disabled") - } - /// A signed-out first send keeps its stable refusal code for callers, but the /// card must tell the person how to sign in instead of displaying `not_signed_in`. @Test func aSignedOutFirstSendCarriesActionableRecoveryCopy() { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudVmKindTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudVmKindTests.swift index ef3a4b6f5ea7..3af4ccc318ce 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudVmKindTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudVmKindTests.swift @@ -16,22 +16,16 @@ struct CloudVmKindTests { return try JSONDecoder().decode(CloudAttachEndpoint.self, from: Data(body.utf8)) } - @Test func onlyOwnerStartsTheLinkAtTheVMBoundary() throws { + /// The word's rules are Rust's (cmux-cloud-start-core `vm_kind`, with their cases); this checks the + /// app's decoder (a value of another type fails the attach, never owner) and that the endpoint and + /// the link read the word from there. + @Test func theWordComesFromTheRustRulesAndAMalformedValueIsNeverOwner() throws { let owner = try endpoint(#""owner""#) - #expect(owner.vmKind == "owner") - #expect(owner.callerOwnsVM) + #expect(owner.callerOwnsVM && !owner.callerIsTeammate) #expect(CloudMachineLink.vmKindArguments(owner) == ["--vm-kind", "owner"]) - for kind in [#""team""#, "null", #""Owner""#, #""""#] { - let other = try endpoint(kind) - #expect(!other.callerOwnsVM, "\(kind)") - #expect(CloudMachineLink.vmKindArguments(other).isEmpty, "\(kind)") - } - } - - @Test func anOlderServerKeepsTheSandboxAndAMalformedValueIsNeverOwner() throws { - let older = try endpoint(nil) - #expect(older.vmKind == nil) - #expect(CloudMachineLink.vmKindArguments(older).isEmpty) + let team = try endpoint(#""team""#) + #expect(!team.callerOwnsVM && team.callerIsTeammate && CloudMachineLink.vmKindArguments(team).isEmpty) + #expect(try endpoint(nil).vmKind == nil && !(try endpoint(nil).callerOwnsVM)) for malformed in ["1", "true", #"{"kind":"owner"}"#, #"["owner"]"#] { #expect(throws: (any Error).self, "\(malformed)") { _ = try endpoint(malformed) } }