From 2997fae9a6982ca44daa055ee9391e7c84f8ff3b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 18:42:40 -0700 Subject: [PATCH 1/6] Cloud credential transfer: DEV builds pass the login to the creator's team machine Every machine on a team account has guest kind "team", including one Leo just made, so #29771's first-chat login and the "Transfer and restart" recovery both end in "Personal credentials can only be transferred to a Cloud machine you own." In DEV and dogfood builds (cloud.startChat's existing gate), a team guest whose creator is the caller (the platform's vmKind == "owner" answer, still checked before and after the login) may take the login. Production keeps requiring an owner guest. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CmuxNextApp/Cloud/CloudPresenter.swift | 4 +++ .../Cloud/CloudStartChat+Login.swift | 31 +++++++++-------- .../CloudCredentialInstaller.swift | 19 +++++++++-- .../CloudCredentialTransfer.swift | 19 ++++++++--- .../CloudCredentialTransferTests.swift | 33 +++++++++++++++++++ .../CloudStartChatLoginTests.swift | 16 +++++++++ 6 files changed, 102 insertions(+), 20 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift index 7c743bb328ba..18ac4b9f464b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift @@ -132,9 +132,13 @@ 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.passesHarnessLogin( + isDebugBuild: DevTools.isDebugBuild, optedIn: UserDefaults.standard.bool(forKey: CloudStartChat.passHarnessLoginKey)), openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false }) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartChat+Login.swift index 5b2dcadcff48..09584a50e5b2 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. @@ -65,14 +68,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: @@ -80,7 +85,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/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/CloudCredentialTransfer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift index aef6fa873cf0..cfd62eb7f90b 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. + 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/CloudCredentialTransferTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift index 67077fc9e1df..3a8f3be0cc1a 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() @@ -191,6 +212,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 8e49c4c9749e..bfbc7cb418ae 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartChatLoginTests.swift @@ -43,6 +43,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")) From 7f7b9d3e3ef946e87a123f008783935a5a0d495f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 18:53:40 -0700 Subject: [PATCH 2/6] credential recovery: the restart accepts the creator's team guest under the same DEV allowance Review (router-review, SHIP at 2997fae9, P1 follow-up): the transfer installed on a team guest but the restart still required an owner guest. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CmuxNextApp/Cloud/CloudPresenter.swift | 9 ++++++--- .../CloudCredentialTransfer.swift | 2 +- .../CloudCredentialRecoveryTests.swift | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift index 18ac4b9f464b..b31f7d9b30fb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift @@ -165,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), @@ -248,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/CmuxNextCloudCredentials/CloudCredentialTransfer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift index cfd62eb7f90b..76ecccc882d3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudCredentialTransfer.swift @@ -34,7 +34,7 @@ public import Foundation /// 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. - let allowsCreatorTeamGuest: Bool + public let allowsCreatorTeamGuest: Bool public init(provider: Provider, machineID: String, teamID: String?, userID: String, allowsCreatorTeamGuest: Bool = false, openTerminal: @escaping @MainActor (String) async -> Bool) { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift index 2b4735c3ee9b..c2f556cac6d4 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialRecoveryTests.swift @@ -34,6 +34,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)) From 1e0c95a5d6373be64e0fde2dd7eaa1c778796a9f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 00:00:00 +0000 Subject: [PATCH 3/6] integration: CloudPresenter imports CmuxNextActions (DevTools), the compile red #29962 carries Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift index b31f7d9b30fb..04b12951b7b1 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 From b4b0bd4a64d28facb99ba4fb190c3123c8422fa0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 00:00:00 +0000 Subject: [PATCH 4/6] integration: CloudPresenter takes passesHarnessLoginHere (#30204 renamed the gate) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift index 04b12951b7b1..7bffa6c0e6c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudPresenter.swift @@ -138,8 +138,7 @@ enum CloudPresenter { let transfer = CloudCredentialTransfer( provider: provider, machineID: session.machineID, teamID: teamID, userID: userID, - allowsCreatorTeamGuest: CloudStartChat.passesHarnessLogin( - isDebugBuild: DevTools.isDebugBuild, optedIn: UserDefaults.standard.bool(forKey: CloudStartChat.passHarnessLoginKey)), + allowsCreatorTeamGuest: CloudStartChat.passesHarnessLoginHere, openTerminal: { [weak services] line in await services?.openLocalProviderLogin(line) ?? false }) From ff3f8c9b869f187da52b38474bf3444241b21eeb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 07:01:51 -0800 Subject: [PATCH 5/6] Cloud start: read the cached Claude login with security, not a prompting SecItemCopyMatching A DEV build's Cloud start passes the person's Claude login alongside the folder copy and awaits it before the chat opens (CloudStartChat+Login). The cached login is stored by `cmux cloud secret set`, which writes it with /usr/bin/security, so the item's Keychain ACL trusts only that tool. The app read it with its own SecItemCopyMatching, which asks macOS for access with a modal prompt and blocks until someone answers. On a capture runner nobody does: the copy finished in a second, yet the card sat on "Clone repository" for the whole capture, awaiting the read. Read it the way CloudHandlers.keychainValues reads the same service: `security find-generic-password -w`, bounded at 30 s, no prompt. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CloudCredentialLogin.swift | 51 ++++++++++++++----- .../CloudCredentialTransferTests.swift | 12 +++++ 2 files changed, 49 insertions(+), 14 deletions(-) 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/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift index 3a8f3be0cc1a..455419511f6c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudCredentialTransferTests.swift @@ -180,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)") From e35296b46fd6e1b610c5a90f99e95f0779ca703b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 07:37:07 -0800 Subject: [PATCH 6/6] Cloud start: copy again on the machine's new link after updating it A chat start whose copy the machine's older cmux-tui refused (machine_outdated) updates the machine and copies again. The update restarts the machine's daemon and its link comes back on a new socket, but the second copy reused the socket read before the update and failed: "cannot connect to .../cmux-link-....sock: No such file or directory (os error 2)" (capture at ff3f8c9b, 2026-11-01). CloudOutdatedMachine.copy now reads the socket for each copy and, after an update, waits (up to 90 s) for the connection's handshake key to change before reading it again. CmuxNextApp shrinks by one line. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3bab30503f0b9a7059e5 --- .../CmuxNextApp/Cloud/CloudStartChat.swift | 11 ++-- .../Move/CloudOutdatedMachine.swift | 27 +++++++--- .../CloudStartChatMachineTests.swift | 54 ++++++++++++++++--- 3 files changed, 72 insertions(+), 20 deletions(-) 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/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