diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index c637b05..429ca29 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -30,6 +30,9 @@ final class HostConnection { private(set) var modelCatalog: WireModelCatalog = .empty private(set) var dashboard = DashboardSnapshot.empty 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. 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 /// account truth (every paired Mac reports the same value). 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 @@ -812,6 +820,17 @@ final class HostConnection { case .settings(let settings): // The account-level synced settings changed on the host — adopt the new truth. 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): directBox?.deliver(.answer(offer)) case .directGo: diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 2db78ea..eba94e4 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -439,6 +439,33 @@ final class RemoteStore: ObservableObject { /// The host that forwarded the pending confirm, so the answer routes back to it. 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): /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. 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. 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 } @@ -1947,6 +1984,138 @@ final class RemoteStore: ObservableObject { 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). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() @@ -2280,6 +2449,10 @@ final class RemoteStore: ObservableObject { .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, .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 // own ready, with the merged ledger's cursors) — nothing routes them here. .castSubscribe, @@ -2484,6 +2657,9 @@ final class RemoteStore: ObservableObject { .intelligenceResult, .credentialManifest, .credentialProvision, // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. .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). .castSubscribe, // Live composer streaming — demo has no other devices to stream to, and diff --git a/NucleicRemote/NucleicRemote/Net/OAuthRedirectListener.swift b/NucleicRemote/NucleicRemote/Net/OAuthRedirectListener.swift new file mode 100644 index 0000000..717d4e4 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Net/OAuthRedirectListener.swift @@ -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? + 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(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(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? = 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 + ? "

Signed in.

You can return to Nucleic." + : "

Waiting for sign-in…

" + 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? = 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: @unchecked Sendable { + var value: T + init(_ value: T) { self.value = value } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 48bccd8..7f04f25 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -11,12 +11,23 @@ import NucleicProtocol /// transcript survive it. struct AdaptiveRootView: View { @Environment(\.horizontalSizeClass) private var sizeClass + @EnvironmentObject var store: RemoteStore var body: some View { - if sizeClass == .regular { - SplitRootView() - } else { - CompactRootView() + Group { + if sizeClass == .regular { + SplitRootView() + } 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() } } } diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift new file mode 100644 index 0000000..804cd96 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -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) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 1a04309..a424c69 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -363,6 +363,11 @@ struct SessionDetailView: View { // 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. 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 } .padding(.horizontal, 12) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index a9c2547..43966c7 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -103,6 +103,10 @@ struct SettingsView: View { .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") { LabeledContent("Name", value: deviceName) Button("Rename device") {