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
This commit is contained in:
Peter Steinberger
2026-07-11 16:43:21 -07:00
committed by GitHub
parent 0ea7b2a543
commit 847460600f
11 changed files with 271 additions and 34 deletions

View File

@@ -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 didnt 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 didnt 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",

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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 {

View File

@@ -457,7 +457,7 @@ describe("xAI OAuth", () => {
}),
);
vi.stubGlobal("fetch", fetchImpl);
const note = vi.fn<(message: string, title?: string) => Promise<void>>(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");

View File

@@ -590,6 +590,15 @@ async function noteXaiDeviceCode(
deviceCode: XaiDeviceCodeResponse,
): Promise<void> {
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<Prov
...(ctx.signal ? { signal: ctx.signal } : {}),
});
const browserUrl = deviceCode.verificationUriComplete ?? deviceCode.verificationUri;
if (ctx.isRemote) {
let openedBrowser = false;
try {
await ctx.openUrl(browserUrl);
openedBrowser = true;
} catch {
ctx.runtime.log(`Open manually: ${deviceCode.verificationUri}`);
}
await noteXaiDeviceCode(ctx, deviceCode);
const logUrl = deviceCode.verificationUri;
if (ctx.isRemote) {
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${logUrl}\n`);
} else {
try {
await ctx.openUrl(browserUrl);
ctx.runtime.log(`Open: ${logUrl}`);
} catch {
ctx.runtime.log(`Open manually: ${logUrl}`);
}
} else if (openedBrowser) {
ctx.runtime.log(`Open: ${logUrl}`);
}
progress.update("Waiting for xAI device authorization...");

View File

@@ -61,6 +61,15 @@ export const WizardStepOptionSchema = Type.Object(
{ additionalProperties: false },
);
const WizardDeviceCodeSchema = Type.Object(
{
code: NonEmptyString,
expiresInMinutes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1440 })),
message: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
/** UI contract for one wizard step rendered by gateway clients. */
export const WizardStepSchema = Type.Object(
{
@@ -83,6 +92,7 @@ export const WizardStepSchema = Type.Object(
sensitive: Type.Optional(Type.Boolean()),
executor: Type.Optional(Type.Union([Type.Literal("gateway"), Type.Literal("client")])),
externalUrl: Type.Optional(Type.String()),
deviceCode: Type.Optional(WizardDeviceCodeSchema),
},
{ additionalProperties: false },
);

View File

@@ -49,10 +49,19 @@ export type WizardProgress = {
stop: (message?: string) => void;
};
export type WizardDeviceCodeParams = {
title: string;
code: string;
expiresInMinutes?: number;
message?: string;
};
export type WizardPrompter = {
intro: (title: string) => Promise<void>;
outro: (message: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
/** Present a browser device code as structured UI when the client supports it. */
deviceCode?: (params: WizardDeviceCodeParams) => Promise<void>;
plain?: (message: string) => Promise<void>;
select: <T>(params: WizardSelectParams<T>) => Promise<T>;
multiselect: <T>(params: WizardMultiSelectParams<T>) => Promise<T[]>;

View File

@@ -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();

View File

@@ -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<void> {
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<void> {
await this.prompt({ type: "note", message, format: "plain", executor: "client" });
}