Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextActions
import CmuxNextAgentPane
import CmuxNextCloud
import CmuxNextCloudCredentials
Expand Down Expand Up @@ -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
})
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -64,22 +67,24 @@ 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:
guard await CloudPresenter.confirmFirstLogin(provider: provider, machine: machine.machine.title, services: services) else { return false }
}
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand All @@ -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<Answer>(_ 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<Answer>(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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,35 @@ 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
/// image's agent-auth.sh to verify that the next launcher chooses the delivered own login.
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)
Expand Down
Loading
Loading