diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift index 7c743bb328ba..7bffa6c0e6c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextActions import CmuxNextAgentPane import CmuxNextCloud import CmuxNextCloudCredentials @@ -132,9 +133,12 @@ enum CloudPresenter { currentTab === tab, (try? services.machines.agentDaemon(forTab: currentTab)) === daemon else { return false } + // DEV and dogfood builds (cloud.startChat's own gate) let the creator's team machine take the + // login here too; production keeps requiring an owner guest. let transfer = CloudCredentialTransfer( provider: provider, machineID: session.machineID, teamID: teamID, userID: userID, + allowsCreatorTeamGuest: CloudStartChat.passesHarnessLoginHere, openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false }) @@ -161,7 +165,7 @@ enum CloudPresenter { currentTab === tab, (try? services.machines.agentDaemon(forTab: currentTab)) === daemon else { return false } let pinned = services.cloud.machineAPI.pinningTeam(teamID) - guard try await restartCredentialSession(request.sessionID, exec: { + guard try await restartCredentialSession(request.sessionID, creatorTeamGuest: transfer.allowsCreatorTeamGuest, exec: { try await pinned.exec(session.machineID, command: $0, timeoutMs: 300_000) }) else { CloudPresenter.show(String(localized: "cloud.credentialRecovery.installedTitle", defaultValue: "Credentials installed", table: "CloudCredentialRecovery", bundle: .module), @@ -244,10 +248,13 @@ enum CloudPresenter { /// Use the same installed CLI and user as the credentials. Old/root-layout machines fail /// closed rather than restarting a different daemon. No provider output reaches the UI. - @MainActor static func restartCredentialSession(_ sessionID: String, exec: (String) async throws -> CloudExecResult) async throws -> Bool { + /// `creatorTeamGuest`: the transfer's DEV creator allowance, so a team guest it installed on restarts too. + @MainActor static func restartCredentialSession(_ sessionID: String, creatorTeamGuest: Bool = false, + exec: (String) async throws -> CloudExecResult) async throws -> Bool { + let kinds = creatorTeamGuest ? "owner|team" : "owner" let command = """ set -eu - [ "$(cat /etc/cmux/vm-kind 2>/dev/null)" = owner ] + case "$(cat /etc/cmux/vm-kind 2>/dev/null)" in \(kinds)) ;; *) exit 1 ;; esac [ -x /home/cmux/.cmux/bin/cmux-tui ] if [ "$(id -u)" = 0 ]; then set -- setpriv --reuid=cmux --regid=cmux --init-groups diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift index 7fb1626bc732..9a8612cac717 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift @@ -5,9 +5,8 @@ import CmuxNextCloudCredentials import Foundation /// A `cloud.startChat` chat's harness login, before its first prompt (Leo, 2026-10-31: "A TOKEN GETS -/// PASSED"). On a machine the signed-in person owns (the attach answer's `vmKind == "owner"`) whose -/// guest is an owner machine (no teammate shares its Unix user; a team machine, its creator's -/// included, is left alone, cmux#29774), Claude gets the person's own `claude setup-token` +/// PASSED"). On a machine the signed-in person owns (the attach answer's `vmKind == "owner"`, which a +/// team machine's creator gets too, #28788), Claude gets the person's own `claude setup-token` /// login through ``CloudCredentialTransfer``: silently when cmux already holds that login for this /// 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. @@ -34,20 +33,24 @@ extension CloudStartChat { /// What the start does about the login: nothing, install with no prompt, or ask once first. enum LoginStep: Equatable { case skip, install, confirmThenInstall } - /// `ownerGuest`: the guest is an owner machine (`/etc/cmux/vm-kind`). A team guest, a creator's - /// included, is skipped before any confirm: every member's terminal there runs as the same Unix - /// user, so the transfer's preflight refuses it (``CloudCredentialInstaller``), and asking first - /// would only end in that refusal. Team machines are cmux#29774. + /// `ownerGuest`: the guest is an owner machine (`/etc/cmux/vm-kind`). `creatorTeamGuest`: this + /// build lets a team guest whose creator is the caller receive it (DEV and dogfood, the same gate + /// as `enabled`; every member's terminal there runs as the same Unix user, so production keeps + /// requiring an owner guest, cmux#29774). Without it a team guest is skipped before any confirm, + /// since the transfer's preflight would only refuse it. nonisolated static func loginStep(enabled: Bool, provider: CloudCredentialTransfer.Provider?, signedInUser: Bool, - callerOwns: Bool, ownerGuest: Bool, machineHasLogin: Bool, cachedLogin: Bool) -> LoginStep { + callerOwns: Bool, ownerGuest: Bool, creatorTeamGuest: Bool = false, machineHasLogin: Bool, + cachedLogin: Bool) -> LoginStep { // Codex logins are per machine (its refresh token is VM-bound): its broker flow stays the // turn card's recovery until it gets the same treatment. - guard enabled, provider == .claude, signedInUser, callerOwns, ownerGuest, !machineHasLogin else { return .skip } + guard enabled, provider == .claude, signedInUser, callerOwns, ownerGuest || creatorTeamGuest, !machineHasLogin else { return .skip } return cachedLogin ? .install : .confirmThenInstall } /// The guest's own kind, as the transfer's preflight reads it. Exits 0 on an owner machine. static let ownerGuestProbe = #"[ "$(cat /etc/cmux/vm-kind 2>/dev/null)" = owner ]"# + /// A team guest (a team account's machine, its creator's included). Exits 0 on one. + static let teamGuestProbe = #"[ "$(cat /etc/cmux/vm-kind 2>/dev/null)" = team ]"# /// The probe for a login already on the machine: the image's own detection, with its config /// sourced as the launcher does. Exits 0 when Claude has its own sign-in there. @@ -64,14 +67,16 @@ extension CloudStartChat { let api = cloud.machineAPI.pinningTeam(teamID) do { let owns = try await cloud.callerOwnsVM(machineID) - var ownerGuest = false, hasLogin = false, cached = false + var ownerGuest = false, creatorTeamGuest = false, hasLogin = false, cached = false if owns { ownerGuest = (try? await api.exec(machineID, command: Self.ownerGuestProbe, timeoutMs: 20_000).exitCode) == 0 } - if ownerGuest { + // This build passes logins (the gate above), so the creator's team machine takes one too. + if owns, !ownerGuest { creatorTeamGuest = (try? await api.exec(machineID, command: Self.teamGuestProbe, timeoutMs: 20_000).exitCode) == 0 } + if ownerGuest || creatorTeamGuest { hasLogin = (try? await api.exec(machineID, command: Self.machineLoginProbe, timeoutMs: 20_000).exitCode) == 0 if !hasLogin { cached = await CloudCredentialTransfer.hasCachedLogin(provider: provider, teamID: teamID, userID: userID) } } switch Self.loginStep(enabled: enabled, provider: provider, signedInUser: true, callerOwns: owns, - ownerGuest: ownerGuest, machineHasLogin: hasLogin, cachedLogin: cached) { + ownerGuest: ownerGuest, creatorTeamGuest: creatorTeamGuest, machineHasLogin: hasLogin, cachedLogin: cached) { case .skip: return false case .install: break case .confirmThenInstall: @@ -79,7 +84,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, - openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false }) + allowsCreatorTeamGuest: creatorTeamGuest, 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) }) return true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift index feec9ea27b75..f8485c83bd02 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat.swift @@ -98,9 +98,6 @@ struct CloudStartChat { await CloudChatPick(services: services).read(folder: chat, tab: tab) } let name = machine.machine.title, again = CloudMoveRetries.Retry.move(machineID: machine.machineID) - guard let socket = await machine.daemon.connection?.endpoint?.socketPath else { - throw CloudMove.Failure(message: CloudMoveStrings.notConnected(name)) - } // The account may have changed while the machine connected: copy nothing then. try Self.checkAccount(account, now: services.cloud.account, listCurrent: services.cloud.hasLoadedCurrentMachines) // The chat's harness login (DEV and dogfood builds, +Login): it runs alongside the copy and is @@ -112,11 +109,13 @@ struct CloudStartChat { // copy finished opens its chat without copying again, which would overwrite what it changed. let copy: (CloudMoveCLI.Answer, (chat: String, copied: Bool)) do { - // A machine whose older cmux-tui refuses the copy is updated to this app's build, then copied again (CloudOutdatedMachine). - copy = try await CloudOutdatedMachine.copy({ + // A machine whose older cmux-tui refuses the copy is updated to this app's build, then copied again on its new link (CloudOutdatedMachine). + copy = try await CloudOutdatedMachine.copy(socket: { await machine.daemon.connection?.endpoint?.socketPath }, + link: { CloudMachineUpdates.handshake(machine, machines: services.machines) }, + notConnected: CloudMove.Failure(message: CloudMoveStrings.notConnected(name))) { socket in try await cli.startChatCopy(key: start.key, account: Self.accountText(account), machine: machine.machineID, socket: socket, local: folder.root, subpath: folder.subpath, workRoots: services.cloud.move.workRoots) - }, answer: { ($0.0.ok, $0.0.errorCode, $0.0.side) }) { await CloudMachineUpdates.updateNow(machine.machineID, services: services) } + } answer: { ($0.0.ok, $0.0.errorCode, $0.0.side) } update: { await CloudMachineUpdates.updateNow(machine.machineID, services: services) } } catch { // Timed out or didn't launch: Retry copies again on this machine, into a new folder. throw CloudMove.Failure(message: CloudMoveStrings.syncFailed(name, CloudStrings.errorText(error)), retry: again) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift index 84bcbe77ef0b..e17c682d8d65 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Move/CloudOutdatedMachine.swift @@ -3,7 +3,7 @@ /// /// A new machine's image may carry an older cmux-tui than the app, and the automatic update its /// first handshake queued may not have run yet. A chat start's copy (`cmux link start-chat copy`) -/// refused that way updates the machine to the app's own build and copies once more (``copy(_:answer:update:)``), +/// refused that way updates the machine to the app's own build and copies once more, on its new link (``copy(socket:link:notConnected:relink:_:answer:update:)``), /// instead of failing the person's first send with "Update the machine". public struct CloudOutdatedMachine { public init() {} @@ -16,14 +16,25 @@ public struct CloudOutdatedMachine { !ok && errorCode == code && side != "source" } - /// Runs `copy`; when its answer (read by `answer`) is the machine's older daemon refusing it - /// (``updatesBeforeCopyingAgain(ok:errorCode:side:)``) and `update` updated the machine (true), - /// runs it once more. Otherwise the first answer stands: nothing else updates the machine. - public static func copy(_ copy: () async throws -> Answer, answer: (Answer) -> (ok: Bool, errorCode: String?, side: String?), - update: () async -> Bool) async throws -> Answer { - let first = try await copy() + /// Runs `copy` on the machine's link socket (`socket`, nil when not connected: `notConnected` is thrown); when + /// its answer (read by `answer`) is the machine's older daemon refusing it (``updatesBeforeCopyingAgain(ok:errorCode:side:)``) + /// and `update` updated the machine (true), runs it once more. Otherwise the first answer stands: nothing else updates it. + /// + /// The update restarts the machine's daemon and its link comes back on a new socket: copying again on the + /// first one failed with "cannot connect to …/cmux-link-….sock: No such file or directory" (capture at + /// ff3f8c9b, 2026-11-01). So the second copy waits, up to `relink`, for `link` (the connection's handshake + /// key) to change from what it was before the update, and reads the socket again. + @MainActor + public static func copy(socket: () async -> String?, link: @escaping @MainActor @Sendable () -> String?, notConnected: any Error, + relink: Duration = .seconds(90), _ copy: (String) async throws -> Answer, + answer: (Answer) -> (ok: Bool, errorCode: String?, side: String?), update: () async -> Bool) async throws -> Answer { + guard let path = await socket() else { throw notConnected } + let before = link() + let first = try await copy(path) let read = answer(first) guard updatesBeforeCopyingAgain(ok: read.ok, errorCode: read.errorCode, side: read.side), await update() else { return first } - return try await copy() + _ = await CloudWait.holds(within: relink) { link().map { $0 != before } ?? false } + guard let again = await socket() else { throw notConnected } + return try await copy(again) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialInstaller.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialInstaller.swift index 1b483c7e79b3..bbf78d81ce6e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialInstaller.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialInstaller.swift @@ -15,13 +15,25 @@ nonisolated enum CloudCredentialInstaller { fi """# + /// The DEV and dogfood creator allowance (``CloudCredentialTransfer/allowsCreatorTeamGuest``): an + /// owner or team guest, the caller's ownership answer being checked by the transfer itself. Every + /// other check of ``preflight`` stays. + static let creatorTeamPreflight = #""" + set -eu + case "$(cat /etc/cmux/vm-kind 2>/dev/null)" in owner|team) ;; *) exit 1 ;; esac + [ -f /etc/cmux/agent-auth.sh ] + if [ "$(id -u)" = 0 ]; then install -d -m 700 -o cmux -g cmux /home/cmux/.cmux/credential-transfer; else + [ "$(id -un)" = cmux ]; mkdir -p /home/cmux/.cmux/credential-transfer; chmod 700 /home/cmux/.cmux/credential-transfer + fi + """# + static func quote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } - static func command(provider: CloudCredentialTransfer.Provider, payloadPath: String) -> String { + static func command(provider: CloudCredentialTransfer.Provider, payloadPath: String, creatorTeamGuest: Bool = false) -> String { // Reuse the image's exact own-mode normalization: only its known endpoint/placeholder // values are removed. Custom endpoints remain visible to the installer's conflict guard. "set -e\n. /etc/cmux/agent-auth.sh\ncmux_agent_drop_model_plane_env\n" - + "python3 - \(quote(provider.rawValue)) \(quote(payloadPath)) <<'CMUX_CREDENTIAL_INSTALL'\n" + script + "\nCMUX_CREDENTIAL_INSTALL" + + "python3 - \(quote(provider.rawValue)) \(quote(payloadPath)) \(creatorTeamGuest ? "owner,team" : "owner") <<'CMUX_CREDENTIAL_INSTALL'\n" + script + "\nCMUX_CREDENTIAL_INSTALL" } /// Tests execute this installer against a scratch HOME/policy directory, then source the real @@ -29,8 +41,9 @@ nonisolated enum CloudCredentialInstaller { static let script = #""" import json, os, pathlib, pwd, stat, sys, tempfile provider, source = sys.argv[1:3] + kinds = set((sys.argv[3] if len(sys.argv) > 3 else 'owner').split(',')) & {'owner', 'team'} policy = pathlib.Path('/etc/cmux') - if (policy / 'vm-kind').read_text().strip() != 'owner' or not (policy / 'agent-auth.sh').is_file(): + if (policy / 'vm-kind').read_text().strip() not in kinds or not (policy / 'agent-auth.sh').is_file(): sys.exit(70) user = pwd.getpwnam('cmux') home = pathlib.Path(user.pw_dir) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialLogin.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialLogin.swift index ed1cad2d08b5..0a3560e30ff6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialLogin.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialLogin.swift @@ -2,7 +2,6 @@ import CmuxNextDaemon import CmuxNextCompat import CryptoKit import Foundation -import Security /// The transfer cache uses #25488's cmux-cloud-secret Keychain service and its stdin-only /// `cloud secret set` API. It is separate from the user's normal Claude/Codex login/cache. @@ -16,24 +15,48 @@ import Security return "\(provider.rawValue.uppercased())_TRANSFER_\(scope)" } + /// The cached login, read the way it was stored: `cmux cloud secret set` writes the item with + /// `/usr/bin/security`, so its Keychain ACL trusts only that tool. The app's own + /// `SecItemCopyMatching` of it asks macOS for access with a modal prompt and waits for an answer, + /// which never comes on a machine with nobody at it (a capture runner): a Cloud start then sat + /// on "Clone repository" with its copy long done, awaiting this read. `security + /// find-generic-password -w` reads it with no prompt, bounded by ``readTimeout`` (as + /// `CloudHandlers.keychainValues` reads the same service). Nil when there is none. @concurrent static func load(provider: Provider, teamID: String?, userID: String, machineID: String? = nil) async throws -> Data? { - var result: CFTypeRef? - let status = SecItemCopyMatching([ - kSecClass: kSecClassGenericPassword, kSecAttrService: "cmux-cloud-secret", - kSecAttrAccount: "*/" + name(provider: provider, teamID: teamID, userID: userID, machineID: machineID), - kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, - ] as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let bytes = result as? Data, - let object = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any], - let created = object["created"] as? Double, - created > 0, - let encoded = object["credential"] as? String, let data = Data(base64Encoded: encoded) - else { if status == errSecSuccess { return nil }; throw Failure.storeFailed } + let account = "*/" + name(provider: provider, teamID: teamID, userID: userID, machineID: machineID) + let result: ProcessResult + do { + result = try await ProcessRunner.run(executable: URL(fileURLWithPath: "/usr/bin/security"), + arguments: ["find-generic-password", "-s", service, "-a", account, "-w"], + environment: nil, timeout: readTimeout, clock: ContinuousClock()) + } catch { + throw Failure.storeFailed + } + if result.status == itemNotFoundStatus { return nil } + guard result.status == 0 else { throw Failure.storeFailed } + guard let data = credential(envelope: result.stdout) else { return nil } try CloudCredentialTransfer.validate(data, provider: provider) return data } + /// The Keychain service `cmux cloud secret set` stores under. + nonisolated static let service = "cmux-cloud-secret" + /// How long one Keychain read may take before the login counts as unreadable. + nonisolated static let readTimeout: Duration = .seconds(30) + /// `security find-generic-password`'s exit status for no such item (errSecItemNotFound). + nonisolated static let itemNotFoundStatus: Int32 = 44 + + /// The credential in a stored envelope (`{"created": T, "credential": BASE64}`, one line as + /// `security -w` prints it); nil for anything else. + nonisolated static func credential(envelope output: Data) -> Data? { + var bytes = output + while let last = bytes.last, last == UInt8(ascii: "\n") || last == UInt8(ascii: "\r") { bytes.removeLast() } + guard let object = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any], + let created = object["created"] as? Double, created > 0, + let encoded = object["credential"] as? String else { return nil } + return Data(base64Encoded: encoded) + } + static func store(_ value: Data, provider: Provider, teamID: String?, userID: String, machineID: String? = nil) async throws { guard let binary = try? DaemonLauncher.resolveBinary(bundle: .main, environment: [:]) else { throw Failure.storeFailed } let envelope = try JSONSerialization.data(withJSONObject: [ diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift index aef6fa873cf0..76ecccc882d3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift @@ -31,8 +31,19 @@ public import Foundation let teamID: String? let userID: String let openTerminal: @MainActor (String) async -> Bool + /// DEV and dogfood builds only (``CloudCredentialInstaller/creatorTeamGuest``): a team guest + /// whose creator is the caller (the platform's `vmKind == "owner"` answer, still required) may + /// receive the login although teammates share its Unix user. Off everywhere else. + public let allowsCreatorTeamGuest: Bool - public init(provider: Provider, machineID: String, teamID: String?, userID: String, openTerminal: @escaping @MainActor (String) async -> Bool) { self.provider = provider; self.machineID = machineID; self.teamID = teamID; self.userID = userID; self.openTerminal = openTerminal } + public init(provider: Provider, machineID: String, teamID: String?, userID: String, allowsCreatorTeamGuest: Bool = false, + openTerminal: @escaping @MainActor (String) async -> Bool) { + self.provider = provider; self.machineID = machineID; self.teamID = teamID; self.userID = userID + self.allowsCreatorTeamGuest = allowsCreatorTeamGuest; self.openTerminal = openTerminal + } + + /// The guest check this transfer runs before and after the login. + var preflight: String { allowsCreatorTeamGuest ? CloudCredentialInstaller.creatorTeamPreflight : CloudCredentialInstaller.preflight } /// `callerOwnsVM`: the signed-in caller owns the machine, from the platform (the classic attach /// answer's `vmKind == "owner"`, which #28788 gives a team machine's creator, or a Worker @@ -107,7 +118,7 @@ public import Foundation // No credential is acquired or read until the platform says the caller owns the machine and // the machine has the image's launchers. guard try await effects.callerOwnsVM() else { throw Failure.sharedMachine } - guard try await effects.exec(CloudCredentialInstaller.preflight) == 0 else { throw Failure.sharedMachine } + guard try await effects.exec(preflight) == 0 else { throw Failure.sharedMachine } try checkScope() let cached = refresh ? nil : try await effects.load() let credential: Data @@ -118,13 +129,13 @@ public import Foundation try checkScope() // Recheck after browser/Keychain interaction; the target may have changed meanwhile. guard try await effects.callerOwnsVM() else { throw Failure.sharedMachine } - guard try await effects.exec(CloudCredentialInstaller.preflight) == 0 else { throw Failure.sharedMachine } + guard try await effects.exec(preflight) == 0 else { throw Failure.sharedMachine } let path = "/home/cmux/.cmux/credential-transfer/\(UUID().uuidString).json" do { try checkScope() try await effects.write(path, credential) try checkScope() - guard try await effects.exec(CloudCredentialInstaller.command(provider: provider, payloadPath: path)) == 0 else { + guard try await effects.exec(CloudCredentialInstaller.command(provider: provider, payloadPath: path, creatorTeamGuest: allowsCreatorTeamGuest)) == 0 else { throw Failure.deliveryFailed } } catch { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift index 5fee5d8d067d..e6726735e961 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift @@ -43,6 +43,21 @@ struct CloudCredentialRecoveryTests { })) } + /// The restart checks the guest kind the transfer installed on: an owner guest, or with the DEV + /// creator allowance a team guest as well. + @Test func restartAcceptsTheCreatorsTeamGuestOnlyWithTheAllowance() async throws { + var commands: [String] = [] + let ok = #"{"restarted":1,"after_turn":0,"failed":0,"errors":[]}"# + _ = try await CloudPresenter.restartCredentialSession("chat-1", exec: { command in + commands.append(command); return CloudExecResult(exitCode: 0, stdout: ok, stderr: "") + }) + _ = try await CloudPresenter.restartCredentialSession("chat-1", creatorTeamGuest: true, exec: { command in + commands.append(command); return CloudExecResult(exitCode: 0, stdout: ok, stderr: "") + }) + #expect(commands[0].contains("in owner)") && !commands[0].contains("team")) + #expect(commands[1].contains("in owner|team)")) + } + @Test func credentialRecoveryConfirmationIsAlwaysUserOnly() { #expect(DebugDialog.userOnly.contains(CloudPresenter.credentialRecoveryIdentifier)) #expect(DebugDialog.userOnly.contains(CloudPresenter.codexDeviceCodeIdentifier)) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift index 67077fc9e1df..455419511f6c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift @@ -69,6 +69,27 @@ struct CloudCredentialTransferTests { #expect(CloudCredentialInstaller.script.contains("'vm-kind'")) } + /// The DEV creator allowance swaps only the guest-kind line: the caller's owner answer, the launchers + /// check and the work-user checks stay, and the installer accepts a team guest only when asked. + @Test func theCreatorAllowanceAcceptsATeamGuestOnlyWhenAsked() { + let plain = CloudCredentialTransfer(provider: .claude, machineID: "vm", teamID: "t", userID: "u", openTerminal: { _ in false }) + let creator = CloudCredentialTransfer(provider: .claude, machineID: "vm", teamID: "t", userID: "u", allowsCreatorTeamGuest: true, + openTerminal: { _ in false }) + #expect(plain.preflight == CloudCredentialInstaller.preflight) + #expect(creator.preflight == CloudCredentialInstaller.creatorTeamPreflight) + #expect(CloudCredentialInstaller.creatorTeamPreflight.contains("owner|team")) + #expect(CloudCredentialInstaller.creatorTeamPreflight.contains("/etc/cmux/agent-auth.sh")) + #expect(CloudCredentialInstaller.command(provider: .claude, payloadPath: "/p").contains("'/p' owner <<")) + #expect(CloudCredentialInstaller.command(provider: .claude, payloadPath: "/p", creatorTeamGuest: true).contains("'/p' owner,team <<")) + } + + /// Without the allowance, a caller-owned machine whose guest is a team one is refused before any login. + @Test func ownerAnswerOnATeamGuestStillNeedsTheAllowance() async { + let r = Recorder(); r.preflight = 1 + await #expect(throws: CloudCredentialTransfer.Failure.sharedMachine) { try await r.run() } + #expect(r.commands == [CloudCredentialInstaller.preflight] && r.logins == 0) + } + @Test func firstTransferStoresThenDeliversAndRepeatReusesScopedCache() async throws { let r = Recorder() try await r.run(); try await r.run() @@ -159,6 +180,18 @@ struct CloudCredentialTransferTests { #expect(CloudCredentialLogin.name(provider: .claude, teamID: "t", userID: "a") != CloudCredentialLogin.name(provider: .claude, teamID: "t", userID: "b")) } + /// The cached login is read back as `security find-generic-password -w` prints what `cloud secret set` + /// stored: the envelope on one line. Anything else is no login (the start asks once), never a hang. + @Test func cachedLoginEnvelopeReadsAsSecurityPrintsIt() { + let envelope = #"{"created":1767225600,"credential":"\#(Self.token.base64EncodedString())"}"# + #expect(CloudCredentialLogin.credential(envelope: Data((envelope + "\n").utf8)) == Self.token) + #expect(CloudCredentialLogin.credential(envelope: Data(envelope.utf8)) == Self.token) + #expect(CloudCredentialLogin.credential(envelope: Data(#"{"created":0,"credential":"eA=="}"#.utf8)) == nil) + #expect(CloudCredentialLogin.credential(envelope: Data("not json\n".utf8)) == nil) + #expect(CloudCredentialLogin.service == "cmux-cloud-secret") + #expect(CloudCredentialLogin.readTimeout <= .seconds(30)) + } + @Test(arguments: [CloudCredentialTransfer.Provider.claude, .codex], [false, true]) func installerAcceptsShippedPlaneButRejectsCustomEndpoints(provider: CloudCredentialTransfer.Provider, custom: Bool) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("transfer-test-\(UUID().uuidString)") @@ -191,6 +224,18 @@ struct CloudCredentialTransferTests { arguments: ["-c", installer()], environment: environment, timeout: .seconds(10), clock: ContinuousClock()) #expect(refused.status != 0) + // The DEV creator allowance installs it there (custom endpoints are still refused). + let teamHome = directory.appendingPathComponent("team-home") + try FileManager.default.createDirectory(at: teamHome, withIntermediateDirectories: true) + let creator = CloudCredentialInstaller.command(provider: provider, payloadPath: source.path, creatorTeamGuest: true) + .replacingOccurrences(of: "/etc/cmux/agent-auth.sh", with: policy.appendingPathComponent("agent-auth.sh").path) + .replacingOccurrences(of: "pathlib.Path('/etc/cmux')", with: "pathlib.Path(\(Self.pythonLiteral(policy.path)))") + .replacingOccurrences(of: "pwd.getpwnam('cmux')", with: "pwd.getpwuid(os.geteuid())") + .replacingOccurrences(of: "pathlib.Path(user.pw_dir)", with: "pathlib.Path(\(Self.pythonLiteral(teamHome.path)))") + let allowed = try await ProcessRunner.run(executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", creator], environment: environment, timeout: .seconds(10), clock: ContinuousClock()) + #expect((allowed.status == 0) == !custom) + #expect(FileManager.default.fileExists(atPath: teamHome.appendingPathComponent(".config/cmux/agent-auth/\(provider.rawValue)").path) == !custom) #expect(!FileManager.default.fileExists(atPath: home.appendingPathComponent(".config/cmux/agent-auth/\(provider.rawValue)").path)) try (provider == .claude ? Self.token : Self.codex).write(to: source) try Data("owner".utf8).write(to: policy.appendingPathComponent("vm-kind")) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift index 1d358d89a0db..cd3a6256d206 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift @@ -71,6 +71,22 @@ struct CloudStartChatLoginTests { #expect(step(provider: nil) == .skip) } + /// Leo's machines are all team-account machines he created (guest kind `team`), so a DEV build lets + /// the creator's team guest take the login (the platform's owner answer still required); without + /// that allowance it stays skipped, as in production. + @Test func aDevBuildPassesTheLoginToTheCreatorsTeamMachine() { + func step(owns: Bool = true, creatorTeam: Bool, cached: Bool = true) -> Step { + CloudStartChat.loginStep(enabled: true, provider: .claude, signedInUser: true, callerOwns: owns, + ownerGuest: false, creatorTeamGuest: creatorTeam, machineHasLogin: false, cachedLogin: cached) + } + #expect(step(creatorTeam: true) == .install) + #expect(step(creatorTeam: true, cached: false) == .confirmThenInstall) + #expect(step(creatorTeam: false) == .skip) + // A teammate's team machine: the platform does not answer owner, so never. + #expect(step(owns: false, creatorTeam: true) == .skip) + #expect(CloudStartChat.teamGuestProbe.contains("= team")) + } + @Test func theMachineProbeUsesTheImagesOwnDetection() { #expect(CloudStartChat.machineLoginProbe.contains("cmux_agent_has_own_login claude")) #expect(!CloudStartChat.machineLoginProbe.contains("sk-ant")) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift index cabcac6b857e..e83c7400c619 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatMachineTests.swift @@ -2,6 +2,7 @@ @testable import CmuxNextApp import CmuxNextCloud import Foundation +import Observation import Testing /// What stays in the App of a `cloud.startChat` send: the account and list checks, the folder it may @@ -38,21 +39,62 @@ struct CloudStartChatMachineTests { (.init(ok: false, errorCode: outdated, side: "source"), false), (.init(ok: false, errorCode: "sync_failed"), false), (.init(ok: true), false)] { for updated in [true, false] { - var copies = 0, asked = 0 - let answer = try await CloudOutdatedMachine.copy({ () async throws -> CloudMoveCLI.Answer in - copies += 1 - return copies == 1 ? first : CloudMoveCLI.Answer(ok: true) - }, answer: read) { + let link = Link() + var sockets: [String] = [], asked = 0 + let answer = try await CloudOutdatedMachine.copy(socket: { link.socket }, link: { link.key }, notConnected: CancellationError(), + relink: .seconds(5)) { (socket: String) async throws -> CloudMoveCLI.Answer in + sockets.append(socket) + return sockets.count == 1 ? first : CloudMoveCLI.Answer(ok: true) + } answer: { read($0) } update: { asked += 1 + link.restart() return updated } #expect(asked == (updates ? 1 : 0)) - #expect(copies == (updates && updated ? 2 : 1)) + #expect(sockets == (updates && updated ? ["link-1.sock", "link-2.sock"] : ["link-1.sock"])) #expect(answer == (updates && updated ? CloudMoveCLI.Answer(ok: true) : first)) } } } + /// Capture at ff3f8c9b (2026-11-01): the update restarted the machine's daemon and its link came back on a new + /// socket a moment after the update returned. The second copy waits for the new link instead of failing with + /// "cannot connect to …/cmux-link-….sock: No such file or directory", and nothing copies while not connected. + @Test func theCopyAfterAnUpdateWaitsForTheMachinesNewLink() async throws { + let link = Link(), outdated = CloudMoveCLI.Answer(ok: false, errorCode: CloudOutdatedMachine.code) + var sockets: [String] = [] + let answer = try await CloudOutdatedMachine.copy(socket: { link.socket }, link: { link.key }, notConnected: CancellationError(), + relink: .seconds(30)) { (socket: String) async throws -> CloudMoveCLI.Answer in + sockets.append(socket) + return sockets.count == 1 ? outdated : CloudMoveCLI.Answer(ok: true) + } answer: { ($0.ok, $0.errorCode, $0.side) } update: { + link.drop() + Task { @MainActor in link.restart() } + return true + } + #expect(sockets == ["link-1.sock", "link-2.sock"]) + #expect(answer == CloudMoveCLI.Answer(ok: true)) + let gone = Link() + gone.drop() + await #expect(throws: CancellationError.self) { + _ = try await CloudOutdatedMachine.copy(socket: { gone.socket }, link: { gone.key }, notConnected: CancellationError()) { (_: String) async throws in + Issue.record("copied while not connected") + return CloudMoveCLI.Answer(ok: true) + } answer: { ($0.ok, $0.errorCode, $0.side) } update: { true } + } + } + + /// A machine's daemon link as the copy sees it: its socket and handshake key, observable, both + /// gone while the daemon restarts. + @MainActor @Observable private final class Link { + private(set) var generation: Int? = 1 + var socket: String? { generation.map { "link-\($0).sock" } } + var key: String? { generation.map { "key-\($0)" } } + private var last = 1 + func drop() { generation = nil } + func restart() { last += 1; generation = last } + } + /// The plan reads the send under its account and Mac workspace, only the machines the account's list /// named, and the ones made since (kept for a pin, never picked). /// Router review at f766845: a machine the server destroyed may stay in the store after the list dropped it. It is