From 44bbd2d83d34c7619950c6a6da94cdaf2bee2eb2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 15:01:04 -0700 Subject: [PATCH] refactor: split control ui gateway connect flow --- ui/src/ui/gateway.node.test.ts | 206 ++++++++---------- ui/src/ui/gateway.ts | 377 +++++++++++++++++++++------------ 2 files changed, 334 insertions(+), 249 deletions(-) diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 264daa6cde7..b7ac079e9f8 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -79,7 +79,17 @@ vi.mock("./device-identity.ts", () => ({ signDevicePayload: signDevicePayloadMock, })); -const { GatewayBrowserClient } = await import("./gateway.ts"); +const { CONTROL_UI_OPERATOR_SCOPES, GatewayBrowserClient, shouldRetryWithDeviceToken } = + await import("./gateway.ts"); + +type ConnectFrame = { + id?: string; + method?: string; + params?: { + auth?: { token?: string; password?: string; deviceToken?: string }; + scopes?: string[]; + }; +}; function createStorageMock(): Storage { const store = new Map(); @@ -119,6 +129,23 @@ function stubInsecureCrypto() { }); } +function parseLatestConnectFrame(ws: MockWebSocket): ConnectFrame { + return JSON.parse(ws.sent.at(-1) ?? "{}") as ConnectFrame; +} + +async function startConnect(client: InstanceType, nonce = "nonce-1") { + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce }, + }); + await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + return { ws, connectFrame: parseLatestConnectFrame(ws) }; +} + describe("GatewayBrowserClient", () => { beforeEach(() => { const storage = createStorageMock(); @@ -143,13 +170,7 @@ describe("GatewayBrowserClient", () => { deviceId: "device-1", role: "operator", token: "stored-device-token", - scopes: [ - "operator.admin", - "operator.read", - "operator.write", - "operator.approvals", - "operator.pairing", - ], + scopes: [...CONTROL_UI_OPERATOR_SCOPES], }); }); @@ -158,27 +179,26 @@ describe("GatewayBrowserClient", () => { vi.unstubAllGlobals(); }); + it("requests the full control ui operator scope bundle on connect", async () => { + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + + const { connectFrame } = await startConnect(client); + + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); + }); + it("prefers explicit shared auth over cached device tokens", async () => { const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", token: "shared-auth-token", }); - client.start(); - const ws = getLatestWebSocket(); - ws.emitOpen(); - ws.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + const { connectFrame } = await startConnect(client); - const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { - id?: string; - method?: string; - params?: { auth?: { token?: string } }; - }; expect(typeof connectFrame.id).toBe("string"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth?.token).toBe("shared-auth-token"); @@ -195,21 +215,8 @@ describe("GatewayBrowserClient", () => { token: "shared-auth-token", }); - client.start(); - const ws = getLatestWebSocket(); - ws.emitOpen(); - ws.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + const { connectFrame } = await startConnect(client); - const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { - id?: string; - method?: string; - params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; - }; expect(connectFrame.id).toBe("req-insecure"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth).toEqual({ @@ -228,21 +235,8 @@ describe("GatewayBrowserClient", () => { password: "shared-password", // pragma: allowlist secret }); - client.start(); - const ws = getLatestWebSocket(); - ws.emitOpen(); - ws.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + const { connectFrame } = await startConnect(client); - const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { - id?: string; - method?: string; - params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; - }; expect(connectFrame.id).toBe("req-insecure"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth).toEqual({ @@ -259,21 +253,8 @@ describe("GatewayBrowserClient", () => { url: "ws://127.0.0.1:18789", }); - client.start(); - const ws = getLatestWebSocket(); - ws.emitOpen(); - ws.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + const { connectFrame } = await startConnect(client); - const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { - id?: string; - method?: string; - params?: { auth?: { token?: string } }; - }; expect(typeof connectFrame.id).toBe("string"); expect(connectFrame.method).toBe("connect"); expect(connectFrame.params?.auth?.token).toBe("stored-device-token"); @@ -289,19 +270,7 @@ describe("GatewayBrowserClient", () => { token: "shared-auth-token", }); - client.start(); - const ws1 = getLatestWebSocket(); - ws1.emitOpen(); - ws1.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); - const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { - id: string; - params?: { auth?: { token?: string; deviceToken?: string } }; - }; + const { ws: ws1, connectFrame: firstConnect } = await startConnect(client); expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); @@ -328,10 +297,7 @@ describe("GatewayBrowserClient", () => { payload: { nonce: "nonce-2" }, }); await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); - const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { - id: string; - params?: { auth?: { token?: string; deviceToken?: string } }; - }; + const secondConnect = parseLatestConnectFrame(ws2); expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); @@ -363,19 +329,7 @@ describe("GatewayBrowserClient", () => { token: "shared-auth-token", }); - client.start(); - const ws1 = getLatestWebSocket(); - ws1.emitOpen(); - ws1.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); - const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { - id: string; - params?: { auth?: { token?: string; deviceToken?: string } }; - }; + const { ws: ws1, connectFrame: firstConnect } = await startConnect(client); expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); @@ -402,9 +356,7 @@ describe("GatewayBrowserClient", () => { payload: { nonce: "nonce-2" }, }); await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0)); - const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as { - params?: { auth?: { token?: string; deviceToken?: string } }; - }; + const secondConnect = parseLatestConnectFrame(ws2); expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token"); @@ -421,16 +373,7 @@ describe("GatewayBrowserClient", () => { token: "shared-auth-token", }); - client.start(); - const ws1 = getLatestWebSocket(); - ws1.emitOpen(); - ws1.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); - const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string }; + const { ws: ws1, connectFrame: firstConnect } = await startConnect(client); ws1.emitMessage({ type: "res", @@ -460,16 +403,7 @@ describe("GatewayBrowserClient", () => { url: "ws://127.0.0.1:18789", }); - client.start(); - const ws1 = getLatestWebSocket(); - ws1.emitOpen(); - ws1.emitMessage({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-1" }, - }); - await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0)); - const connect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string }; + const { ws: ws1, connectFrame: connect } = await startConnect(client); ws1.emitMessage({ type: "res", @@ -490,3 +424,41 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); }); + +describe("shouldRetryWithDeviceToken", () => { + it("allows a bounded retry for trusted loopback endpoints", () => { + expect( + shouldRetryWithDeviceToken({ + deviceTokenRetryBudgetUsed: false, + authDeviceToken: undefined, + explicitGatewayToken: "shared-auth-token", + deviceIdentity: { + deviceId: "device-1", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }, + storedToken: "stored-device-token", + canRetryWithDeviceTokenHint: true, + url: "ws://127.0.0.1:18789", + }), + ).toBe(true); + }); + + it("blocks the retry after the one-shot budget is spent", () => { + expect( + shouldRetryWithDeviceToken({ + deviceTokenRetryBudgetUsed: true, + authDeviceToken: undefined, + explicitGatewayToken: "shared-auth-token", + deviceIdentity: { + deviceId: "device-1", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }, + storedToken: "stored-device-token", + canRetryWithDeviceTokenHint: true, + url: "ws://127.0.0.1:18789", + }), + ).toBe(false); + }); +}); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 62f164fb149..28d107d6aaf 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -128,6 +128,72 @@ type SelectedConnectAuth = { canFallbackToShared: boolean; }; +export const CONTROL_UI_OPERATOR_ROLE = "operator"; + +export const CONTROL_UI_OPERATOR_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] as const; + +export type GatewayConnectAuth = { + token?: string; + deviceToken?: string; + password?: string; +}; + +export type GatewayConnectDevice = { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string; +}; + +export type GatewayConnectClientInfo = { + id: GatewayClientName; + version: string; + platform: string; + mode: GatewayClientMode; + instanceId?: string; +}; + +export type GatewayConnectParams = { + minProtocol: 3; + maxProtocol: 3; + client: GatewayConnectClientInfo; + role: string; + scopes: string[]; + device?: GatewayConnectDevice; + caps: string[]; + auth?: GatewayConnectAuth; + userAgent: string; + locale: string; +}; + +type ConnectPlan = { + role: string; + scopes: string[]; + client: GatewayConnectClientInfo; + explicitGatewayToken?: string; + selectedAuth: SelectedConnectAuth; + auth?: GatewayConnectAuth; + deviceIdentity: Awaited> | null; + device?: GatewayConnectDevice; +}; + +type DeviceTokenRetryDecision = { + deviceTokenRetryBudgetUsed: boolean; + authDeviceToken?: string; + explicitGatewayToken?: string; + deviceIdentity: Awaited> | null; + storedToken?: string; + canRetryWithDeviceTokenHint: boolean; + url: string; +}; + export type GatewayBrowserClientOptions = { url: string; token?: string; @@ -146,6 +212,66 @@ export type GatewayBrowserClientOptions = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; +function buildGatewayConnectAuth( + selectedAuth: SelectedConnectAuth, +): GatewayConnectAuth | undefined { + const authToken = selectedAuth.authToken; + if (!(authToken || selectedAuth.authPassword)) { + return undefined; + } + return { + token: authToken, + deviceToken: selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken, + password: selectedAuth.authPassword, + }; +} + +async function buildGatewayConnectDevice(params: { + deviceIdentity: Awaited> | null; + client: GatewayConnectClientInfo; + role: string; + scopes: string[]; + authToken?: string; + connectNonce: string | null; +}): Promise { + const { deviceIdentity } = params; + if (!deviceIdentity) { + return undefined; + } + const signedAtMs = Date.now(); + const nonce = params.connectNonce ?? ""; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: params.client.id, + clientMode: params.client.mode, + role: params.role, + scopes: params.scopes, + signedAtMs, + token: params.authToken ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + return { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }; +} + +export function shouldRetryWithDeviceToken(params: DeviceTokenRetryDecision): boolean { + return ( + !params.deviceTokenRetryBudgetUsed && + !params.authDeviceToken && + Boolean(params.explicitGatewayToken) && + Boolean(params.deviceIdentity) && + Boolean(params.storedToken) && + params.canRetryWithDeviceTokenHint && + isTrustedRetryEndpoint(params.url) + ); +} + export class GatewayBrowserClient { private ws: WebSocket | null = null; private pending = new Map(); @@ -227,31 +353,42 @@ export class GatewayBrowserClient { this.pending.clear(); } - private async sendConnect() { - if (this.connectSent) { - return; - } - this.connectSent = true; - if (this.connectTimer !== null) { - window.clearTimeout(this.connectTimer); - this.connectTimer = null; - } + private buildConnectClient(): GatewayConnectClientInfo { + return { + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: this.opts.clientVersion ?? "control-ui", + platform: this.opts.platform ?? navigator.platform ?? "web", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + instanceId: this.opts.instanceId, + }; + } + + private buildConnectParams(plan: ConnectPlan): GatewayConnectParams { + return { + minProtocol: 3, + maxProtocol: 3, + client: plan.client, + role: plan.role, + scopes: plan.scopes, + device: plan.device, + caps: ["tool-events"], + auth: plan.auth, + userAgent: navigator.userAgent, + locale: navigator.language, + }; + } + + private async buildConnectPlan(): Promise { + const role = CONTROL_UI_OPERATOR_ROLE; + const scopes = [...CONTROL_UI_OPERATOR_SCOPES]; + const client = this.buildConnectClient(); + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitPassword = this.opts.password?.trim() || undefined; // crypto.subtle is only available in secure contexts (HTTPS, localhost). // Over plain HTTP, we skip device identity and fall back to token-only auth. // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; - - const scopes = [ - "operator.admin", - "operator.read", - "operator.write", - "operator.approvals", - "operator.pairing", - ]; - const role = "operator"; - const explicitGatewayToken = this.opts.token?.trim() || undefined; - const explicitPassword = this.opts.password?.trim() || undefined; let deviceIdentity: Awaited> | null = null; let selectedAuth: SelectedConnectAuth = { authToken: explicitGatewayToken, @@ -269,124 +406,100 @@ export class GatewayBrowserClient { this.pendingDeviceTokenRetry = false; } } - const authToken = selectedAuth.authToken; - const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken; - const auth = - authToken || selectedAuth.authPassword - ? { - token: authToken, - deviceToken, - password: selectedAuth.authPassword, - } - : undefined; - let device: - | { - id: string; - publicKey: string; - signature: string; - signedAt: number; - nonce: string; - } - | undefined; - - if (isSecureContext && deviceIdentity) { - const signedAtMs = Date.now(); - const nonce = this.connectNonce ?? ""; - const payload = buildDeviceAuthPayload({ - deviceId: deviceIdentity.deviceId, - clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, - role, - scopes, - signedAtMs, - token: authToken ?? null, - nonce, - }); - const signature = await signDevicePayload(deviceIdentity.privateKey, payload); - device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKey, - signature, - signedAt: signedAtMs, - nonce, - }; - } - const params = { - minProtocol: 3, - maxProtocol: 3, - client: { - id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: this.opts.clientVersion ?? "control-ui", - platform: this.opts.platform ?? navigator.platform ?? "web", - mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, - instanceId: this.opts.instanceId, - }, + return { role, scopes, - device, - caps: ["tool-events"], - auth, - userAgent: navigator.userAgent, - locale: navigator.language, + client, + explicitGatewayToken, + selectedAuth, + auth: buildGatewayConnectAuth(selectedAuth), + deviceIdentity, + device: await buildGatewayConnectDevice({ + deviceIdentity, + client, + role, + scopes, + authToken: selectedAuth.authToken, + connectNonce: this.connectNonce, + }), }; + } - void this.request("connect", params) - .then((hello) => { - this.pendingDeviceTokenRetry = false; - this.deviceTokenRetryBudgetUsed = false; - if (hello?.auth?.deviceToken && deviceIdentity) { - storeDeviceAuthToken({ - deviceId: deviceIdentity.deviceId, - role: hello.auth.role ?? role, - token: hello.auth.deviceToken, - scopes: hello.auth.scopes ?? [], - }); - } - this.backoffMs = 800; - this.opts.onHello?.(hello); - }) - .catch((err: unknown) => { - const connectErrorCode = - err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null; - const recoveryAdvice = - err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {}; - const retryWithDeviceTokenRecommended = - recoveryAdvice.recommendedNextStep === "retry_with_device_token"; - const canRetryWithDeviceTokenHint = - recoveryAdvice.canRetryWithDeviceToken === true || - retryWithDeviceTokenRecommended || - connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; - const shouldRetryWithDeviceToken = - !this.deviceTokenRetryBudgetUsed && - !selectedAuth.authDeviceToken && - Boolean(explicitGatewayToken) && - Boolean(deviceIdentity) && - Boolean(selectedAuth.storedToken) && - canRetryWithDeviceTokenHint && - isTrustedRetryEndpoint(this.opts.url); - if (shouldRetryWithDeviceToken) { - this.pendingDeviceTokenRetry = true; - this.deviceTokenRetryBudgetUsed = true; - } - if (err instanceof GatewayRequestError) { - this.pendingConnectError = { - code: err.gatewayCode, - message: err.message, - details: err.details, - }; - } else { - this.pendingConnectError = undefined; - } - if ( - selectedAuth.canFallbackToShared && - deviceIdentity && - connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH - ) { - clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); - } - this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); + private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) { + this.pendingDeviceTokenRetry = false; + this.deviceTokenRetryBudgetUsed = false; + if (hello?.auth?.deviceToken && plan.deviceIdentity) { + storeDeviceAuthToken({ + deviceId: plan.deviceIdentity.deviceId, + role: hello.auth.role ?? plan.role, + token: hello.auth.deviceToken, + scopes: hello.auth.scopes ?? [], }); + } + this.backoffMs = 800; + this.opts.onHello?.(hello); + } + + private handleConnectFailure(err: unknown, plan: ConnectPlan) { + const connectErrorCode = + err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null; + const recoveryAdvice = + err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {}; + const retryWithDeviceTokenRecommended = + recoveryAdvice.recommendedNextStep === "retry_with_device_token"; + const canRetryWithDeviceTokenHint = + recoveryAdvice.canRetryWithDeviceToken === true || + retryWithDeviceTokenRecommended || + connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; + + if ( + shouldRetryWithDeviceToken({ + deviceTokenRetryBudgetUsed: this.deviceTokenRetryBudgetUsed, + authDeviceToken: plan.selectedAuth.authDeviceToken, + explicitGatewayToken: plan.explicitGatewayToken, + deviceIdentity: plan.deviceIdentity, + storedToken: plan.selectedAuth.storedToken, + canRetryWithDeviceTokenHint, + url: this.opts.url, + }) + ) { + this.pendingDeviceTokenRetry = true; + this.deviceTokenRetryBudgetUsed = true; + } + if (err instanceof GatewayRequestError) { + this.pendingConnectError = { + code: err.gatewayCode, + message: err.message, + details: err.details, + }; + } else { + this.pendingConnectError = undefined; + } + if ( + plan.selectedAuth.canFallbackToShared && + plan.deviceIdentity && + connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH + ) { + clearDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, role: plan.role }); + } + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); + } + + private async sendConnect() { + if (this.connectSent) { + return; + } + this.connectSent = true; + if (this.connectTimer !== null) { + window.clearTimeout(this.connectTimer); + this.connectTimer = null; + } + + const plan = await this.buildConnectPlan(); + void this.request("connect", this.buildConnectParams(plan)) + .then((hello) => this.handleConnectHello(hello, plan)) + .catch((err: unknown) => this.handleConnectFailure(err, plan)); } private handleMessage(raw: string) {