Merge nucleic/sleek-river-urchin-2nzm into dev

This commit is contained in:
2026-07-17 23:27:01 -07:00
parent 4af015c283
commit bb6a692c12
7 changed files with 644 additions and 4 deletions
@@ -30,6 +30,9 @@ final class HostConnection {
private(set) var modelCatalog: WireModelCatalog = .empty private(set) var modelCatalog: WireModelCatalog = .empty
private(set) var dashboard = DashboardSnapshot.empty private(set) var dashboard = DashboardSnapshot.empty
private(set) var meshPeers: [PeerSummary] = [] private(set) var meshPeers: [PeerSummary] = []
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
/// and on every change; feeds Settings ▸ Agent Accounts.
private(set) var agentAuthStatuses: [WireProviderAuthStatus] = []
/// The live transport, for the "Connected · …" chip. /// The live transport, for the "Connected · …" chip.
private(set) var activeTransport: SyncTransportHint = .lan private(set) var activeTransport: SyncTransportHint = .lan
@@ -135,6 +138,11 @@ final class HostConnection {
/// connect and every `HostMsg.settings` broadcast after. RemoteStore adopts it as the /// connect and every `HostMsg.settings` broadcast after. RemoteStore adopts it as the
/// account truth (every paired Mac reports the same value). /// account truth (every paired Mac reports the same value).
var settingsChanged: (SyncedSettings) -> Void = { _ in } var settingsChanged: (SyncedSettings) -> Void = { _ in }
/// The consent challenge for a remote agent sign-in this phone began on this host
/// (docs/REMOTE_AGENT_LOGIN.md) — open the URL and capture the redirect as described.
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
} }
private let callbacks: Callbacks private let callbacks: Callbacks
@@ -812,6 +820,17 @@ final class HostConnection {
case .settings(let settings): case .settings(let settings):
// The account-level synced settings changed on the host — adopt the new truth. // The account-level synced settings changed on the host — adopt the new truth.
callbacks.settingsChanged(settings) callbacks.settingsChanged(settings)
case .agentLoginChallenge(let challenge):
// Remote agent sign-in: the host built the consent URL for an attempt this phone
// began — up to RemoteStore's flow (browser + redirect capture).
callbacks.agentLoginChallenge(challenge)
case .agentLoginResult(let result):
callbacks.agentLoginResult(result)
case .agentAuthStatus(let statuses):
// Per-provider install/auth state (pushed post-hello and on change) — feeds the
// Agent Accounts list and the in-chat sign-in affordance.
agentAuthStatuses = statuses
callbacks.didUpdate()
case .directAnswer(let offer): case .directAnswer(let offer):
directBox?.deliver(.answer(offer)) directBox?.deliver(.answer(offer))
case .directGo: case .directGo:
@@ -439,6 +439,33 @@ final class RemoteStore: ObservableObject {
/// The host that forwarded the pending confirm, so the answer routes back to it. /// The host that forwarded the pending confirm, so the answer routes back to it.
private var pendingMacPairHostID: String? private var pendingMacPairHostID: String?
/// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md): the phone asks a host — a Mac or a
/// cloud runner — to run the Claude/Codex OAuth flow; the phone contributes the browser and
/// the redirect capture (its own loopback, or a pasted code), the host everything secret.
enum AgentLoginState: Equatable {
case idle
/// `agentLoginBegin` sent; waiting on the host's challenge.
case starting
/// Consent page presented; the loopback listener is armed and will auto-capture.
case browser(URL)
/// Consent page presented; the vendor's page renders a code the user pastes back.
case pasteCode(URL)
/// Captured code sent to the host; waiting on the exchange outcome.
case finishing
/// The attempt's definitive outcome (success, or the host's failure message).
case done(success: Bool, message: String?)
}
@Published private(set) var agentLogin: AgentLoginState = .idle
/// Which provider/host the in-flight attempt targets (drives sheet labels).
@Published private(set) var agentLoginProvider: AgentLoginProvider?
private(set) var agentLoginHostID: String?
/// The in-flight attempt's id — replies are matched on it, so a stale challenge/result
/// (user dismissed and restarted) can't settle the wrong attempt.
private var agentLoginRequestID: String?
/// The armed redirect listener (bound BEFORE `agentLoginBegin`, since the begin carries the
/// bound port). Stopped on every terminal transition.
private var agentLoginListener: OAuthRedirectListener?
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble): /// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable { struct LastError: Equatable, Identifiable {
@@ -1356,6 +1383,16 @@ final class RemoteStore: ObservableObject {
// Every paired Mac reports the same value, so last-writer-wins is correct here. // Every paired Mac reports the same value, so last-writer-wins is correct here.
self?.adoptSyncedSettings(settings) self?.adoptSyncedSettings(settings)
} }
cb.agentLoginChallenge = { [weak self] challenge in
// Remote agent sign-in: the host built the consent URL for this phone's attempt —
// only the host we asked can answer (correlated by requestID inside).
guard let self, hostID == self.agentLoginHostID else { return }
self.agentLoginChallengeReceived(challenge)
}
cb.agentLoginResult = { [weak self] result in
guard let self, hostID == self.agentLoginHostID else { return }
self.agentLoginResultReceived(result)
}
return cb return cb
} }
@@ -1947,6 +1984,138 @@ final class RemoteStore: ObservableObject {
addProject = .idle addProject = .idle
} }
// MARK: - Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md)
/// Hosts that can broker an agent sign-in right now, with their per-provider status — the
/// Agent Accounts list. Name-sorted for stability.
var agentAccountHosts: [(hostID: String, name: String, statuses: [WireProviderAuthStatus])] {
connections.values
.filter { $0.connectivity.isLive && !$0.agentAuthStatuses.isEmpty }
.map { ($0.hostID, $0.hostName, $0.agentAuthStatuses) }
.sorted { $0.1 < $1.1 }
}
/// Begin a provider sign-in brokered by `hostID`. Binds the redirect listener FIRST (the
/// begin message carries the bound port): Codex's OAuth client only permits the fixed
/// `localhost:1455`, Claude accepts any port — and no port at all falls back to Claude's
/// paste-a-code capture, which the host chooses when the begin carries none.
func beginAgentLogin(provider: AgentLoginProvider, onHost hostID: String) {
guard case .idle = agentLogin else { return }
guard let conn = connections[hostID], conn.connectivity.isLive,
conn.capabilities.canBrokerAgentLogin
else {
agentLogin = .done(success: false, message: "That host can't sign in right now.")
return
}
let desiredPort: UInt16? = provider == .codex ? 1455 : nil
let listener = OAuthRedirectListener.start(port: desiredPort)
let requestID = UUID().uuidString
agentLoginRequestID = requestID
agentLoginHostID = hostID
agentLoginProvider = provider
agentLoginListener = listener
agentLogin = .starting
conn.send(.agentLoginBegin(WireAgentLoginBegin(
requestID: requestID, provider: provider, boundLoopbackPort: listener?.port)))
}
/// Begin against the best host for `sessionID`'s backend — the in-chat "Sign in" CTA on an
/// auth failure. Prefers the session's own host (that's where the turn will retry).
func beginAgentLogin(forSession sessionID: SessionID) {
guard let session = sessions.first(where: { $0.sessionID == sessionID }),
let provider = AgentLoginProvider.forBackend(session.backend)
else { return }
let host = connection(owningSession: sessionID)
?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canBrokerAgentLogin }
guard let host else {
agentLogin = .done(success: false, message: "No connected host can sign in right now.")
return
}
beginAgentLogin(provider: provider, onHost: host.hostID)
}
/// The user pasted the vendor's `code#state` blob (Claude's console fallback) — forward it.
func submitPastedLoginCode(_ code: String) {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
sendAgentLoginCallback(code: trimmed, state: nil)
}
/// The user dismissed the sign-in sheet (or it expired) — tell the host to discard the
/// attempt's PKCE state and reset for the next open. Safe in any state; after a terminal
/// result there's nothing host-side left to discard, so no cancel rides the wire.
func cancelAgentLogin() {
let resolved: Bool
if case .done = agentLogin { resolved = true } else { resolved = false }
if !resolved, let id = agentLoginRequestID, let hostID = agentLoginHostID {
connections[hostID]?.send(.agentLoginCancel(id))
}
agentLoginListener?.stop()
agentLoginListener = nil
agentLoginRequestID = nil
agentLoginHostID = nil
agentLoginProvider = nil
agentLogin = .idle
}
/// The host's consent challenge arrived (correlated by requestID): open the URL and capture
/// as instructed — the armed loopback for `.loopback`, a paste field for `.pasteCode`.
fileprivate func agentLoginChallengeReceived(_ challenge: WireAgentLoginChallenge) {
guard challenge.requestID == agentLoginRequestID,
let url = URL(string: challenge.authorizeURL)
else { return }
switch challenge.capture.mode {
case AgentLoginCapture.loopbackMode:
guard let listener = agentLoginListener, listener.port == challenge.capture.port else {
// The host echoed a port we no longer hold — unrecoverable for this attempt.
cancelAgentLogin()
agentLogin = .done(success: false, message: "The sign-in listener was lost — try again.")
return
}
agentLogin = .browser(url)
let timeout = max(30, challenge.expiresAt.timeIntervalSinceNow)
Task { [weak self] in
guard let callback = await listener.waitForCallback(timeout: timeout) else { return }
self?.agentLoginCaptured(callback)
}
case AgentLoginCapture.pasteCodeMode:
agentLoginListener?.stop()
agentLoginListener = nil
agentLogin = .pasteCode(url)
default:
// A capture shape this app predates — fail gracefully rather than hang the sheet.
cancelAgentLogin()
agentLogin = .done(
success: false, message: "This host needs a newer version of the app.")
}
}
private func agentLoginCaptured(_ callback: OAuthRedirectListener.Callback) {
// Only meaningful while the browser capture is armed; a late redirect after
// cancel/paste is dropped.
guard case .browser = agentLogin else { return }
sendAgentLoginCallback(code: callback.code, state: callback.state)
}
private func sendAgentLoginCallback(code: String, state: String?) {
guard let id = agentLoginRequestID, let hostID = agentLoginHostID,
let conn = connections[hostID]
else { return }
agentLogin = .finishing
conn.send(.agentLoginCallback(WireAgentLoginCallback(
requestID: id, code: code, state: state)))
}
/// The attempt's definitive outcome (correlated by requestID) — surface it; the refreshed
/// per-provider status rides the host's `agentAuthStatus` push separately.
fileprivate func agentLoginResultReceived(_ result: WireAgentLoginResult) {
guard result.requestID == agentLoginRequestID else { return }
agentLoginListener?.stop()
agentLoginListener = nil
agentLoginRequestID = nil
agentLogin = .done(success: result.succeeded, message: result.error)
}
/// Record that the user looked at this session now (clears its unseen-completion wash). /// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) { func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date() lastOpenedAt[sessionID] = Date()
@@ -2280,6 +2449,10 @@ final class RemoteStore: ObservableObject {
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode, .respondMacPair, .requestPairingCode, .cancelPairingCode, .respondMacPair,
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject, .intelligenceResult, .credentialManifest, .credentialProvision, .createProject,
// Remote agent sign-in goes straight to the user-chosen host from
// `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is
// pinned to one host's PKCE state, so owner-routing can never apply.
.agentLoginBegin, .agentLoginCallback, .agentLoginCancel,
// Cast subscriptions are per-connection (each `HostConnection` subscribes on its // Cast subscriptions are per-connection (each `HostConnection` subscribes on its
// own ready, with the merged ledger's cursors) — nothing routes them here. // own ready, with the merged ledger's cursors) — nothing routes them here.
.castSubscribe, .castSubscribe,
@@ -2484,6 +2657,9 @@ final class RemoteStore: ObservableObject {
.intelligenceResult, .credentialManifest, .credentialProvision, .intelligenceResult, .credentialManifest, .credentialProvision,
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
.createProject, .createProject,
// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker
// an OAuth flow; the Agent Accounts section never renders (no status push).
.agentLoginBegin, .agentLoginCallback, .agentLoginCancel,
// Mesh casting (demo has no host to cast). // Mesh casting (demo has no host to cast).
.castSubscribe, .castSubscribe,
// Live composer streaming — demo has no other devices to stream to, and // Live composer streaming — demo has no other devices to stream to, and
@@ -0,0 +1,184 @@
import Foundation
import Network
/// A single-shot localhost HTTP listener that catches an OAuth redirect during a remote agent
/// sign-in (docs/REMOTE_AGENT_LOGIN.md §6) — the phone-side sibling of the Mac's `OAuthLoopback`.
///
/// The browser presenting the vendor's consent page runs ON THIS PHONE, so `localhost` in the
/// vendor's redirect URI resolves here: bind the port *before* asking the host to begin (Codex's
/// OAuth client only permits the fixed `localhost:1455`; Claude accepts any port), then forward
/// the captured `code`/`state` to the host over the E2EE channel. Answers exactly one request
/// with a tiny "return to Nucleic" page, then tears down. Binds 127.0.0.1 only.
final class OAuthRedirectListener: @unchecked Sendable {
struct Callback: Sendable {
let code: String
let state: String?
}
/// The port actually bound (the requested one, or the OS-granted ephemeral port).
let port: UInt16
private let listener: NWListener
private let queue = DispatchQueue(label: "xyz.blakeslee.nucleic.remote.oauth-redirect")
private let lock = NSLock()
private var continuation: CheckedContinuation<Callback?, Never>?
private var pending: Callback?
private var finished = false
private init(listener: NWListener, port: UInt16) {
self.listener = listener
self.port = port
}
/// Bind a loopback port and begin listening. Pass `port` to require a specific one (Codex's
/// fixed 1455); omit it for an ephemeral port (Claude). Returns nil when the OS won't grant
/// it (something else holds 1455) — the caller then begins without a port and the host falls
/// back to a paste capture where the provider supports one.
static func start(port desiredPort: UInt16? = nil) -> OAuthRedirectListener? {
let params = NWParameters.tcp
// Loopback only — this port must never be reachable off-device.
let endpointPort: NWEndpoint.Port =
desiredPort.flatMap { NWEndpoint.Port(rawValue: $0) } ?? .any
params.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
params.allowLocalEndpointReuse = true
guard let listener = try? NWListener(using: params) else { return nil }
let ready = DispatchSemaphore(value: 0)
let portBox = Box<UInt16?>(nil)
listener.stateUpdateHandler = { state in
switch state {
case .ready:
portBox.value = listener.port?.rawValue
ready.signal()
case .failed, .cancelled:
ready.signal()
default:
break
}
}
let instanceBox = Box<OAuthRedirectListener?>(nil)
listener.newConnectionHandler = { connection in
instanceBox.value?.handle(connection)
}
listener.start(queue: DispatchQueue(
label: "xyz.blakeslee.nucleic.remote.oauth-redirect.listen"))
// A loopback bind is immediate; cap the wait so a wedged listener can't hang the flow.
guard ready.wait(timeout: .now() + 2) == .success, let bound = portBox.value else {
listener.cancel()
return nil
}
let instance = OAuthRedirectListener(listener: listener, port: bound)
instanceBox.value = instance
return instance
}
/// Await the captured redirect; nil on timeout or `stop()`. Single-shot — a redirect that
/// raced ahead of this call was buffered and returns immediately.
func waitForCallback(timeout: TimeInterval) async -> Callback? {
let buffered: Callback? = lock.withLock {
if let pending { self.pending = nil; return pending }
return nil
}
if let buffered { return buffered }
return await withTaskGroup(of: Callback?.self) { group in
group.addTask { [self] in
await withCheckedContinuation { cont in
let deliverNow: Callback?? = lock.withLock {
if finished { return .some(nil) }
if let pending { self.pending = nil; return .some(pending) }
continuation = cont
return nil
}
if let deliverNow { cont.resume(returning: deliverNow) }
}
}
group.addTask {
try? await Task.sleep(for: .seconds(max(1, timeout)))
return nil
}
let first = await group.next() ?? nil
group.cancelAll()
self.stop()
return first
}
}
/// Tear down; resumes a pending wait with nil. Idempotent.
func stop() {
let cont: CheckedContinuation<Callback?, Never>? = lock.withLock {
finished = true
let cont = continuation
continuation = nil
return cont
}
listener.cancel()
cont?.resume(returning: nil)
}
// MARK: - One-request HTTP
private func handle(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(connection, buffer: Data())
}
private func receiveRequest(_ connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) {
[weak self] data, _, isComplete, error in
guard let self else { connection.cancel(); return }
var buffer = buffer
if let data { buffer.append(data) }
// The request line + headers end with CRLFCRLF; we only need the request line.
if buffer.range(of: Data("\r\n\r\n".utf8)) != nil || isComplete || error != nil {
self.respond(connection, requestHead: buffer)
} else if buffer.count < 64 * 1024 {
self.receiveRequest(connection, buffer: buffer)
} else {
connection.cancel()
}
}
}
private func respond(_ connection: NWConnection, requestHead: Data) {
let callback = Self.parseCallback(requestHead: requestHead)
let body = callback != nil
? "<html><body style=\"font-family:-apple-system\"><h3>Signed in.</h3>You can return to Nucleic.</body></html>"
: "<html><body style=\"font-family:-apple-system\"><h3>Waiting for sign-in…</h3></body></html>"
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
+ "Content-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n" + body
connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in
connection.cancel()
})
guard let callback else { return }
let cont: CheckedContinuation<Callback?, Never>? = lock.withLock {
guard !finished else { return nil }
if let cont = continuation {
continuation = nil
return cont
}
pending = callback // redirect raced ahead of waitForCallback — buffer it
return nil
}
cont?.resume(returning: callback)
}
/// Pull `code` (+ `state`) out of the redirect's request line: `GET /path?code=…&state=… HTTP/1.1`.
static func parseCallback(requestHead: Data) -> Callback? {
guard let head = String(data: requestHead, encoding: .utf8),
let requestLine = head.components(separatedBy: "\r\n").first
else { return nil }
let parts = requestLine.components(separatedBy: " ")
guard parts.count >= 2,
let components = URLComponents(string: "http://localhost\(parts[1])"),
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
!code.isEmpty
else { return nil }
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
return Callback(code: code, state: state)
}
private final class Box<T>: @unchecked Sendable {
var value: T
init(_ value: T) { self.value = value }
}
}
@@ -11,12 +11,23 @@ import NucleicProtocol
/// transcript survive it. /// transcript survive it.
struct AdaptiveRootView: View { struct AdaptiveRootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@EnvironmentObject var store: RemoteStore
var body: some View { var body: some View {
if sizeClass == .regular { Group {
SplitRootView() if sizeClass == .regular {
} else { SplitRootView()
CompactRootView() } else {
CompactRootView()
}
}
// Remote agent sign-in rides one root-level sheet so every entry point — Settings ▸
// Agent Accounts and the in-chat auth-failure banner — presents the same flow.
.sheet(isPresented: Binding(
get: { store.agentLogin != .idle },
set: { if !$0 { store.cancelAgentLogin() } })
) {
AgentLoginSheet()
} }
} }
} }
@@ -0,0 +1,241 @@
import SwiftUI
import SafariServices
import NucleicProtocol
// Remote agent sign-in, phone UI (docs/REMOTE_AGENT_LOGIN.md §6): the Agent Accounts section in
// Settings (per-host provider status + Sign in), the sheet that drives one attempt (in-app
// Safari + loopback auto-capture, or Claude's paste-a-code fallback), and the in-chat banner
// that offers a sign-in when a turn dies on an auth failure.
/// The Settings ▸ Agent Accounts section: every live host's per-provider install/auth state
/// (from its `agentAuthStatus` push) with a Sign in button where that host can broker the flow.
struct AgentAccountsSection: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
let hosts = store.agentAccountHosts
if !hosts.isEmpty {
Section {
ForEach(hosts, id: \.hostID) { host in
if hosts.count > 1 {
Text(host.name)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
ForEach(host.statuses, id: \.provider.rawValue) { status in
providerRow(status, hostID: host.hostID)
}
}
} header: {
Text("Agent accounts")
} footer: {
Text("Sign-ins run on the host — your Mac or a cloud runner — and sync to every "
+ "device in your mesh. This phone only shows the consent page and relays "
+ "the sign-in code over the encrypted channel.")
}
}
}
@ViewBuilder
private func providerRow(_ status: WireProviderAuthStatus, hostID: String) -> some View {
HStack(spacing: 10) {
Image(systemName: status.authenticated ? "checkmark.seal.fill" : "person.crop.circle.badge.questionmark")
.foregroundStyle(status.authenticated ? Color.green : Color.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(status.name.isEmpty ? status.provider.rawValue.capitalized : status.name)
Text(detail(for: status))
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if status.canBrokerLogin {
Button(status.authenticated ? "Sign in again" : "Sign in") {
store.beginAgentLogin(provider: status.provider, onHost: hostID)
}
.buttonStyle(.borderless)
.font(.callout)
}
}
}
private func detail(for status: WireProviderAuthStatus) -> String {
switch (status.installed, status.authenticated) {
case (true, true):
if let label = status.accountLabel, !label.isEmpty { return "Signed in · \(label)" }
return "Signed in"
case (true, false): return "Installed, not signed in"
case (false, _): return "Not installed on this host"
}
}
}
/// Drives one sign-in attempt end to end, rendering whatever the flow state asks for: a spinner
/// while the host builds the challenge, the vendor's consent page (with the loopback armed for
/// auto-capture, or a paste bar for Claude's console fallback), and the final outcome.
struct AgentLoginSheet: View {
@EnvironmentObject var store: RemoteStore
@State private var pastedCode = ""
private var providerLabel: String {
switch store.agentLoginProvider {
case .some(.claude): "Claude"
case .some(.codex): "Codex"
case .some(let other): other.rawValue.capitalized
case .none: "Agent"
}
}
var body: some View {
NavigationStack {
content
.navigationTitle("Sign in to \(providerLabel)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(isDone ? "Done" : "Cancel") { store.cancelAgentLogin() }
}
}
}
.interactiveDismissDisabled(!isDone)
}
private var isDone: Bool {
if case .done = store.agentLogin { return true }
return false
}
@ViewBuilder
private var content: some View {
switch store.agentLogin {
case .idle, .starting:
ProgressView("Contacting host…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .browser(let url):
SafariView(url: url)
.ignoresSafeArea(edges: .bottom)
case .pasteCode(let url):
SafariView(url: url)
.ignoresSafeArea(edges: .bottom)
.safeAreaInset(edge: .bottom) { pasteBar }
case .finishing:
ProgressView("Completing sign-in…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .done(let success, let message):
VStack(spacing: 12) {
Image(systemName: success ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 44))
.foregroundStyle(success ? Color.green : Color.orange)
Text(success
? "\(providerLabel) is signed in. Every device in your mesh can use it."
: (message ?? "Sign-in did not complete."))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
Button("Done") { store.cancelAgentLogin() }
.buttonStyle(.borderedProminent)
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Claude's console fallback renders a `code#state` blob on the consent page — the user
/// copies it there and pastes it here; the host does the exchange.
private var pasteBar: some View {
HStack(spacing: 8) {
TextField("Paste the code shown by the sign-in page", text: $pastedCode)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
Button("Submit") {
store.submitPastedLoginCode(pastedCode)
}
.buttonStyle(.borderedProminent)
.disabled(pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(10)
.background(.bar)
}
}
/// In-app Safari for the vendor's consent page. `SFSafariViewController` (not
/// `ASWebAuthenticationSession`) on purpose: its callback API can't intercept a plain
/// `http://localhost` redirect, and keeping the app foreground keeps the loopback listener and
/// the host socket alive for the whole round-trip — Safari-on-device resolves `localhost` to
/// this phone, which is the entire capture trick.
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let controller = SFSafariViewController(url: url)
controller.dismissButtonStyle = .cancel
return controller
}
func updateUIViewController(_ controller: SFSafariViewController, context: Context) {}
}
/// The auth-failure matcher the in-chat banner keys on — mirrors the Mac's
/// `TranscriptRow.isAuthError` so both surfaces light up on the same failures.
enum AgentAuthErrors {
static func isAuthError(_ text: String) -> Bool {
let lowered = text.lowercased()
return lowered.contains("401")
|| lowered.contains("authentication_error")
|| lowered.contains("authentication error")
|| lowered.contains("not logged in")
|| lowered.contains("oauth token has expired")
|| lowered.contains("please run /login")
|| lowered.contains("invalid api key")
|| lowered.contains("credential")
&& lowered.contains("expired")
}
/// Whether the tail of a transcript ended on an auth failure — the banner's trigger. Only
/// the events after the last completed run matter: a re-auth mid-history shouldn't nag.
static func transcriptNeedsLogin(_ events: [AgentEvent]) -> Bool {
for event in events.suffix(30).reversed() {
switch event.kind {
case .runFinished(let finished):
guard finished.outcome == .errored else { return false }
return finished.finalText.map(isAuthError) ?? false
case .error(let error):
if isAuthError(error.message) { return true }
default:
continue
}
}
return false
}
}
/// The in-chat re-auth affordance: shown above the composer when the open session's last run
/// died on an auth failure and a connected host can broker the matching provider's sign-in.
struct AgentAuthErrorBanner: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
let backend: BackendID
var body: some View {
if AgentLoginProvider.forBackend(backend) != nil,
AgentAuthErrors.transcriptNeedsLogin(store.openEvents)
{
HStack(spacing: 10) {
Image(systemName: "key.fill")
.foregroundStyle(.orange)
Text("The agent isn't signed in.")
.font(.callout)
Spacer()
Button("Sign in") {
store.beginAgentLogin(forSession: sessionID)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 12)
}
}
}
@@ -363,6 +363,11 @@ struct SessionDetailView: View {
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
// disabled composer reads as "offline / read-only history" rather than broken. // disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner } if !store.connectivity.isLive { disconnectedBanner }
// The last run died on an auth failure and the host can broker a sign-in — the
// phone analogue of the Mac's in-transcript "Log in" row (docs/REMOTE_AGENT_LOGIN.md).
if let summary {
AgentAuthErrorBanner(sessionID: sessionID, backend: summary.backend)
}
actionContent actionContent
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -103,6 +103,10 @@ struct SettingsView: View {
.id(pairedHostsToken) .id(pairedHostsToken)
} }
// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md): each connected host's
// per-provider auth state, with Sign in where that host brokers the flow.
AgentAccountsSection()
Section("This device") { Section("This device") {
LabeledContent("Name", value: deviceName) LabeledContent("Name", value: deviceName)
Button("Rename device") { Button("Rename device") {