Files
openclaw/src/wizard/session.ts
Peter Steinberger 8310c565e0 feat(onboarding): add provider sign-in (#104502)
* feat(onboarding): add provider sign-in flows

* fix(oauth): keep callback compatibility

* fix(onboarding): reconcile lost auth outcomes

* fix(onboarding): lock auth cancellation at commit

* fix(onboarding): close provider auth lifecycle gaps

* fix(onboarding): make terminal auth failures dismissable

* fix(onboarding): satisfy native app checks

* fix(onboarding): reconcile absent auth sessions

* fix(onboarding): bound provider auth sessions

* fix(onboarding): open provider auth links safely

* test(onboarding): use scanner-safe auth fixtures

* revert: keep established onboarding auth fixtures

* fix(onboarding): close provider auth cancellation gaps

* fix(gateway): retain uncollected wizard results

* fix(onboarding): bind provider reconciliation attempt

* fix(i18n): avoid guessing moved string identities

* style(onboarding): normalize remote auth choices efficiently

* fix(protocol): refresh optional provider auth choices

* test(gateway): cover provider auth dispatch order

* refactor(macos): split onboarding setup support

* fix(macos): refresh merged native checks
2026-07-11 13:00:17 -07:00

357 lines
10 KiB
TypeScript

// Wizard session helpers track onboarding session ids and state.
import { randomUUID } from "node:crypto";
import { createDeferred, type Deferred } from "../shared/deferred.js";
import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js";
// WizardSession exposes interactive setup as a step/answer protocol for remote
// clients while reusing the same WizardPrompter contract as the local CLI.
export type WizardStepOption = {
value: unknown;
label: string;
hint?: string;
};
export type WizardStep = {
id: string;
type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action";
title?: string;
message?: string;
format?: "plain";
options?: WizardStepOption[];
initialValue?: unknown;
placeholder?: string;
sensitive?: boolean;
executor?: "gateway" | "client";
externalUrl?: string;
};
type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
type WizardNextResult = {
done: boolean;
step?: WizardStep;
status: WizardSessionStatus;
error?: string;
};
function normalizeTextAnswer(value: unknown): string | undefined {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
return undefined;
}
class WizardSessionPrompter implements WizardPrompter {
constructor(private session: WizardSession) {}
async intro(title: string): Promise<void> {
await this.prompt({
type: "note",
title,
message: "",
executor: "client",
});
}
async outro(message: string): Promise<void> {
await this.prompt({
type: "note",
title: "Done",
message,
executor: "client",
});
}
async note(message: string, title?: string): Promise<void> {
await this.prompt({ type: "note", title, message, executor: "client" });
}
async plain(message: string): Promise<void> {
await this.prompt({ type: "note", message, format: "plain", executor: "client" });
}
async select<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValue?: T;
}): Promise<T> {
const res = await this.prompt({
type: "select",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValue,
executor: "client",
});
return res as T;
}
async multiselect<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValues?: T[];
}): Promise<T[]> {
const res = await this.prompt({
type: "multiselect",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValues,
executor: "client",
});
return (Array.isArray(res) ? res : []) as T[];
}
async text(params: {
message: string;
initialValue?: string;
placeholder?: string;
validate?: (value: string) => string | undefined;
sensitive?: boolean;
}): Promise<string> {
const res = await this.session.awaitAnswer(
this.createStep({
type: "text",
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
sensitive: params.sensitive,
executor: "client",
}),
params.validate,
);
const value =
res === null || res === undefined
? ""
: typeof res === "string"
? res
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
? String(res)
: "";
return value;
}
async confirm(params: Parameters<WizardPrompter["confirm"]>[0]): Promise<boolean> {
const res = await this.prompt({
type: "confirm",
message: params.message,
initialValue: params.initialValue,
executor: "client",
});
return Boolean(res);
}
progress(_label: string): WizardProgress {
return {
update: (_message) => {},
stop: (_message) => {},
};
}
async openUrl(url: string): Promise<void> {
this.session.queueExternalUrl(url);
}
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
return await this.session.awaitAnswer(this.createStep(step));
}
private createStep(step: Omit<WizardStep, "id">): WizardStep {
// Each emitted step receives an id so remote clients can answer the exact
// pending prompt and stale answers can be rejected. Explicit browser
// destinations bind to the very next step regardless of its input type.
const externalUrl = this.session.consumeExternalUrl();
return {
...step,
...(externalUrl ? { externalUrl } : {}),
id: randomUUID(),
};
}
}
export class WizardSession {
private readonly abortController = new AbortController();
private readonly expiryTimer: ReturnType<typeof setTimeout> | undefined;
private currentStep: WizardStep | null = null;
private stepDeferred: Deferred<WizardStep | null> | null = null;
private pendingTerminalResolution = false;
private cancellationLocked = false;
private pendingExternalUrl: string | undefined;
private answerDeferred = new Map<
string,
{
deferred: Deferred<unknown>;
text: boolean;
validate?: (value: string) => string | undefined;
}
>();
private status: WizardSessionStatus = "running";
private error: string | undefined;
constructor(
private runner: (prompter: WizardPrompter, signal: AbortSignal) => Promise<void>,
options?: { timeoutMs?: number },
) {
const prompter = new WizardSessionPrompter(this);
if (options?.timeoutMs !== undefined) {
this.expiryTimer = setTimeout(() => this.cancel(), options.timeoutMs);
this.expiryTimer.unref?.();
}
void this.run(prompter);
}
async next(): Promise<WizardNextResult> {
if (this.currentStep) {
return { done: false, step: this.currentStep, status: this.status };
}
if (this.pendingTerminalResolution) {
this.pendingTerminalResolution = false;
return { done: true, status: this.status, error: this.error };
}
if (this.status !== "running") {
return { done: true, status: this.status, error: this.error };
}
if (!this.stepDeferred) {
this.stepDeferred = createDeferred();
}
const step = await this.stepDeferred.promise;
if (step) {
return { done: false, step, status: this.status };
}
return { done: true, status: this.status, error: this.error };
}
async answer(stepId: string, value: unknown): Promise<string | undefined> {
const pending = this.answerDeferred.get(stepId);
if (!pending) {
throw new Error("wizard: no pending step");
}
const normalizedValue = pending.text ? normalizeTextAnswer(value) : value;
if (pending.text && normalizedValue === undefined) {
return "wizard: text answer must be a scalar value";
}
const validationError = pending.validate?.(normalizedValue as string) ?? undefined;
if (validationError) {
return validationError;
}
this.answerDeferred.delete(stepId);
this.currentStep = null;
pending.deferred.resolve(normalizedValue);
return undefined;
}
cancel(): boolean {
if (this.status !== "running" || this.cancellationLocked) {
return false;
}
this.status = "cancelled";
this.error = "cancelled";
this.abortController.abort(new WizardCancelledError());
this.currentStep = null;
for (const [, pending] of this.answerDeferred) {
// Reject all pending prompt promises so the runner can unwind through its
// normal cancellation path.
pending.deferred.reject(new WizardCancelledError());
}
this.answerDeferred.clear();
this.resolveStep(null);
return true;
}
/** The underlying mutation crossed its durable commit point and must finish. */
lockCancellation() {
this.cancellationLocked = true;
}
get signal(): AbortSignal {
return this.abortController.signal;
}
pushStep(step: WizardStep) {
this.currentStep = step;
this.resolveStep(step);
}
queueExternalUrl(url: string) {
this.pendingExternalUrl = url;
}
consumeExternalUrl(): string | undefined {
const url = this.pendingExternalUrl;
this.pendingExternalUrl = undefined;
return url;
}
private async run(prompter: WizardPrompter) {
try {
await this.runner(prompter, this.signal);
if (this.status === "running") {
this.status = "done";
}
} catch (err) {
if (this.status !== "running") {
return;
}
if (err instanceof WizardCancelledError) {
this.status = "cancelled";
this.error = err.message;
} else {
this.status = "error";
this.error = String(err);
}
} finally {
if (this.expiryTimer) {
clearTimeout(this.expiryTimer);
}
this.resolveStep(null);
}
}
async awaitAnswer(
step: WizardStep,
validate?: (value: string) => string | undefined,
): Promise<unknown> {
if (this.status !== "running") {
throw new Error("wizard: session not running");
}
this.pushStep(step);
const deferred = createDeferred<unknown>();
this.answerDeferred.set(step.id, { deferred, text: step.type === "text", validate });
return await deferred.promise;
}
private resolveStep(step: WizardStep | null) {
if (!this.stepDeferred) {
if (step === null) {
// The runner can finish immediately after an answer before next() has
// installed a waiter; remember that terminal state for the next poll.
this.pendingTerminalResolution = true;
}
return;
}
const deferred = this.stepDeferred;
this.stepDeferred = null;
deferred.resolve(step);
}
getStatus(): WizardSessionStatus {
return this.status;
}
getError(): string | undefined {
return this.error;
}
}