410 lines
17 KiB
Swift
410 lines
17 KiB
Swift
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
|
|
/// The row whose "Use API key…" sheet is open — (host, provider, display name).
|
|
@State private var apiKeyTarget: APIKeyTarget?
|
|
/// The mesh-wide deletion awaiting the user's confirm (it tombstones the credential on
|
|
/// EVERY device, so it always confirms first). Nil hides the dialog.
|
|
@State private var deleteTarget: DeleteTarget?
|
|
|
|
struct APIKeyTarget: Identifiable {
|
|
let hostID: String
|
|
let hostName: String
|
|
let provider: AgentLoginProvider
|
|
let providerName: String
|
|
var id: String { hostID + "·" + provider.rawValue }
|
|
}
|
|
|
|
struct DeleteTarget: Identifiable {
|
|
let kind: CredentialKind
|
|
let label: String
|
|
var id: String { kind.rawValue }
|
|
}
|
|
|
|
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, hostName: host.name)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Agent accounts")
|
|
} footer: {
|
|
Text(footerText)
|
|
}
|
|
.sheet(item: $apiKeyTarget) { target in
|
|
APIKeyEntrySheet(target: target)
|
|
}
|
|
.confirmationDialog(
|
|
"Delete the \(deleteTarget?.label ?? "credential") from every device in your mesh?",
|
|
isPresented: Binding(
|
|
get: { deleteTarget != nil },
|
|
set: { if !$0 { deleteTarget = nil } }),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete Everywhere", role: .destructive) {
|
|
guard let target = deleteTarget else { return }
|
|
deleteTarget = nil
|
|
store.revokeCredential(kind: target.kind)
|
|
}
|
|
Button("Cancel", role: .cancel) { deleteTarget = nil }
|
|
} message: {
|
|
Text("Your Macs, cloud runners, and this iPhone all drop it. A tombstone keeps "
|
|
+ "any offline device from bringing it back; signing in again re-enables "
|
|
+ "the provider everywhere.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The section footer: the standard sign-in explainer, plus — once this phone actually
|
|
/// holds mirrored logins — the holder note (an encrypted copy lives here, so a fresh
|
|
/// runner can be credentialed with every Mac asleep).
|
|
private var footerText: String {
|
|
var 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."
|
|
let held = store.phoneVaultKinds.compactMap { kind -> String? in
|
|
switch kind {
|
|
case .claudeOAuth: "Claude"
|
|
case .codexAuth: "Codex"
|
|
default: nil
|
|
}
|
|
}
|
|
if !held.isEmpty {
|
|
text += " This iPhone also keeps an encrypted copy of the "
|
|
+ held.joined(separator: " and ")
|
|
+ " sign-in, so it can credential a fresh runner on its own."
|
|
}
|
|
return text
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func providerRow(
|
|
_ status: WireProviderAuthStatus, hostID: String, hostName: String
|
|
) -> some View {
|
|
let name = status.name.isEmpty ? status.provider.rawValue.capitalized : status.name
|
|
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(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)
|
|
}
|
|
// The ToS-defensive fallback (REMOTE_AGENT_LOGIN §8): set a Console/API key
|
|
// instead of a subscription login. Only for the two key-backed providers, and only
|
|
// when the host can take a sealed key from this phone.
|
|
if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) {
|
|
Button {
|
|
apiKeyTarget = APIKeyTarget(
|
|
hostID: hostID, hostName: hostName,
|
|
provider: status.provider, providerName: name)
|
|
} label: {
|
|
Image(systemName: "key")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityLabel("Use an API key for \(name)")
|
|
}
|
|
}
|
|
.contextMenu {
|
|
// Mesh-wide deletion (key deletion from any device): offered when a host that
|
|
// accepts the tombstone verb is reachable — the deletion then propagates from it
|
|
// to every other member (and this phone clears its own vault copy regardless).
|
|
if status.authenticated, store.canRevokeCredentials(onHost: hostID) {
|
|
if status.method == "apiKey" {
|
|
Button(role: .destructive) {
|
|
deleteTarget = DeleteTarget(
|
|
kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey,
|
|
label: "\(name) API key")
|
|
} label: {
|
|
Label("Delete API Key on All Devices…", systemImage: "trash")
|
|
}
|
|
} else {
|
|
Button(role: .destructive) {
|
|
deleteTarget = DeleteTarget(
|
|
kind: status.provider == .codex ? .codexAuth : .claudeOAuth,
|
|
label: "\(name) sign-in")
|
|
} label: {
|
|
Label("Sign Out on All Devices…", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] }
|
|
|
|
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)" }
|
|
if status.method == "apiKey" { return "Signed in · API key" }
|
|
return "Signed in"
|
|
case (true, false): return "Installed, not signed in"
|
|
case (false, _): return "Not installed on this host"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SecureField entry for a provider API key, sealed straight to the chosen host
|
|
/// (REMOTE_AGENT_LOGIN §8). The phone never stores the key; confirmation is implicit — the
|
|
/// provider row flips to "Signed in · API key" when the host's refreshed status push lands.
|
|
private struct APIKeyEntrySheet: View {
|
|
@EnvironmentObject var store: RemoteStore
|
|
@Environment(\.dismiss) private var dismiss
|
|
let target: AgentAccountsSection.APIKeyTarget
|
|
|
|
@State private var key = ""
|
|
@State private var sent = false
|
|
@State private var failed = false
|
|
|
|
private var keyName: String {
|
|
target.provider == .claude ? "Anthropic API key" : "OpenAI API key"
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if sent {
|
|
Section {
|
|
Label("Key sent to \(target.hostName)", systemImage: "checkmark.seal.fill")
|
|
.foregroundStyle(.green)
|
|
Text("The \(target.providerName) row will show “API key” once it lands.")
|
|
.font(.footnote).foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Section {
|
|
SecureField(keyName, text: $key)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
if failed {
|
|
Text("Couldn't send the key — the host may have disconnected. Try again.")
|
|
.font(.footnote).foregroundStyle(.red)
|
|
}
|
|
} footer: {
|
|
Text("Sealed to \(target.hostName) over the encrypted channel and stored "
|
|
+ "there — never on this phone. Replaces any key already set.")
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("\(target.providerName) API key")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(sent ? "Done" : "Cancel") { dismiss() }
|
|
}
|
|
if !sent {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") {
|
|
let ok = store.submitAPIKey(
|
|
key, provider: target.provider, toHost: target.hostID)
|
|
sent = ok
|
|
failed = !ok
|
|
}
|
|
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|