From 847460600fbe353d8e50464aee484dfe25dd7755 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 16:43:21 -0700 Subject: [PATCH] feat(macos): redesign device-code sign-in (#104766) * feat(macos): redesign device-code sign-in * chore(i18n): refresh native string inventory * fix(macos): localize device-code completion action * fix(auth): preserve device-code fallback copy * fix(xai): report browser launch accurately --- apps/.i18n/native-source.json | 78 ++++++++++++++----- .../OpenClaw/OnboardingAISetupView.swift | 63 ++++++++++++++- .../OnboardingAISetupTests.swift | 17 ++++ .../OpenClawProtocol/GatewayModels.swift | 6 +- .../OpenClawProtocol/WizardHelpers.swift | 27 +++++++ extensions/xai/xai-oauth.test.ts | 14 +++- extensions/xai/xai-oauth.ts | 24 ++++-- .../gateway-protocol/src/schema/wizard.ts | 10 +++ src/wizard/prompts.ts | 9 +++ src/wizard/session.test.ts | 26 +++++++ src/wizard/session.ts | 31 ++++++++ 11 files changed, 271 insertions(+), 34 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 8428d8920525..0432aa6cc145 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -19451,7 +19451,7 @@ }, { "kind": "ui-call", - "line": 509, + "line": 518, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Open sign-in page…", "surface": "apple", @@ -19459,7 +19459,7 @@ }, { "kind": "ui-call", - "line": 516, + "line": 525, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Starting secure sign-in…", "surface": "apple", @@ -19467,7 +19467,7 @@ }, { "kind": "ui-named-argument", - "line": 522, + "line": 531, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Sign-in didn’t complete", "surface": "apple", @@ -19475,7 +19475,7 @@ }, { "kind": "ui-call", - "line": 532, + "line": 541, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Cancel", "surface": "apple", @@ -19483,7 +19483,39 @@ }, { "kind": "ui-call", - "line": 572, + "line": 564, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "Finish in your browser", + "surface": "apple", + "id": "native.apple.012f9f442abb9624" + }, + { + "kind": "ui-call", + "line": 580, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "Copy", + "surface": "apple", + "id": "native.apple.d9f2c3523534fd83" + }, + { + "kind": "ui-call", + "line": 591, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "Expires in \\(minutes) minutes", + "surface": "apple", + "id": "native.apple.acb7041e820bfb87" + }, + { + "kind": "ui-call", + "line": 598, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "Open sign-in page", + "surface": "apple", + "id": "native.apple.3e537aee648f41a5" + }, + { + "kind": "ui-call", + "line": 626, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Option", "surface": "apple", @@ -19491,15 +19523,23 @@ }, { "kind": "ui-call", - "line": 578, + "line": 632, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Confirm", "surface": "apple", "id": "native.apple.675c1390266b5ccb" }, + { + "kind": "ui-localized-call", + "line": 641, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "I've signed in", + "surface": "apple", + "id": "native.apple.707648a54749ed5b" + }, { "kind": "conditional-branch", - "line": 586, + "line": 643, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Continue", "surface": "apple", @@ -19507,7 +19547,7 @@ }, { "kind": "conditional-branch", - "line": 586, + "line": 643, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Submit", "surface": "apple", @@ -19515,7 +19555,7 @@ }, { "kind": "ui-call", - "line": 591, + "line": 648, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect with an API key or token", "surface": "apple", @@ -19523,7 +19563,7 @@ }, { "kind": "ui-call", - "line": 594, + "line": 651, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Provider", "surface": "apple", @@ -19531,7 +19571,7 @@ }, { "kind": "ui-call", - "line": 602, + "line": 659, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "API key or token", "surface": "apple", @@ -19539,7 +19579,7 @@ }, { "kind": "ui-call", - "line": 614, + "line": 671, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect", "surface": "apple", @@ -19547,7 +19587,7 @@ }, { "kind": "ui-named-argument", - "line": 627, + "line": 684, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "That key didn’t work", "surface": "apple", @@ -19555,7 +19595,7 @@ }, { "kind": "ui-call", - "line": 653, + "line": 710, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Crestodian — setup helper", "surface": "apple", @@ -19563,7 +19603,7 @@ }, { "kind": "ui-call", - "line": 656, + "line": 713, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Done", "surface": "apple", @@ -19571,7 +19611,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 774, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Open help…", "surface": "apple", @@ -19579,7 +19619,7 @@ }, { "kind": "conditional-branch", - "line": 756, + "line": 813, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Hide details", "surface": "apple", @@ -19587,7 +19627,7 @@ }, { "kind": "conditional-branch", - "line": 756, + "line": 813, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Show details", "surface": "apple", @@ -19595,7 +19635,7 @@ }, { "kind": "ui-call", - "line": 779, + "line": 836, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Copy error", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift index 5f8b5857feb1..f449873c1f61 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift @@ -494,10 +494,17 @@ struct OnboardingAISetupView: View { } if let step = self.model.authStep { - if let title = step.title, !title.isEmpty { + let deviceCode = parseWizardDeviceCode(step.devicecode) + if deviceCode == nil, + let title = step.title, + !title.isEmpty, + title != self.model.activeAuthOption?.label + { Text(title).font(.headline) } - if let message = step.message, !message.isEmpty { + if let deviceCode { + self.deviceCodeStep(deviceCode) + } else if let message = step.message, !message.isEmpty { ScrollView { Text(message) .textSelection(.enabled) @@ -505,7 +512,9 @@ struct OnboardingAISetupView: View { } .frame(maxHeight: 190) } - if let url = OnboardingProviderAuthLink.safeURL(step.externalurl) { + if deviceCode == nil, + let url = OnboardingProviderAuthLink.safeURL(step.externalurl) + { Link("Open sign-in page…", destination: url) .font(.caption.weight(.semibold)) } @@ -549,6 +558,51 @@ struct OnboardingAISetupView: View { } } + private func deviceCodeStep(_ deviceCode: WizardDeviceCodePresentation) -> some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text("Finish in your browser") + .font(.headline) + Text(deviceCode.message ?? "Enter this one-time code on the provider's sign-in page.") + .font(.callout) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Text(deviceCode.code) + .font(.system(.title2, design: .monospaced).weight(.semibold)) + .textSelection(.enabled) + Spacer(minLength: 8) + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(deviceCode.code, forType: .string) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + + HStack(spacing: 12) { + if let minutes = deviceCode.expiresInMinutes { + Label("Expires in \(minutes) minutes", systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + if let url = OnboardingProviderAuthLink.safeURL(self.model.authStep?.externalurl) { + Link(destination: url) { + Label("Open sign-in page", systemImage: "arrow.up.right.square") + } + .font(.caption.weight(.semibold)) + } + } + } + } + private func openProviderAuthURLIfNeeded(_ rawURL: String?) { guard let url = OnboardingProviderAuthLink.safeURL(rawURL), url != openedProviderAuthURL @@ -583,6 +637,9 @@ struct OnboardingAISetupView: View { private var authContinueTitle: String { guard let step = model.authStep else { return "Continue" } + if parseWizardDeviceCode(step.devicecode) != nil { + return String(localized: "I've signed in") + } return wizardStepType(step) == "note" ? "Continue" : "Submit" } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index 4f8dfd2ef601..63bfc599083a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -588,6 +588,23 @@ struct OnboardingAISetupTests { #expect(OnboardingProviderIcon.resourceURL(for: "gemini-cli") == nil) } + @Test func `device code presentation decodes structured wizard metadata`() throws { + let presentation = try #require(parseWizardDeviceCode([ + "code": AnyCodable("ABCD-1234"), + "expiresInMinutes": AnyCodable(15), + "message": AnyCodable("Enter this code in your browser."), + ])) + + #expect(presentation.code == "ABCD-1234") + #expect(presentation.expiresInMinutes == 15) + #expect(presentation.message == "Enter this code in your browser.") + #expect(parseWizardDeviceCode(["code": AnyCodable("")]) == nil) + #expect(parseWizardDeviceCode([ + "code": AnyCodable("ABCD-1234"), + "expiresInMinutes": AnyCodable(1e100), + ])?.expiresInMinutes == nil) + } + @Test func `provider auth transport outlives device code windows`() { #expect(OnboardingAISetupModel.providerAuthRequestTimeoutMs > 15 * 60 * 1000) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3657a10e1dd7..789168285a10 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5343,6 +5343,7 @@ public struct WizardStep: Codable, Sendable { public let sensitive: Bool? public let executor: AnyCodable? public let externalurl: String? + public let devicecode: [String: AnyCodable]? public init( id: String, @@ -5355,7 +5356,8 @@ public struct WizardStep: Codable, Sendable { placeholder: String? = nil, sensitive: Bool? = nil, executor: AnyCodable? = nil, - externalurl: String? = nil) + externalurl: String? = nil, + devicecode: [String: AnyCodable]? = nil) { self.id = id self.type = type @@ -5368,6 +5370,7 @@ public struct WizardStep: Codable, Sendable { self.sensitive = sensitive self.executor = executor self.externalurl = externalurl + self.devicecode = devicecode } private enum CodingKeys: String, CodingKey { @@ -5382,6 +5385,7 @@ public struct WizardStep: Codable, Sendable { case sensitive case executor case externalurl = "externalUrl" + case devicecode = "deviceCode" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift index 2580f2b88789..050012c8525d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift @@ -12,6 +12,33 @@ public struct WizardOption: Sendable { } } +public struct WizardDeviceCodePresentation: Sendable { + public let code: String + public let expiresInMinutes: Int? + public let message: String? +} + +public func parseWizardDeviceCode( + _ raw: [String: AnyCodable]?) -> WizardDeviceCodePresentation? +{ + guard let code = raw?["code"]?.value as? String, !code.isEmpty else { return nil } + let allowedMinutes = 1...1440 + let expiresInMinutes: Int? = switch raw?["expiresInMinutes"]?.value { + case let value as Int where allowedMinutes.contains(value): value + case let value as Double: + if let exact = Int(exactly: value), allowedMinutes.contains(exact) { + exact + } else { + nil + } + default: nil + } + return WizardDeviceCodePresentation( + code: code, + expiresInMinutes: expiresInMinutes, + message: raw?["message"]?.value as? String) +} + public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { guard let raw else { return nil } do { diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index 635ef71cdfee..522e3f1e7513 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -457,7 +457,7 @@ describe("xAI OAuth", () => { }), ); vi.stubGlobal("fetch", fetchImpl); - const note = vi.fn<(message: string, title?: string) => Promise>(async () => {}); + const deviceCode = vi.fn(async () => {}); const openUrl = vi.fn(async () => {}); const log = vi.fn(); const runtime = { ...createRuntimeEnv(), log }; @@ -467,7 +467,7 @@ describe("xAI OAuth", () => { openUrl, prompter: createTestWizardPrompter({ progress: vi.fn(() => progress), - note, + deviceCode, }), runtime, oauth: { @@ -480,7 +480,15 @@ describe("xAI OAuth", () => { const result = await loginXaiDeviceCode(ctx); expect(openUrl).toHaveBeenCalledWith("https://accounts.x.ai/oauth2/device?user_code=ABCD-1234"); - expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI OAuth"); + expect(deviceCode).toHaveBeenCalledWith({ + title: "xAI OAuth", + code: "ABCD-1234", + expiresInMinutes: 15, + message: "Enter this one-time code on the xAI sign-in page.", + }); + expect(openUrl.mock.invocationCallOrder[0]).toBeLessThan( + deviceCode.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ); const remoteLog = log.mock.calls[0]?.[0]; expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device"); expect(remoteLog).not.toContain("ABCD-1234"); diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index de92edc3150b..aa3ce62b885c 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -590,6 +590,15 @@ async function noteXaiDeviceCode( deviceCode: XaiDeviceCodeResponse, ): Promise { const expiresInMinutes = Math.max(1, Math.round(deviceCode.expiresInMs / 60_000)); + if (ctx.prompter.deviceCode) { + await ctx.prompter.deviceCode({ + title: "xAI OAuth", + code: deviceCode.userCode, + expiresInMinutes, + message: "Enter this one-time code on the xAI sign-in page.", + }); + return; + } await ctx.prompter.note( [ ctx.isRemote @@ -615,20 +624,19 @@ export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise void; }; +export type WizardDeviceCodeParams = { + title: string; + code: string; + expiresInMinutes?: number; + message?: string; +}; + export type WizardPrompter = { intro: (title: string) => Promise; outro: (message: string) => Promise; note: (message: string, title?: string) => Promise; + /** Present a browser device code as structured UI when the client supports it. */ + deviceCode?: (params: WizardDeviceCodeParams) => Promise; plain?: (message: string) => Promise; select: (params: WizardSelectParams) => Promise; multiselect: (params: WizardMultiSelectParams) => Promise; diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 1fae68548588..d9752ff7f51d 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -81,6 +81,32 @@ describe("WizardSession", () => { expect((await session.next()).status).toBe("done"); }); + test("carries device-code presentation without parsing provider prose", async () => { + const session = new WizardSession(async (prompter) => { + await prompter.openUrl?.("https://provider.example/device"); + await prompter.deviceCode?.({ + title: "Provider sign-in", + code: "ABCD-1234", + expiresInMinutes: 15, + message: "Enter this one-time code in your browser.", + }); + }); + + const first = await session.next(); + expect(first.step).toMatchObject({ + type: "note", + title: "Provider sign-in", + message: + "Enter this one-time code in your browser.\nCode: ABCD-1234\nCode expires in 15 minutes. Never share it.", + externalUrl: "https://provider.example/device", + deviceCode: { + code: "ABCD-1234", + expiresInMinutes: 15, + message: "Enter this one-time code in your browser.", + }, + }); + }); + test("invalid answers throw", async () => { const session = noteRunner(); const first = await session.next(); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index dc2a7092ef9b..f5aa76b1dcf7 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -23,6 +23,11 @@ export type WizardStep = { sensitive?: boolean; executor?: "gateway" | "client"; externalUrl?: string; + deviceCode?: { + code: string; + expiresInMinutes?: number; + message?: string; + }; }; type WizardSessionStatus = "running" | "done" | "cancelled" | "error"; @@ -72,6 +77,32 @@ class WizardSessionPrompter implements WizardPrompter { await this.prompt({ type: "note", title, message, executor: "client" }); } + async deviceCode(params: { + title: string; + code: string; + expiresInMinutes?: number; + message?: string; + }): Promise { + const fallbackMessage = [ + params.message ?? "Enter this one-time code on the provider's sign-in page.", + `Code: ${params.code}`, + ...(params.expiresInMinutes + ? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`] + : []), + ].join("\n"); + await this.prompt({ + type: "note", + title: params.title, + message: fallbackMessage, + deviceCode: { + code: params.code, + ...(params.expiresInMinutes ? { expiresInMinutes: params.expiresInMinutes } : {}), + ...(params.message ? { message: params.message } : {}), + }, + executor: "client", + }); + } + async plain(message: string): Promise { await this.prompt({ type: "note", message, format: "plain", executor: "client" }); }