Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import CmuxNextCloud
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"`, 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.
/// 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"`, 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; later machines of the account get it with no prompt. Codex gets the Mac's broker (cmux#33489).
///
/// DEV and dogfood builds pass it as before. A Release build passes it only on Leo's three conditions
/// (``CloudLoginCopyGate``); otherwise the machine's chat offers its own one-click "Sign in to Claude".
Expand All @@ -20,29 +19,25 @@ import Foundation
/// Never blocks the chat: a machine that is not the person's, a cancelled confirm or a failed install
/// opens the chat anyway, and its turn card still offers recovery (#26148).
extension CloudStartChat {
/// Gives the start's machine the person's Claude login when this build passes it. Returns
/// whether a login was installed; never throws. `cmux cloud start-session` passes it the same way.
func passHarnessLogin(harness: String?, machine: CloudMachineSession) async -> Bool {
/// Gives the start's machine the person's Claude or Codex login when this build passes it. Returns
/// whether a login was installed; never throws. `cmux cloud start-session` passes it the same way, with no Codex prompt.
func passHarnessLogin(harness: String?, machine: CloudMachineSession, codexPrompt: Bool = true) async -> Bool {
let cloud = services.cloud, userID = cloud.auth.user?.id
let teamID = cloud.auth.teamID, machineID = machine.machineID
let api = cloud.machineAPI.pinningTeam(teamID)
let teamID = cloud.auth.teamID, machineID = machine.machineID, api = cloud.machineAPI.pinningTeam(teamID)
var decision = CloudHarnessLoginDecision(dogfood: DevTools.isEnabled, setting: CloudCredentialTransfer.passHarnessLoginSetting(.standard),
harness: harness, signedIn: userID != nil)
let gate = Self.loginCopyGate(services)
await gate.read(into: &decision)
// Never silent (CloudHarnessLoginDecision): the reason goes to the log and the machine's `cloud.machines` row.
defer {
machine.harnessLogin = decision
cloud.logger.info("cloud start chat: harness login \(decision.summary, privacy: .public)")
}
defer { machine.harnessLogin = decision; cloud.logger.info("cloud start chat: harness login \(decision.summary, privacy: .public)") }
do {
var verdict = decision.verdict // One FFI answer per step (cmux#32483): a step's fields never mix a failed call with a good one.
if verdict.gate, verdict.transferProvider == .claude, let userID {
if verdict.gate, verdict.transferProvider != nil, let userID {
decision.callerOwns = try await cloud.callerOwnsVM(machineID)
verdict = decision.verdict
if verdict.readsMachine { decision.guestStdout = (try? await api.exec(machineID, command: verdict.guestKindProbe, timeoutMs: 20_000))?.stdout }
// A login the provider rejects (a reused machine's expired token) is replaced like a missing one.
if decision.verdict.guestTakesLogin { decision.take(await CloudMachineLogin.read(on: api, machineID: machineID, teamID: teamID, userID: userID)) }
if verdict.transferProvider == .claude, decision.verdict.guestTakesLogin { decision.take(await CloudMachineLogin.read(on: api, machineID: machineID, teamID: teamID, userID: userID)) }
}
verdict = decision.verdict
guard let provider = verdict.transferProvider, let userID else { return false }
Expand All @@ -52,17 +47,22 @@ extension CloudStartChat {
case .askConsent:
guard await gate.askConsent(&decision) else { return false }
case .confirmThenInstall:
guard await CloudPresenter.confirmFirstLogin(provider: provider, machine: machine.machine.title, services: services) else {
decision.outcome = "declined"
return false
}
guard await CloudPresenter.confirmFirstLogin(provider: provider, machine: machine.machine.title, services: services) else { decision.outcome = "declined"; 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,
allowsCreatorTeamGuest: verdict.guestKind == "team" && CloudCredentialTransfer.allowsCreatorTeamGuest(isDogfoodBuild: DevTools.isEnabled, defaults: .standard), 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) },
stillAllowed: gate.stillAllowed)
if provider == .codex { // The Mac's broker: short-lived access, a device-code sign-in only when it holds none; it checks the account itself.
// A machine it already serves keeps that access untouched: a failed repeat would revoke it.
if await cloud.codexBroker.delivers(machineID: machineID, scope: .init(userID: userID, teamID: teamID)) { decision.outcome = "already delivered"; return false }
try await cloud.codexBroker.run(machineID: machineID, scope: .init(userID: userID, teamID: teamID), confirmed: true) { [weak services] prompt in
guard codexPrompt, let window = services?.windows.active?.window else { return false }
return await CloudPresenter.presentCodexDeviceCode(prompt, in: .window(window))
}
} else {
let transfer = CloudCredentialTransfer(provider: provider, machineID: machineID, teamID: teamID, userID: userID,
allowsCreatorTeamGuest: verdict.guestKind == "team" && CloudCredentialTransfer.allowsCreatorTeamGuest(isDogfoodBuild: DevTools.isEnabled, defaults: .standard), 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) }, stillAllowed: gate.stillAllowed)
}
decision.outcome = "installed"
return true
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ struct CloudStartSession {
/// The machine takes the person's harness login before the session starts, as a composer start's does (+Login).
/// The login may wait on a confirm: `still` checks the asking account after it, as after every wait.
func passHarnessLogin(_ session: CloudMachineSession, agent: String, still: () throws -> Void) async throws {
_ = await CloudStartChat(services: services).passHarnessLogin(harness: agent, machine: session)
_ = await CloudStartChat(services: services).passHarnessLogin(harness: agent, machine: session, codexPrompt: false) // a scripted run never waits on a device code
try still()
}
/// How long a machine has to come up (Move to Cloud's own wait).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ import Observation
try check(scope)
}

/// Whether this scope's broker already delivers (and renews) access on `machineID`. A new chat's
/// start leaves such a machine alone: a failed repeat transfer would revoke the access its other
/// chats use (cmux#33489).
public func delivers(machineID: String, scope: CloudCodexBrokerScope) async -> Bool {
await registry.existing(for: scope)?.registeredMachineIDs().contains(machineID) ?? false
}

/// Account/team observations call this even when the user remains signed in.
public func scopeDidChange() async {
let keep = currentScope()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public nonisolated struct CloudHarnessLoginDecision: Sendable, Equatable {
public enum Reason: String, Sendable, Equatable, Decodable {
case buildGateOff = "build-gate-off"
case noTransfer = "no-transfer-for-harness"
/// Codex: the Mac's broker gives the machine short-lived access before the first prompt (cmux#33489).
case codexBroker = "codex-broker"
case signedOut = "signed-out"
case callerNotOwner = "caller-not-owner"
case guestNotOwner = "guest-not-owner"
Expand Down
54 changes: 45 additions & 9 deletions cmux-tui/crates/cmux-cloud-start-core/src/harness_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,15 @@ pub enum Reason {
/// This build passes no login (the app's `passesHarnessLogin`).
#[serde(rename = "build-gate-off")]
BuildGateOff,
/// The chat's harness has no transfer here (Codex keeps its broker flow, pi and opencode none).
/// The chat's harness has no transfer here: pi and opencode none, and Codex in a Release build
/// (its broker is DEV and dogfood only; the Release consent covers the Claude login alone).
#[serde(rename = "no-transfer-for-harness")]
NoTransfer,
/// Codex: the Mac's broker gives the machine short-lived access before the first prompt. It
/// shows its own device-code sign-in (with Cancel) only when the Mac holds no Codex login for
/// this account yet; the refresh token stays on the Mac (cmux#33489).
#[serde(rename = "codex-broker")]
CodexBroker,
#[serde(rename = "signed-out")]
SignedOut,
/// The platform does not say the signed-in caller owns the machine.
Expand Down Expand Up @@ -173,6 +179,7 @@ impl Reason {
match self {
Self::BuildGateOff => "build-gate-off",
Self::NoTransfer => "no-transfer-for-harness",
Self::CodexBroker => "codex-broker",
Self::SignedOut => "signed-out",
Self::CallerNotOwner => "caller-not-owner",
Self::GuestNotOwner => "guest-not-owner",
Expand Down Expand Up @@ -274,13 +281,30 @@ pub struct Decision {
}

/// Whether a guest of this kind takes the caller's login. A Release copy goes only to an `owner`
/// guest: "never on team machines, never shared" (Leo, 2026-11-02).
fn guest_takes_login(kind: Option<&str>, release: bool) -> bool {
if release { kind == Some("owner") } else { matches!(kind, Some("owner" | "team")) }
/// guest: "never on team machines, never shared" (Leo, 2026-11-02). So does Codex's broker access:
/// acpmux's Codex bridge refuses it on a team guest (`codex_auth_file`), where the team login stays.
fn guest_takes_login(kind: Option<&str>, release: bool, provider: Option<Provider>) -> bool {
if release || provider == Some(Provider::Codex) {
kind == Some("owner")
} else {
matches!(kind, Some("owner" | "team"))
}
}

/// Whether the start passes this provider's login: Claude always (its gate decides), Codex only
/// outside Release, where the Mac's broker runs (cmux#33489: a Codex start that passed nothing
/// failed its first prompt until the turn card's broker sign-in).
fn transfers(d: &Inputs) -> bool {
match d.provider {
Some(Provider::Claude) => true,
Some(Provider::Codex) => !d.release,
None => false,
}
}

fn step_and_reason(d: &Inputs) -> (Step, Reason) {
// Codex logins are per machine (its refresh token is VM-bound): its broker flow stays the turn card's.
// Codex's refresh token never leaves the Mac: the start runs its broker, which delivers only
// short-lived access to the machine and renews it (CloudCodexBroker).
// A Release copy is held by its setting and its remote switch whatever `gate` the caller sent
// (a pre-resolved request with `release` and `gate` but no `dogfood` gets the same answer).
if d.release && !release_passes_harness_login(d.setting, d.remote_switch) {
Expand All @@ -291,7 +315,7 @@ fn step_and_reason(d: &Inputs) -> (Step, Reason) {
if !d.gate {
return (Step::Skip, Reason::BuildGateOff);
}
if d.provider != Some(Provider::Claude) {
if !transfers(d) {
return (Step::Skip, Reason::NoTransfer);
}
if !d.signed_in {
Expand All @@ -310,9 +334,21 @@ fn step_and_reason(d: &Inputs) -> (Step, Reason) {
};
// The caller created the machine: an owner or team guest takes the login
// (the transfer runs its creator allowance on a team guest).
if !guest_takes_login(Some(kind), d.release) {
if !guest_takes_login(Some(kind), d.release, d.provider) {
return (Step::Skip, Reason::GuestNotOwner);
}
if d.provider == Some(Provider::Codex) {
// The machine keeps its own Codex sign-in when asked to; otherwise the broker runs. Its
// device-code prompt is its own confirm (with Cancel), so no second one comes first, and a
// run that may show no prompt passes nothing rather than refusing the start.
let chose_pass = choice == Some(Choice::Pass)
&& (d.requested_by_person || d.stored == Some(Choice::Pass));
return match choice {
Some(Choice::Machine) => (Step::Skip, Reason::MachineLoginKept),
_ if d.headless && !chose_pass => (Step::Skip, Reason::NeedsLoginChoice),
_ => (Step::Install, Reason::CodexBroker),
};
}
// A machine that already has a working login keeps it, whatever the choice.
if !d.machine_login.is_none_or(MachineLogin::needs_login) {
return (Step::Skip, Reason::MachineLoginKept);
Expand Down Expand Up @@ -454,11 +490,11 @@ pub fn decide(d: &Inputs) -> Decision {
guest_kind_probe: GUEST_KIND_PROBE.to_owned(),
reads_machine: d.gate
&& (!d.release || release_passes_harness_login(d.setting, d.remote_switch))
&& d.provider == Some(Provider::Claude)
&& transfers(d)
&& d.signed_in
&& d.caller_owns == Some(true)
&& choice(d) != Some(Choice::None),
guest_takes_login: guest_takes_login(kind, d.release),
guest_takes_login: guest_takes_login(kind, d.release, d.provider),
skipped_team_machine: reason == Reason::GuestNotOwner && kind == Some("team"),
summary: summary(d, step, reason),
}
Expand Down
Loading
Loading