diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0a23e5bbbb26..5dc41cc2e7ec 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1238,7 +1238,6 @@ public struct HelloOk: Codable, Sendable { public let controluitabs: [[String: AnyCodable]]? public let controluiwidgetkinds: [[String: AnyCodable]]? public let pluginsurfaceurls: [String: AnyCodable]? - public let deviceauthmigration: [String: AnyCodable]? public let auth: [String: AnyCodable] public let policy: [String: AnyCodable] @@ -1251,7 +1250,6 @@ public struct HelloOk: Codable, Sendable { controluitabs: [[String: AnyCodable]]? = nil, controluiwidgetkinds: [[String: AnyCodable]]? = nil, pluginsurfaceurls: [String: AnyCodable]? = nil, - deviceauthmigration: [String: AnyCodable]? = nil, auth: [String: AnyCodable], policy: [String: AnyCodable]) { @@ -1263,7 +1261,6 @@ public struct HelloOk: Codable, Sendable { self.controluitabs = controluitabs self.controluiwidgetkinds = controluiwidgetkinds self.pluginsurfaceurls = pluginsurfaceurls - self.deviceauthmigration = deviceauthmigration self.auth = auth self.policy = policy } @@ -1277,7 +1274,6 @@ public struct HelloOk: Codable, Sendable { case controluitabs = "controlUiTabs" case controluiwidgetkinds = "controlUiWidgetKinds" case pluginsurfaceurls = "pluginSurfaceUrls" - case deviceauthmigration = "deviceAuthMigration" case auth case policy } diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index 440b95db50ad..8491d0bf463d 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -54,7 +54,6 @@ exhaustive): | `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | | `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no | | `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | -| `gateway.control_ui.device_auth_disabled` | critical | Retired device-auth bypass migration still permits remediation access | complete **Secure this browser** pairing (`gateway.controlUi.deviceAuthMigration` state) | no | | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | | `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no | | `gateway.auth_no_rate_limit` | warn | Exposed auth without rate limiting increases brute-force risk | `gateway.auth.rateLimit` | no | diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index a1f37928cd3f..c1407b7222b1 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -691,7 +691,7 @@ Trusted proxy headers do not make node device pairing automatically trusted - `g The Control UI needs a secure context (HTTPS or localhost) to generate device identity. - Token/password auth does not replace browser device identity over remote plain HTTP. Use HTTPS (for example, Tailscale Serve) or open the UI on `127.0.0.1` from the Gateway host. -- `gateway.controlUi.dangerouslyDisableDeviceAuth`: retired break-glass input. Older configs preserve authenticated, pairing-only Control UI access for remediation until a browser reopened over HTTPS or localhost completes the bounded, explicit self-pairing migration; do not add it to current config. +- `gateway.controlUi.dangerouslyDisableDeviceAuth`: retired break-glass input, now fully inert. Control UI browsers pair through the normal device flow; `openclaw doctor --fix` removes the legacy key. - Separately, successful `gateway.auth.mode: "trusted-proxy"` authentication can admit **operator** Control UI sessions without device identity. This does not extend to node-role Control UI sessions. ### Insecure/dangerous flags @@ -701,7 +701,6 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` - - pending Control UI device-auth migration imported from retired `gateway.controlUi.dangerouslyDisableDeviceAuth=true` - `security.audit.suppressions configured ()` - `hooks.gmail.allowUnsafeExternalContent=true` - `hooks.mappings[].allowUnsafeExternalContent=true` @@ -713,7 +712,7 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id Control UI and browser: - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` - - `gateway.controlUi.dangerouslyDisableDeviceAuth` (retired upgrade input) + - `gateway.controlUi.dangerouslyDisableDeviceAuth` (retired, inert) - `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` Channel name-matching (bundled and plugin channels; also per `accounts.` where applicable): diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index f266cf689869..635279ead710 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -209,9 +209,6 @@ Scope implications: - Device-less Control UI WebSocket sessions cannot self-declare permissions. OpenClaw clears their requested scope list to `[]`, then applies any matching server-side `identityScopes` grant after proxy identity verification. - If methods fail with `missing scope` after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See [Control UI insecure HTTP](/web/control-ui#insecure-http). -- Older configs that still contain the retired - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` key use the bounded - [Control UI upgrade migration](/web/control-ui#device-pairing-first-connection). Reverse-proxy scope capping: if your proxy sends `x-openclaw-scopes` on the Control UI WebSocket upgrade request, OpenClaw caps device enrollment or upgrade requests and the final union of device-authorized and identity-granted session scopes. This header does not grant scopes; it only narrows authority. When `deviceAutoApprove.enabled` is true, the cap also limits the persistent device grant written by [automatic device approval](#automatic-device-approval). @@ -542,7 +539,7 @@ Separate, non-trusted-proxy-specific findings also apply whenever Control UI is - For Control UI, use HTTPS so the browser can generate device identity and complete pairing. - For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc). - - Do not add the retired `gateway.controlUi.dangerouslyDisableDeviceAuth` key to current config. Older installs use the one-time self-pairing migration automatically. + - Do not add the retired `gateway.controlUi.dangerouslyDisableDeviceAuth` key to current config; it is ignored and `openclaw doctor --fix` removes it. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 87b08968b697..9ef6467de2df 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -49,20 +49,6 @@ Onboarding usually configures a gateway token for shared-secret auth. If the Gat After gateway auth succeeds, connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`. On the Gateway host, `openclaw dashboard` is the preferred owner path: it opens a short-lived, single-use pairing link and leaves that exact signed browser with a durable administrator credential. Opening a fresh link in the same browser also repairs a previously limited credential; another browser profile cannot inherit or replay the grant. - -When upgrading directly from a release that used the retired -`gateway.controlUi.dangerouslyDisableDeviceAuth=true` break-glass setting, -OpenClaw keeps token/password- or trusted-proxy-authenticated Control UI access -available for pairing-only remediation. If the browser is on plain HTTP and cannot create device identity, -reopen it over HTTPS or localhost first. Then click **Secure this browser** in -the warning banner. The Gateway returns to normal device-auth enforcement only -after a signed browser pairs explicitly; it never creates or approves an -identity for a device-less browser. The transition is not available when -another operator device is already paired. Gateway startup and -`openclaw doctor --fix` both report this migration explicitly instead of -silently discarding the old key. - - ```bash diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index e4f830565f55..ea63dd80e7d6 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -127,11 +127,6 @@ export const HelloOkSchema = closedObject({ ), ), pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), - deviceAuthMigration: Type.Optional( - closedObject({ - pending: Type.Literal(true), - }), - ), auth: closedObject({ deviceToken: Type.Optional(NonEmptyString), recoveryMigrationAllowed: Type.Optional(Type.Literal(true)), diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts index 25765a5f2480..414f9f5a98f8 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts @@ -37,7 +37,7 @@ const GATEWAY_WEBCHAT_RULE: LegacyConfigRule = { const CONTROL_UI_DEVICE_AUTH_MIGRATION_RULE: LegacyConfigRule = { path: ["gateway", "controlUi", "dangerouslyDisableDeviceAuth"], message: - 'gateway.controlUi.dangerouslyDisableDeviceAuth is retired. OpenClaw will preserve authenticated, pairing-only access for remediation, remove the legacy key, and prompt you to reopen the Control UI over HTTPS or localhost before clicking Secure this browser. Run "openclaw doctor --fix".', + 'gateway.controlUi.dangerouslyDisableDeviceAuth is retired and ignored. Control UI browsers pair through the normal device flow; run "openclaw doctor --fix" to remove the legacy key.', match: (value) => typeof value === "boolean", }; @@ -69,7 +69,7 @@ function escapeControlForLog(value: string): string { export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "gateway.control-ui-device-auth-bypass->pairing-migration", - describe: "Convert the retired Control UI device-auth bypass into explicit pairing", + describe: "Remove the retired Control UI device-auth bypass", legacyRules: [CONTROL_UI_DEVICE_AUTH_MIGRATION_RULE], apply: (raw, changes) => { const gateway = getRecord(raw.gateway); @@ -77,13 +77,8 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY: LegacyConfigMigrationSpec if (!controlUi || !Object.hasOwn(controlUi, "dangerouslyDisableDeviceAuth")) { return; } - const migrationRequired = controlUi.dangerouslyDisableDeviceAuth === true; delete controlUi.dangerouslyDisableDeviceAuth; - changes.push( - migrationRequired - ? "Preserved the retired Control UI device-auth bypass for remediation. Reopen the Control UI over HTTPS or localhost, then click Secure this browser." - : "Removed disabled gateway.controlUi.dangerouslyDisableDeviceAuth legacy config.", - ); + changes.push("Removed retired gateway.controlUi.dangerouslyDisableDeviceAuth legacy config."); }, }), defineLegacyConfigMigration({ diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts index f9045f82ff5e..9c028bba8815 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts @@ -209,14 +209,14 @@ describe("retired runtime config migrations", () => { it.each([ [ - "uses a dedicated, actionable migration for the retired device-auth bypass", + "removes the retired device-auth bypass when it was enabled", true, - "Preserved the retired Control UI device-auth bypass for remediation. Reopen the Control UI over HTTPS or localhost, then click Secure this browser.", + "Removed retired gateway.controlUi.dangerouslyDisableDeviceAuth legacy config.", ], [ - "removes a disabled retired device-auth bypass without requiring pairing", + "removes a disabled retired device-auth bypass", false, - "Removed disabled gateway.controlUi.dangerouslyDisableDeviceAuth legacy config.", + "Removed retired gateway.controlUi.dangerouslyDisableDeviceAuth legacy config.", ], ] as const)("%s", (_name, bypassEnabled, expectedChange) => { const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY.find( @@ -234,7 +234,7 @@ describe("retired runtime config migrations", () => { expect(raw).not.toHaveProperty("gateway.controlUi.dangerouslyDisableDeviceAuth"); expect(changes).toEqual([expectedChange]); if (bypassEnabled) { - expect(migration?.legacyRules?.[0]?.message).toContain("reopen the Control UI over HTTPS"); + expect(migration?.legacyRules?.[0]?.message).toContain("retired and ignored"); } }); diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index ce177b32d4ea..c0b33830005e 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -60,9 +60,6 @@ export async function prepareGatewayKernelRequestRuntime(params: { watchNodeHttpRuntime, sharedGatewaySessionGenerationState, resolveSharedGatewaySessionGenerationForRuntimeSnapshot, - completeControlUiDeviceAuthMigrationForEffectiveOperator, - claimControlUiDeviceAuthMigration, - releaseControlUiDeviceAuthMigrationClaim, nodeRegistry, nodeDesktopService, workerEnvironmentService, @@ -161,12 +158,6 @@ export async function prepareGatewayKernelRequestRuntime(params: { clients, }); }, - completeControlUiDeviceAuthMigration: - completeControlUiDeviceAuthMigrationForEffectiveOperator, - claimControlUiDeviceAuthMigration: (deviceId: string) => - claimControlUiDeviceAuthMigration(deviceId, { env: process.env }), - releaseControlUiDeviceAuthMigrationClaim: (deviceId: string) => - releaseControlUiDeviceAuthMigrationClaim(deviceId, { env: process.env }), nodeRegistry, ...(nodeDesktopService ? { nodeDesktopService } : {}), ...(workerEnvironmentService ? { workerEnvironmentService } : {}), diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 66172a49b37b..79a0272d1a0c 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -4,16 +4,11 @@ import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry. import { listLoadedChannelPlugins } from "../channels/plugins/registry-loaded.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import { getRuntimeConfig } from "../config/io.js"; -import { - onEffectiveOperatorDevicePaired, - type EffectiveOperatorDeviceIdentity, -} from "../infra/device-pairing.js"; import { upsertPresence } from "../infra/system-presence.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import { clearSecretsRuntimeSnapshotState } from "../secrets/runtime-state.js"; -import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { recordRemoteNodeInfo, removeRemoteNodeInfo, @@ -29,10 +24,7 @@ import { createGatewayCronReconciliation } from "./server-cron-reconciled.js"; import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js"; import { createGatewayServerLiveState } from "./server-live-state.js"; import type { GatewayRequestContext } from "./server-methods/types.js"; -import { - type GatewayCloseOptions, - shouldRetainControlUiDeviceAuthMigrationSession, -} from "./server-public.js"; +import type { GatewayCloseOptions } from "./server-public.js"; import type { prepareGatewayKernelState } from "./server-runtime-state-prepare.js"; import type { GatewayShutdownRuntime } from "./server-shutdown.runtime.js"; import { @@ -57,8 +49,6 @@ export async function prepareGatewayLifecycle(params: { const { runtime, port, log, logCron, diagnosticsEnabled, shutdownRuntime } = params; const { minimalTestGateway, - controlUiDeviceAuthMigration, - completeControlUiDeviceAuthMigration, workerGatewayEndpoint, transportBridge, sessionMessageSubscribers, @@ -95,51 +85,6 @@ export async function prepareGatewayLifecycle(params: { bindDeviceNodeControl, workerPlacementRuntime, } = runtime; - const completeControlUiDeviceAuthMigrationForEffectiveOperator = ( - device: EffectiveOperatorDeviceIdentity, - ) => { - if ( - !controlUiDeviceAuthMigration.pending || - !roleScopesAllow({ - role: "operator", - requestedScopes: ["operator.pairing"], - allowedScopes: device.scopes, - }) - ) { - return; - } - const normalizedDeviceId = device.deviceId.trim(); - // Close the process-local grace immediately after approval. The durable - // receipt prevents stale legacy config from reopening it. - controlUiDeviceAuthMigration.pending = false; - for (const client of clients) { - if (!client.isControlUiDeviceAuthMigrationSession) { - continue; - } - if ( - client.isControlUiDeviceAuthMigration && - shouldRetainControlUiDeviceAuthMigrationSession({ - sessionDevice: client.connect.device, - approvedDevice: device, - }) - ) { - // Retention is bound to the approved key as well as its derived id. - // Keep only that identity long enough to receive the approval response. - continue; - } - client.invalidated = true; - client.invalidatedReason = "device-auth-migration-completed"; - client.socket.close(4001, "device auth migration completed"); - } - try { - completeControlUiDeviceAuthMigration(normalizedDeviceId, { env: process.env }); - } catch (error) { - log.warn(`failed to persist Control UI device-auth migration completion: ${String(error)}`); - } - }; - const unsubscribeEffectiveOperatorPairing = onEffectiveOperatorDevicePaired( - completeControlUiDeviceAuthMigrationForEffectiveOperator, - ); workerGatewayEndpoint.resolve = transportBridge.getWorkerIngressEndpoint; const subscribeSessionMessageEvents: GatewayRequestContext["subscribeSessionMessageEvents"] = ( connId, @@ -446,7 +391,6 @@ export async function prepareGatewayLifecycle(params: { runtimeState.stopGatewayUpdateCheck(); runtimeState.controlUiSessionPullRequests?.stop(); runtimeState.sessionViewerPresence?.stop(); - unsubscribeEffectiveOperatorPairing(); kernel.setDispatchReady(false); gatewayInstanceRuntimeRef.current?.close(); cronReconciliation.invalidate(); @@ -644,8 +588,6 @@ export async function prepareGatewayLifecycle(params: { return { ...runtime, - completeControlUiDeviceAuthMigrationForEffectiveOperator, - unsubscribeEffectiveOperatorPairing, subscribeSessionMessageEvents, unsubscribeSessionMessageEvents, restartRecoveryCandidates, diff --git a/src/gateway/server-methods/device-management-authz.test.ts b/src/gateway/server-methods/device-management-authz.test.ts index 28b64c63f2a7..7faa48ca9e3e 100644 --- a/src/gateway/server-methods/device-management-authz.test.ts +++ b/src/gateway/server-methods/device-management-authz.test.ts @@ -28,55 +28,19 @@ function client(overrides: Partial): GatewayClient { } describe("device management authz", () => { - it("binds a migration session to its signed device without admin power", () => { - expect( - resolveDeviceSessionAuthz( - client({ - isControlUiDeviceAuthMigrationSession: true, - isControlUiDeviceAuthMigration: true, - }), - ), - ).toEqual({ - callerDeviceId: "browser-1", - callerScopes: ["operator.admin", "operator.pairing"], - isAdminCaller: false, - isDeviceAuthMigrationCaller: true, - isDeviceAuthMigrationSession: true, - }); - }); - - it("withholds device-management admin power from a device-less migration session", () => { - expect( - resolveDeviceSessionAuthz( - client({ - isControlUiDeviceAuthMigrationSession: true, - connect: { - ...client({}).connect, - device: undefined, - }, - }), - ), - ).toMatchObject({ - callerDeviceId: null, - isAdminCaller: false, - isDeviceAuthMigrationCaller: false, - isDeviceAuthMigrationSession: true, - }); - }); - it("keeps ordinary shared-auth device metadata untrusted", () => { - expect(resolveDeviceSessionAuthz(client({}))).toMatchObject({ + expect(resolveDeviceSessionAuthz(client({}))).toEqual({ callerDeviceId: null, + callerScopes: ["operator.admin", "operator.pairing"], isAdminCaller: true, - isDeviceAuthMigrationCaller: false, }); }); it("keeps device-token self-service behavior unchanged", () => { - expect(resolveDeviceSessionAuthz(client({ isDeviceTokenAuth: true }))).toMatchObject({ + expect(resolveDeviceSessionAuthz(client({ isDeviceTokenAuth: true }))).toEqual({ callerDeviceId: "browser-1", + callerScopes: ["operator.admin", "operator.pairing"], isAdminCaller: true, - isDeviceAuthMigrationCaller: false, }); }); }); diff --git a/src/gateway/server-methods/device-management-authz.ts b/src/gateway/server-methods/device-management-authz.ts index c824a63957be..c9853a7b13bb 100644 --- a/src/gateway/server-methods/device-management-authz.ts +++ b/src/gateway/server-methods/device-management-authz.ts @@ -5,8 +5,6 @@ export type DeviceSessionAuthz = { callerDeviceId: string | null; callerScopes: string[]; isAdminCaller: boolean; - isDeviceAuthMigrationCaller: boolean; - isDeviceAuthMigrationSession: boolean; }; export type DeviceManagementAuthz = DeviceSessionAuthz & { @@ -16,25 +14,14 @@ export type DeviceManagementAuthz = DeviceSessionAuthz & { export function resolveDeviceSessionAuthz(client: GatewayClient | null): DeviceSessionAuthz { const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; const rawCallerDeviceId = client?.connect?.device?.id; - const isDeviceAuthMigrationCaller = client?.isControlUiDeviceAuthMigration === true; - const isDeviceAuthMigrationSession = client?.isControlUiDeviceAuthMigrationSession === true; const callerDeviceId = - // Migration admission verifies this exact signed device before marking the - // session. It gets self-service ownership, never cross-device admin power. - (client?.isDeviceTokenAuth || isDeviceAuthMigrationCaller) && - typeof rawCallerDeviceId === "string" && - rawCallerDeviceId.trim() + client?.isDeviceTokenAuth && typeof rawCallerDeviceId === "string" && rawCallerDeviceId.trim() ? rawCallerDeviceId.trim() : null; return { callerDeviceId, callerScopes, - isAdminCaller: - !isDeviceAuthMigrationSession && - !isDeviceAuthMigrationCaller && - callerScopes.includes("operator.admin"), - isDeviceAuthMigrationCaller, - isDeviceAuthMigrationSession, + isAdminCaller: callerScopes.includes("operator.admin"), }; } @@ -49,9 +36,6 @@ export function resolveDeviceManagementAuthz( } export function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean { - if (authz.isDeviceAuthMigrationSession && !authz.callerDeviceId) { - return true; - } return Boolean( authz.callerDeviceId && authz.callerDeviceId !== authz.normalizedTargetDeviceId && diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index f785ba918dcd..b36e681cae68 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -11,7 +11,6 @@ import { validateDeviceTokenRotateParams, } from "../../../packages/gateway-protocol/src/index.js"; import { - approveControlUiDeviceAuthMigrationPairing, approveDevicePairing, formatDevicePairingForbiddenMessage, getPairedDevice, @@ -223,9 +222,7 @@ export const deviceHandlers: GatewayRequestHandlers = { const list = await listDevicePairing(); const authz = resolveDeviceSessionAuthz(client); let visibleList = list; - if (authz.isDeviceAuthMigrationSession && !authz.callerDeviceId) { - visibleList = { pending: [], paired: [] }; - } else if (authz.callerDeviceId && !authz.isAdminCaller) { + if (authz.callerDeviceId && !authz.isAdminCaller) { visibleList = { pending: list.pending.filter((request) => request.deviceId.trim() === authz.callerDeviceId), paired: list.paired.filter((device) => device.deviceId.trim() === authz.callerDeviceId), @@ -254,16 +251,7 @@ export const deviceHandlers: GatewayRequestHandlers = { } const { requestId } = params as { requestId: string }; const authz = resolveDeviceSessionAuthz(client); - let migrationApprovalScopes: string[] | undefined; if (!authz.isAdminCaller) { - if (authz.isDeviceAuthMigrationSession && !authz.isDeviceAuthMigrationCaller) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE), - ); - return; - } const pending = await getPendingDevicePairing(requestId); if (!pending) { respond( @@ -307,45 +295,13 @@ export const deviceHandlers: GatewayRequestHandlers = { ); return; } - if (authz.isDeviceAuthMigrationCaller) { - migrationApprovalScopes = pending.scopes ?? []; - } - } - const migrationDeviceId = authz.isDeviceAuthMigrationCaller ? authz.callerDeviceId : null; - if ( - authz.isDeviceAuthMigrationCaller && - (!migrationDeviceId || - context.claimControlUiDeviceAuthMigration?.(migrationDeviceId) !== true) - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE), - ); - return; - } - const releaseMigrationClaim = () => { - if (migrationDeviceId) { - context.releaseControlUiDeviceAuthMigrationClaim?.(migrationDeviceId); - } - }; - let approved: Awaited>; - try { - const callerScopes = migrationApprovalScopes ?? authz.callerScopes; - approved = authz.isDeviceAuthMigrationCaller - ? await approveControlUiDeviceAuthMigrationPairing(requestId, { callerScopes }) - : await approveDevicePairing(requestId, { callerScopes }); - } catch (error) { - releaseMigrationClaim(); - throw error; } + const approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes }); if (!approved) { - releaseMigrationClaim(); respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } if (approved.status === "forbidden") { - releaseMigrationClaim(); emitDevicePairingDeniedSecurityEvent({ authz, controlId: "device.pair.approve", @@ -395,8 +351,6 @@ export const deviceHandlers: GatewayRequestHandlers = { }, { dropIfSlow: true }, ); - // The completion listener keeps this handshake migration-bound until the - // client reconnects with its newly approved device token. respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined); if (approved.nodePairingGenerationChanged) { queueMicrotask(() => { @@ -410,14 +364,6 @@ export const deviceHandlers: GatewayRequestHandlers = { } const { requestId } = params as { requestId: string }; const authz = resolveDeviceSessionAuthz(client); - if (authz.isDeviceAuthMigrationSession && !authz.isDeviceAuthMigrationCaller) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_REJECTION_DENIED_MESSAGE), - ); - return; - } if (authz.callerDeviceId && !authz.isAdminCaller) { const pending = await getPendingDevicePairing(requestId); if (!pending) { diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index d4df6df182e0..c7b070577585 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -100,10 +100,6 @@ export type GatewayClient = { pluginNodeCapabilitySurfaces?: Record; pluginNodeCapabilities?: Record; isDeviceTokenAuth?: boolean; - /** Temporary legacy migration session closed when normal enforcement resumes. */ - isControlUiDeviceAuthMigrationSession?: boolean; - /** Signed shared-auth session admitted only to approve its own upgrade pairing. */ - isControlUiDeviceAuthMigration?: boolean; internal?: { /** Handshake-attested direct-local transport; never accepted from wire params. */ isLocalClient?: true; @@ -254,13 +250,6 @@ type GatewayKernelContext = { approvalEvents?: GatewayApprovalEventPublisher; recoveryRuntime?: GatewayRecoveryRuntime; enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void; - claimControlUiDeviceAuthMigration?: (deviceId: string) => boolean; - releaseControlUiDeviceAuthMigrationClaim?: (deviceId: string) => void; - completeControlUiDeviceAuthMigration?: (device: { - deviceId: string; - publicKey: string; - scopes: string[]; - }) => void; nodeRegistry: NodeRegistry; agentRunSeq: Map; chatAbortControllers: Map; diff --git a/src/gateway/server-public.ts b/src/gateway/server-public.ts index 249468aec26d..d023488f1226 100644 --- a/src/gateway/server-public.ts +++ b/src/gateway/server-public.ts @@ -1,6 +1,4 @@ import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; -import { normalizeDevicePublicKeyBase64Url } from "../infra/device-identity.js"; -import type { EffectiveOperatorDeviceIdentity } from "../infra/device-pairing.js"; import type { GatewayRestartEmitter } from "../infra/restart.js"; import type { ChannelAutostartSuppression } from "./server-channels.js"; import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js"; @@ -67,16 +65,3 @@ export type GatewayServerOptions = { /** Restart request override; direct servers fail closed on restart-required reloads. */ hotReloadRecovery?: GatewayRestartEmitter; }; - -export function shouldRetainControlUiDeviceAuthMigrationSession(params: { - sessionDevice: { id: string; publicKey: string } | null | undefined; - approvedDevice: EffectiveOperatorDeviceIdentity; -}): boolean { - const approvedDeviceId = params.approvedDevice.deviceId.trim(); - const approvedPublicKey = normalizeDevicePublicKeyBase64Url(params.approvedDevice.publicKey); - return Boolean( - params.sessionDevice?.id.trim() === approvedDeviceId && - approvedPublicKey && - normalizeDevicePublicKeyBase64Url(params.sessionDevice.publicKey) === approvedPublicKey, - ); -} diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 1cf2cd380183..aba4160a7782 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -74,13 +74,6 @@ type GatewayRequestContextParams = { ) => void; disconnectDeviceTransports?: (deviceId: string, opts?: { role?: string }) => void; enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void; - claimControlUiDeviceAuthMigration?: (deviceId: string) => boolean; - releaseControlUiDeviceAuthMigrationClaim?: (deviceId: string) => void; - completeControlUiDeviceAuthMigration?: (device: { - deviceId: string; - publicKey: string; - scopes: string[]; - }) => void; nodeRegistry: GatewayRequestContext["nodeRegistry"]; nodeDesktopService?: import("./desktop/node-source.js").NodeDesktopService; workerEnvironmentService?: GatewayRequestContext["workerEnvironmentService"]; @@ -371,9 +364,6 @@ export function createGatewayRequestContext( }, enforceSharedGatewayAuthGenerationForConfigWrite: params.enforceSharedGatewayAuthGenerationForConfigWrite, - claimControlUiDeviceAuthMigration: params.claimControlUiDeviceAuthMigration, - releaseControlUiDeviceAuthMigrationClaim: params.releaseControlUiDeviceAuthMigrationClaim, - completeControlUiDeviceAuthMigration: params.completeControlUiDeviceAuthMigration, nodeRegistry: params.nodeRegistry, ...(params.nodeDesktopService ? { [NODE_DESKTOP_SERVICE_CONTEXT]: params.nodeDesktopService } diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 64ebcaafc0c1..ac380cab60c5 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -20,11 +20,6 @@ import type { GatewayAuthConfig } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSecretRef } from "../config/types.secrets.js"; import { getActiveCronJobCount } from "../cron/active-jobs.js"; -import { - listDevicePairing, - resolveEffectiveOperatorDeviceIdentity, - type EffectiveOperatorDeviceIdentity, -} from "../infra/device-pairing.js"; import { isDiagnosticsEnabled, setDiagnosticsEnabledForProcess, @@ -42,7 +37,6 @@ import { completePluginMetadataSnapshot } from "../plugins/plugin-metadata-snaps import { getTotalQueueSize } from "../process/command-queue.js"; import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission.js"; import { createLazyPromise } from "../shared/lazy-runtime.js"; -import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { assertOpenClawStateWriteAllowedAtPath } from "../state/openclaw-state-ownership.js"; import { ADMIN_SCOPE } from "./method-scopes.js"; @@ -244,64 +238,6 @@ export async function prepareGatewayServerBootstrap(input: { ); const cfgAtStart = authBootstrap.cfg; startupTrace.setConfig(cfgAtStart); - const { - claimControlUiDeviceAuthMigration, - completeControlUiDeviceAuthMigration, - importPendingControlUiDeviceAuthMigration, - isLegacyControlUiDeviceAuthMigrationInput, - readControlUiDeviceAuthMigrationState, - recoverControlUiDeviceAuthMigrationClaim, - releaseControlUiDeviceAuthMigrationClaim, - } = await import("../state/control-ui-device-auth-migration.js"); - const legacyControlUiDeviceAuthBypass = isLegacyControlUiDeviceAuthMigrationInput({ - disabledDeviceAuth: cfgAtStart.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true, - lastTouchedVersion: cfgAtStart.meta?.lastTouchedVersion, - }); - let controlUiDeviceAuthMigrationState = legacyControlUiDeviceAuthBypass - ? importPendingControlUiDeviceAuthMigration({ env: process.env }) - : readControlUiDeviceAuthMigrationState({ env: process.env }); - if ( - controlUiDeviceAuthMigrationState?.status === "pending" && - controlUiDeviceAuthMigrationState.claimedDeviceId - ) { - // A process crash between claim and approval must not strand the upgrade. - controlUiDeviceAuthMigrationState = recoverControlUiDeviceAuthMigrationClaim({ - env: process.env, - }); - } - if (controlUiDeviceAuthMigrationState?.status === "pending") { - const existingOperator = (await listDevicePairing()).paired - .map(resolveEffectiveOperatorDeviceIdentity) - .find( - (device): device is EffectiveOperatorDeviceIdentity => - device !== null && - roleScopesAllow({ - role: "operator", - requestedScopes: ["operator.pairing"], - allowedScopes: device.scopes, - }), - ); - if (existingOperator) { - try { - controlUiDeviceAuthMigrationState = completeControlUiDeviceAuthMigration( - existingOperator.deviceId, - { env: process.env }, - ); - } catch (error) { - log.warn( - `failed to reconcile Control UI device-auth migration with existing operator: ${String(error)}`, - ); - } - } - } - const controlUiDeviceAuthMigration = { - pending: controlUiDeviceAuthMigrationState?.status === "pending", - }; - if (controlUiDeviceAuthMigration.pending) { - log.warn( - "Retired gateway.controlUi.dangerouslyDisableDeviceAuth config detected. Authenticated Control UI access remains available for pairing-only remediation; reopen the Control UI over HTTPS or localhost, then click Secure this browser.", - ); - } if (authBootstrap.generatedToken) { log.warn(formatRuntimeGatewayAuthTokenWarning()); } @@ -567,10 +503,6 @@ export async function prepareGatewayServerBootstrap(input: { startupRuntimeConfig, cfgAtStart, generatedStartupAuthToken: authBootstrap.generatedToken !== undefined, - claimControlUiDeviceAuthMigration, - completeControlUiDeviceAuthMigration, - releaseControlUiDeviceAuthMigrationClaim, - controlUiDeviceAuthMigration, resolvedStartupAuthOverride, startupTailscaleOverride, diagnosticsEnabled, diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index b4033714504f..d02351e40ee4 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -64,7 +64,6 @@ export async function finishGatewayStartup(params: { broadcastToConnIds, clients, sharedGatewaySessionGenerationState, - controlUiDeviceAuthMigration, workerEnvironmentService, workerPlacementRuntime, terminalLaunchPolicy, @@ -158,7 +157,6 @@ export async function finishGatewayStartup(params: { nodeReapprovalCoordinator, preauthHandshakeTimeoutMs, isStartupPending: isGatewayStartupPending, - isControlUiDeviceAuthMigrationPending: () => controlUiDeviceAuthMigration.pending, gatewayMethods: runtimeState.gatewayMethods, events: GATEWAY_EVENTS, logGateway: log, diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index e4e03cdc337e..fe27cf61afbf 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -33,7 +33,6 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) { nodeReapprovalCoordinator: params.nodeReapprovalCoordinator, preauthHandshakeTimeoutMs: params.preauthHandshakeTimeoutMs, isStartupPending: params.isStartupPending, - isControlUiDeviceAuthMigrationPending: params.isControlUiDeviceAuthMigrationPending, gatewayMethods: params.gatewayMethods, events: params.events, refreshHealthSnapshot: params.context.refreshHealthSnapshot, diff --git a/src/gateway/server.control-ui-device-auth-migration.test.ts b/src/gateway/server.control-ui-device-auth-migration.test.ts deleted file mode 100644 index 97f10c37dab9..000000000000 --- a/src/gateway/server.control-ui-device-auth-migration.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -// Upgrade regression: retired Control UI bypass users can explicitly pair without host-shell recovery. -import { randomUUID } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { WebSocket } from "ws"; -import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - signDevicePayload, -} from "../infra/device-identity.js"; -import { buildDeviceAuthPayload } from "./device-auth.js"; -import { shouldRetainControlUiDeviceAuthMigrationSession } from "./server-public.js"; -import { CONTROL_UI_CLIENT } from "./server.auth.test-helpers.js"; -import { - connectReq, - createGatewaySuiteHarness, - installGatewayTestHooks, - readConnectChallengeNonce, - rpcReq, - testState, -} from "./test-helpers.js"; - -installGatewayTestHooks({ scope: "suite" }); - -const BROWSER_ORIGIN = "https://control.example.com"; -const SCOPES = ["operator.admin", "operator.pairing"]; -const REMOTE_HEADERS = { - origin: BROWSER_ORIGIN, - "x-forwarded-for": "203.0.113.50", -}; -const REMOTE_MIGRATION_GATEWAY = { - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: [BROWSER_ORIGIN], - dangerouslyDisableDeviceAuth: true, - }, -} satisfies NonNullable; -const LOCAL_MIGRATION_GATEWAY = { - controlUi: { - allowedOrigins: [BROWSER_ORIGIN], - dangerouslyDisableDeviceAuth: true, - }, -} satisfies NonNullable; -const TRUSTED_PROXY_MIGRATION_GATEWAY = { - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - ...REMOTE_MIGRATION_GATEWAY, -} satisfies NonNullable; -const STATE_ONLY_MIGRATION_GATEWAY = { - controlUi: { dangerouslyDisableDeviceAuth: true }, -} satisfies NonNullable; -const TOKEN_AUTH = { mode: "token", token: "secret" }; - -type MigrationHarness = Awaited>; -type TrackedMigrationHarness = Omit & { - openWs: MigrationHarness["openWs"]; -}; - -function identityPath(label: string): string { - return path.join(os.tmpdir(), `openclaw-${label}-${randomUUID()}.sqlite`); -} - -function createIdentity(label: string) { - return loadOrCreateDeviceIdentity({ path: identityPath(label) }); -} - -async function requestOperatorPairing(label: string, scopes: string[] = SCOPES) { - const { requestDevicePairing } = await import("../infra/device-pairing.js"); - const identityFile = identityPath(label); - const identity = loadOrCreateDeviceIdentity({ path: identityFile }); - const request = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - role: "operator", - scopes, - }); - return { identity, path: identityFile, request }; -} - -async function approveOperatorPairing(requestId: string, callerScopes: string[] = SCOPES) { - const { approveDevicePairing } = await import("../infra/device-pairing.js"); - return await approveDevicePairing(requestId, { callerScopes }); -} - -async function listPairings() { - const { listDevicePairing } = await import("../infra/device-pairing.js"); - return await listDevicePairing(); -} - -async function readMigrationState() { - const { readControlUiDeviceAuthMigrationState } = - await import("../state/control-ui-device-auth-migration.js"); - return readControlUiDeviceAuthMigrationState({ env: process.env }); -} - -async function withMigrationHarness( - run: (harness: TrackedMigrationHarness, prepared: Prepared) => Promise, - options: { - gateway?: NonNullable; - auth?: Record | null; - prepare?: () => Promise; - } = {}, -): Promise { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - meta: { lastTouchedVersion: "2026.7.1" }, - gateway: options.gateway ?? REMOTE_MIGRATION_GATEWAY, - }); - testState.gatewayAuth = options.auth === null ? undefined : (options.auth ?? TOKEN_AUTH); - const prepared = options.prepare ? await options.prepare() : (undefined as Prepared); - const harness = await createGatewaySuiteHarness(); - const sockets = new Set(); - try { - return await run( - { - ...harness, - openWs: async (headers) => { - const socket = await harness.openWs(headers); - sockets.add(socket); - return socket; - }, - }, - prepared, - ); - } finally { - for (const socket of sockets) { - socket.close(); - } - await harness.close(); - } -} - -async function signedDevice(ws: WebSocket, identityFile: string, scopes: string[] = SCOPES) { - const nonce = await readConnectChallengeNonce(ws); - const identity = loadOrCreateDeviceIdentity({ path: identityFile }); - const signedAt = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - role: "operator", - scopes, - signedAtMs: signedAt, - token: "secret", - nonce: nonce ?? "", - }); - return { - identity, - device: { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt, - nonce: nonce ?? "", - }, - }; -} - -async function connectMigration( - ws: WebSocket, - params: { - device: Awaited>["device"] | null; - scopes?: string[]; - trustedProxy?: boolean; - }, -) { - return await connectReq(ws, { - ...(params.trustedProxy ? { skipDefaultAuth: true } : { token: "secret" }), - scopes: params.scopes ?? SCOPES, - client: CONTROL_UI_CLIENT, - device: params.device, - }); -} - -describe("Control UI device-auth upgrade migration", () => { - it("retains only the migration session bound to the approved public key", () => { - const approvedIdentity = createIdentity("device-auth-approved"); - const otherIdentity = createIdentity("device-auth-other"); - const approvedPublicKey = publicKeyRawBase64UrlFromPem(approvedIdentity.publicKeyPem); - const otherPublicKey = publicKeyRawBase64UrlFromPem(otherIdentity.publicKeyPem); - const approvedDevice = { - deviceId: approvedIdentity.deviceId, - publicKey: approvedPublicKey, - scopes: SCOPES, - }; - - expect( - shouldRetainControlUiDeviceAuthMigrationSession({ - sessionDevice: { id: approvedIdentity.deviceId, publicKey: approvedPublicKey }, - approvedDevice, - }), - ).toBe(true); - expect( - shouldRetainControlUiDeviceAuthMigrationSession({ - sessionDevice: { id: approvedIdentity.deviceId, publicKey: otherPublicKey }, - approvedDevice, - }), - ).toBe(false); - expect( - shouldRetainControlUiDeviceAuthMigrationSession({ - sessionDevice: { id: otherIdentity.deviceId, publicKey: approvedPublicKey }, - approvedDevice, - }), - ).toBe(false); - }); - - it("keeps a device-less legacy browser online with secure-context remediation", async () => { - await withMigrationHarness(async (harness) => { - const ws = await harness.openWs(REMOTE_HEADERS); - const connected = await connectMigration(ws, { device: null }); - expect(connected.ok).toBe(true); - expect(connected.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - expect( - (connected.payload as { auth?: { deviceToken?: string } } | undefined)?.auth?.deviceToken, - ).toBeUndefined(); - - const { request: otherRequest } = await requestOperatorPairing( - "device-auth-migration-device-less-target", - ); - const crossDeviceApproval = await rpcReq(ws, "device.pair.approve", { - requestId: otherRequest.request.requestId, - }); - expect(crossDeviceApproval.ok).toBe(false); - - const config = await rpcReq(ws, "config.get", {}); - expect(config.ok).toBe(false); - expect(config.error?.message).toContain("missing scope"); - }); - }); - - it("preserves trusted-proxy migration access", async () => { - await withMigrationHarness( - async (harness) => { - const ws = await harness.openWs({ - ...REMOTE_HEADERS, - "x-forwarded-proto": "https", - "x-forwarded-user": "operator@example.com", - }); - const connected = await connectMigration(ws, { device: null, trustedProxy: true }); - expect(connected.ok).toBe(true); - expect(connected.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - const config = await rpcReq(ws, "config.get", {}); - expect(config.ok).toBe(false); - expect(config.error?.message).toContain("missing scope"); - }, - { gateway: TRUSTED_PROXY_MIGRATION_GATEWAY, auth: null }, - ); - }); - - it("preserves trusted-proxy scope caps during migration", async () => { - await withMigrationHarness( - async (harness) => { - const ws = await harness.openWs({ - ...REMOTE_HEADERS, - "x-forwarded-proto": "https", - "x-forwarded-user": "reader@example.com", - "x-openclaw-scopes": "operator.read", - }); - const connected = await connectMigration(ws, { device: null, trustedProxy: true }); - expect(connected.ok).toBe(true); - expect(connected.payload).toMatchObject({ - auth: { role: "operator", scopes: [] }, - }); - expect(connected.payload).not.toHaveProperty("deviceAuthMigration"); - const pairings = await rpcReq(ws, "device.pair.list", {}); - expect(pairings.ok).toBe(false); - expect(pairings.error?.message).toContain("missing scope"); - }, - { gateway: TRUSTED_PROXY_MIGRATION_GATEWAY, auth: null }, - ); - }); - - it("completes imported migration state when an effective operator already exists", async () => { - await withMigrationHarness( - async (_harness, ownerIdentity) => { - expect(await readMigrationState()).toMatchObject({ - status: "completed", - deviceId: ownerIdentity.deviceId, - }); - }, - { - gateway: STATE_ONLY_MIGRATION_GATEWAY, - auth: testState.gatewayAuth, - prepare: async () => { - const { identity, request } = await requestOperatorPairing("migration-existing-owner"); - await expect(approveOperatorPairing(request.request.requestId)).resolves.toMatchObject({ - status: "approved", - }); - return identity; - }, - }, - ); - }); - - it("keeps migration pending when existing operators cannot manage pairings", async () => { - await withMigrationHarness( - async () => { - expect(await readMigrationState()).toMatchObject({ - status: "pending", - }); - }, - { - gateway: STATE_ONLY_MIGRATION_GATEWAY, - auth: testState.gatewayAuth, - prepare: async () => { - const { request } = await requestOperatorPairing("migration-read-only-owner", [ - "operator.read", - ]); - await expect( - approveOperatorPairing(request.request.requestId, ["operator.read"]), - ).resolves.toMatchObject({ status: "approved" }); - }, - }, - ); - }); - - it("rejects an in-flight migration handshake completed before registration", async () => { - await withMigrationHarness(async (harness) => { - const connectNodeSession = await import("./server/ws-connection/connect-node-session.js"); - const originalPrepare = connectNodeSession.prepareGatewayNodeConnect; - let releasePrepare: () => void = () => {}; - const prepareReleased = new Promise((resolve) => { - releasePrepare = resolve; - }); - let markPrepareEntered: () => void = () => {}; - const prepareEntered = new Promise((resolve) => { - markPrepareEntered = resolve; - }); - const prepareSpy = vi - .spyOn(connectNodeSession, "prepareGatewayNodeConnect") - .mockImplementationOnce(async (context, state) => { - markPrepareEntered(); - await prepareReleased; - return await originalPrepare(context, state); - }); - try { - const ws = await harness.openWs(REMOTE_HEADERS); - const connected = connectMigration(ws, { device: null }); - await prepareEntered; - const { request } = await requestOperatorPairing("migration-race-owner"); - await expect(approveOperatorPairing(request.request.requestId)).resolves.toMatchObject({ - status: "approved", - }); - releasePrepare(); - - const result = await connected; - expect(result.ok).toBe(false); - expect((result.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - ); - } finally { - releasePrepare(); - prepareSpy.mockRestore(); - } - }); - }); - - it("keeps only the signed legacy browser online until it explicitly pairs", async () => { - await withMigrationHarness(async (harness) => { - const other = await requestOperatorPairing("other-pending"); - const firstIdentityPath = identityPath("device-auth-migration"); - const firstWs = await harness.openWs(REMOTE_HEADERS); - const first = await signedDevice(firstWs, firstIdentityPath); - const firstConnect = await connectMigration(firstWs, { device: first.device }); - expect(firstConnect.ok).toBe(true); - expect(firstConnect.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - expect( - (firstConnect.payload as { auth?: { deviceToken?: string } } | undefined)?.auth - ?.deviceToken, - ).toBeUndefined(); - const unrelatedAdminCall = await rpcReq(firstWs, "config.get", {}); - expect(unrelatedAdminCall.ok).toBe(false); - expect(unrelatedAdminCall.error?.message).toContain("missing scope"); - - const competingWs = await harness.openWs(REMOTE_HEADERS); - const competing = await signedDevice(competingWs, other.path); - const competingConnect = await connectMigration(competingWs, { device: competing.device }); - expect(competingConnect.ok).toBe(true); - expect(competingConnect.payload).toMatchObject({ - auth: { scopes: ["operator.pairing"] }, - }); - const firstClosed = new Promise((resolve) => { - firstWs!.once("close", resolve); - }); - const competingClosed = new Promise((resolve) => { - competingWs!.once("close", resolve); - }); - - const list = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string }>; - }>(firstWs, "device.pair.list", {}); - expect(list.ok).toBe(true); - expect(list.payload?.pending).toHaveLength(1); - const pending = list.payload?.pending[0]; - expect(pending?.deviceId).toBe(first.identity.deviceId); - const competingList = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string }>; - }>(competingWs, "device.pair.list", {}); - const competingPending = competingList.payload?.pending[0]; - expect(competingPending?.deviceId).toBe(competing.identity.deviceId); - - const [firstApproval, competingApproval] = await Promise.allSettled([ - rpcReq(firstWs, "device.pair.approve", { requestId: pending?.requestId }), - rpcReq(competingWs, "device.pair.approve", { - requestId: competingPending?.requestId, - }), - ]); - const firstWon = firstApproval.status === "fulfilled" && firstApproval.value.ok; - const competingWon = competingApproval.status === "fulfilled" && competingApproval.value.ok; - expect([firstWon, competingWon].filter(Boolean)).toHaveLength(1); - if (firstWon) { - await expect(competingClosed).resolves.toBe(4001); - } else { - await expect(firstClosed).resolves.toBe(4001); - } - - firstWs.close(); - competingWs.close(); - const secondWs = await harness.openWs(REMOTE_HEADERS); - const second = await signedDevice(secondWs, firstWon ? firstIdentityPath : other.path); - const secondConnect = await connectMigration(secondWs, { device: second.device }); - expect(secondConnect.ok).toBe(true); - expect( - (secondConnect.payload as { deviceAuthMigration?: unknown } | undefined) - ?.deviceAuthMigration, - ).toBeUndefined(); - expect( - (secondConnect.payload as { auth?: { deviceToken?: string } } | undefined)?.auth - ?.deviceToken, - ).toEqual(expect.any(String)); - expect( - (secondConnect.payload as { auth?: { scopes?: string[] } } | undefined)?.auth?.scopes, - ).toEqual(expect.arrayContaining(SCOPES)); - }); - }); - - it("does not silently auto-approve a local signed migration browser", async () => { - await withMigrationHarness( - async (harness) => { - const ws = await harness.openWs({ origin: BROWSER_ORIGIN }); - const signed = await signedDevice(ws, identityPath("device-auth-migration-local-explicit")); - const connected = await connectMigration(ws, { device: signed.device }); - expect(connected.ok).toBe(true); - expect(connected.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - expect( - (connected.payload as { auth?: { deviceToken?: string } } | undefined)?.auth?.deviceToken, - ).toBeUndefined(); - - const list = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string }>; - }>(ws, "device.pair.list", {}); - expect(list.payload?.pending).toContainEqual( - expect.objectContaining({ deviceId: signed.identity.deviceId }), - ); - }, - { gateway: LOCAL_MIGRATION_GATEWAY }, - ); - }); - - it("adds pairing capability when a migrating browser requested read-only access", async () => { - await withMigrationHarness( - async (harness) => { - const ws = await harness.openWs({ origin: BROWSER_ORIGIN }); - const signed = await signedDevice(ws, identityPath("device-auth-migration-read-only"), [ - "operator.read", - ]); - const connected = await connectMigration(ws, { - device: signed.device, - scopes: ["operator.read"], - }); - expect(connected.ok).toBe(true); - expect(connected.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - - const list = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string; scopes?: string[] }>; - }>(ws, "device.pair.list", {}); - const ownRequest = list.payload?.pending.find( - (request) => request.deviceId === signed.identity.deviceId, - ); - expect(ownRequest?.scopes).toEqual( - expect.arrayContaining(["operator.read", "operator.pairing"]), - ); - const approved = await rpcReq(ws, "device.pair.approve", { - requestId: ownRequest?.requestId, - }); - expect(approved.ok).toBe(true); - - const paired = (await listPairings()).paired.find( - (device) => device.deviceId === signed.identity.deviceId, - ); - expect(paired?.tokens?.operator?.scopes).toEqual( - expect.arrayContaining(["operator.read", "operator.pairing"]), - ); - expect(await readMigrationState()).toMatchObject({ - status: "completed", - deviceId: signed.identity.deviceId, - }); - }, - { gateway: LOCAL_MIGRATION_GATEWAY }, - ); - }); - - it("rejects a device-less migration handshake when an operator is already paired", async () => { - await withMigrationHarness(async (harness) => { - const { request } = await requestOperatorPairing("device-auth-migration-existing-owner"); - await expect(approveOperatorPairing(request.request.requestId)).resolves.toMatchObject({ - status: "approved", - }); - const ws = await harness.openWs(REMOTE_HEADERS); - const connected = await connectMigration(ws, { device: null }); - expect(connected.ok).toBe(false); - expect(connected.error?.message).toContain("requires device identity"); - }); - }); - - it("closes competing migration sessions and keeps the winner self-only until reconnect", async () => { - await withMigrationHarness(async (harness) => { - const deviceLessWs = await harness.openWs(REMOTE_HEADERS); - const deviceLessConnect = await connectMigration(deviceLessWs, { device: null }); - expect(deviceLessConnect.ok).toBe(true); - const deviceLessClosed = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error("device-less migration session remained open after completion")); - }, 5_000); - deviceLessWs.once("close", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - - const signedWs = await harness.openWs(REMOTE_HEADERS); - const signed = await signedDevice( - signedWs, - identityPath("device-auth-migration-secure-completion"), - ); - const signedConnect = await connectMigration(signedWs, { device: signed.device }); - expect(signedConnect.ok).toBe(true); - const { request: otherRequest } = await requestOperatorPairing( - "device-auth-migration-other-pending", - ); - const list = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string }>; - }>(signedWs, "device.pair.list", {}); - const pending = list.payload?.pending.find( - (request) => request.deviceId === signed.identity.deviceId, - ); - expect(pending).toBeDefined(); - const approval = await rpcReq(signedWs, "device.pair.approve", { - requestId: pending?.requestId, - }); - expect(approval.ok).toBe(true); - await expect(deviceLessClosed).resolves.toBe(4001); - - const postApprovalList = await rpcReq<{ - pending: Array<{ requestId: string; deviceId: string }>; - paired: Array<{ deviceId: string }>; - }>(signedWs, "device.pair.list", {}); - expect(postApprovalList.ok).toBe(true); - expect(postApprovalList.payload?.pending).toEqual([]); - expect(postApprovalList.payload?.paired.map((device) => device.deviceId)).toEqual([ - signed.identity.deviceId, - ]); - const crossDeviceRejection = await rpcReq(signedWs, "device.pair.reject", { - requestId: otherRequest.request.requestId, - }); - expect(crossDeviceRejection.ok).toBe(false); - }); - }); - - it("denies a stale migration session after another operator is paired", async () => { - await withMigrationHarness(async (harness) => { - const migrationWs = await harness.openWs(REMOTE_HEADERS); - const migration = await signedDevice( - migrationWs, - identityPath("device-auth-migration-stale"), - ); - const migrationConnect = await connectMigration(migrationWs, { device: migration.device }); - expect(migrationConnect.ok).toBe(true); - expect(migrationConnect.payload).toMatchObject({ - deviceAuthMigration: { pending: true }, - auth: { role: "operator", scopes: ["operator.pairing"] }, - }); - const migrationClosed = new Promise((resolve) => { - migrationWs.once("close", resolve); - }); - - const { identity: ownerIdentity, request: ownerRequest } = await requestOperatorPairing( - "device-auth-migration-owner", - ); - await expect(approveOperatorPairing(ownerRequest.request.requestId)).resolves.toMatchObject({ - status: "approved", - }); - await expect(migrationClosed).resolves.toBe(4001); - - const paired = (await listPairings()).paired; - expect(paired.some((device) => device.deviceId === ownerIdentity.deviceId)).toBe(true); - expect(paired.some((device) => device.deviceId === migration.identity.deviceId)).toBe(false); - }); - }); -}); diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index d55d7d00f6d8..11547c0ddd2a 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -96,7 +96,6 @@ type GatewayWsSharedHandlerParams = { nodeReapprovalCoordinator?: NodeReapprovalCoordinator; preauthHandshakeTimeoutMs?: number; isStartupPending?: () => boolean; - isControlUiDeviceAuthMigrationPending?: () => boolean; gatewayMethods: string[]; events: string[]; refreshHealthSnapshot: GatewayRequestContext["refreshHealthSnapshot"]; @@ -190,7 +189,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti browserRateLimiter, nodeReapprovalCoordinator, isStartupPending, - isControlUiDeviceAuthMigrationPending, gatewayMethods, events, refreshHealthSnapshot, @@ -708,7 +706,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti browserRateLimiter, nodeReapprovalCoordinator, isStartupPending, - isControlUiDeviceAuthMigrationPending, gatewayMethods, events, extraHandlers, diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts index 747433db429b..b8b44d502988 100644 --- a/src/gateway/server/ws-connection/connect-auth.ts +++ b/src/gateway/server/ws-connection/connect-auth.ts @@ -23,8 +23,6 @@ import { verifyGatewayConnectDeviceProof } from "./connect-device-proof.js"; import { evaluateMissingDeviceIdentity, isTrustedProxyControlUiOperatorAuth, - resolveControlUiAuthPolicy, - shouldAllowControlUiDeviceAuthMigration, shouldClearUnboundScopesForMissingDeviceIdentity, shouldSkipControlUiPairing, } from "./connect-policy.js"; @@ -102,12 +100,7 @@ export async function authenticateGatewayConnect( const hasTokenAuth = Boolean(connectParams.auth?.token); const hasPasswordAuth = Boolean(connectParams.auth?.password); const hasSharedAuth = hasTokenAuth || hasPasswordAuth; - const controlUiAuthPolicy = resolveControlUiAuthPolicy({ - isControlUi, - deviceRaw, - deviceAuthMigrationPending: context.handler.isControlUiDeviceAuthMigrationPending?.(), - }); - const device = controlUiAuthPolicy.device; + const device = deviceRaw; const hasBootstrapProof = Boolean(connectParams.auth?.bootstrapToken); const hasDeviceTokenProof = Boolean(connectParams.auth?.deviceToken); const hasRawHandshakeCredentials = @@ -253,30 +246,17 @@ export async function authenticateGatewayConnect( authOk, authMethod, }); - const allowDeviceLessControlUiMigration = - !device && - shouldAllowControlUiDeviceAuthMigration({ - policy: controlUiAuthPolicy, - role, - sharedAuthOk, - trustedProxyAuthOk, - authMethod, - }); - const preserveInsecureLocalControlUiScopes = allowDeviceLessControlUiMigration; - const decision = allowDeviceLessControlUiMigration - ? ({ kind: "allow" } as const) - : evaluateMissingDeviceIdentity({ - hasDeviceIdentity: Boolean(device), - role, - isControlUi, - controlUiAuthPolicy, - trustedProxyAuthOk, - localBackendSelfPairingOk: skipLocalBackendSelfPairing, - sharedAuthOk, - authOk, - hasSharedAuth, - isLocalClient, - }); + const decision = evaluateMissingDeviceIdentity({ + hasDeviceIdentity: Boolean(device), + role, + isControlUi, + trustedProxyAuthOk, + localBackendSelfPairingOk: skipLocalBackendSelfPairing, + sharedAuthOk, + authOk, + hasSharedAuth, + isLocalClient, + }); // Device-less shared auth clears self-declared scopes by default. // Only first-party local control paths preserve scopes: backend self- // calls and CLI shared-secret calls that already proved loopback auth. @@ -284,13 +264,7 @@ export async function authenticateGatewayConnect( !device && !skipLocalBackendSelfPairing && !preserveLocalCliSharedAuthScopes && - shouldClearUnboundScopesForMissingDeviceIdentity({ - decision, - controlUiAuthPolicy, - preserveInsecureLocalControlUiScopes, - authMethod, - trustedProxyAuthOk, - }) + shouldClearUnboundScopesForMissingDeviceIdentity({ decision, authMethod }) ) { clearUnboundScopes(); } @@ -464,18 +438,11 @@ export async function authenticateGatewayConnect( scopes = applyConnectionScopeCap({ scopes, upgradeReq }); connectParams.scopes = scopes; } - const controlUiPairingKind = shouldSkipControlUiPairing( - controlUiAuthPolicy, + const controlUiPairingKind = shouldSkipControlUiPairing({ + isControlUi, + device, role, - trustedProxyAuthOk, - resolvedAuth.mode, - authMethod, - ); - const allowControlUiDeviceAuthMigration = shouldAllowControlUiDeviceAuthMigration({ - policy: controlUiAuthPolicy, - role, - sharedAuthOk, - trustedProxyAuthOk, + authMode: resolvedAuth.mode, authMethod, }); @@ -491,7 +458,6 @@ export async function authenticateGatewayConnect( isBrowserOperatorUi, isWebchat, isNativeAppUi, - controlUiAuthPolicy, device, devicePublicKey: deviceProof.devicePublicKey, deviceAuthPayloadVersion: deviceProof.deviceAuthPayloadVersion, @@ -509,7 +475,6 @@ export async function authenticateGatewayConnect( issuedBootstrapProfile, handoffBootstrapProfile, trustedProxyAuthOk, - allowControlUiDeviceAuthMigration, controlUiPairingKind, skipLocalBackendSelfPairing, rejectUnauthorized, diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index d89302990ade..dc02e07055f3 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -7,7 +7,6 @@ import { buildPairingConnectCloseReason, buildPairingConnectErrorDetails, buildPairingConnectErrorMessage, - ConnectErrorDetailCodes, type ConnectPairingRequiredReason, } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { ErrorCodes, errorShape } from "../../../../packages/gateway-protocol/src/index.js"; @@ -20,7 +19,6 @@ import { listApprovedPairedDeviceRoles, listDevicePairing, requestDevicePairing, - resolveEffectiveOperatorDeviceIdentity, updatePairedDeviceMetadata, } from "../../../infra/device-pairing.js"; import { @@ -123,11 +121,8 @@ export async function authorizeGatewayConnectDevice( pairingLocality, skipLocalBackendSelfPairing, controlUiPairingKind, - allowControlUiDeviceAuthMigration, } = state; let hasServerApprovedDeviceTokenBaseline = false; - let connectionAdmittedForControlUiDeviceAuthMigration = false; - let allowControlUiDeviceAuthMigrationForUnpairedInstall = false; let pairedClientId: string | undefined; let pairedBrowserOrigin: string | undefined; // Canonicalize protocol-v3 desktop aliases before pairing persistence and comparison. @@ -135,66 +130,6 @@ export async function authorizeGatewayConnectDevice( const browserCopilotOrigin = isBrowserCopilotClient(connectParams.client) ? normalizeChromeExtensionOrigin(requestOrigin) : undefined; - const deviceAuthMigrationScopes = - authMethod !== "trusted-proxy" || - roleScopesAllow({ - role: "operator", - requestedScopes: ["operator.pairing"], - allowedScopes: scopes, - }) - ? ["operator.pairing"] - : []; - if (allowControlUiDeviceAuthMigration) { - const pairingSnapshot = await listDevicePairing(); - const existingOperator = pairingSnapshot.paired - .map(resolveEffectiveOperatorDeviceIdentity) - .find( - (candidate) => - candidate && - roleScopesAllow({ - role: "operator", - requestedScopes: ["operator.pairing"], - allowedScopes: candidate.scopes, - }), - ); - if (existingOperator) { - // The transition is only for installs whose retired bypass left them - // without any trusted operator. Existing paired operators keep the - // normal owner-approval boundary for every other browser. - buildRequestContext().completeControlUiDeviceAuthMigration?.({ - deviceId: existingOperator.deviceId, - publicKey: existingOperator.publicKey, - scopes: existingOperator.scopes, - }); - if (!device) { - const message = - "control ui requires device identity (use HTTPS or localhost secure context)"; - setHandshakeState("failed"); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, message, { - details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED }, - }), - }); - close(1008, truncateCloseReason(message)); - return undefined; - } - } else { - allowControlUiDeviceAuthMigrationForUnpairedInstall = true; - } - } - if ( - !device && - allowControlUiDeviceAuthMigrationForUnpairedInstall && - deviceAuthMigrationScopes.length > 0 - ) { - // Plain-HTTP browsers cannot create a device identity. Preserve the exact - // legacy shared-auth posture until the operator reopens this UI through a - // secure context; never mint or approve a device without a signed key. - connectionAdmittedForControlUiDeviceAuthMigration = true; - } if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => { const normalized = normalizeSortedUniqueTrimmedStringList(items); @@ -369,18 +304,11 @@ export async function authorizeGatewayConnectDevice( : undefined; const bootstrapApprovalProfile = setupCodeHandoffBootstrapProfile ?? controlUiOperatorBootstrapProfile; - const pairingRequestScopes = - allowControlUiDeviceAuthMigrationForUnpairedInstall && - deviceAuthMigrationScopes.length > 0 && - reason === "not-paired" && - !existingPairedDevice - ? uniqueStrings([...scopes, ...deviceAuthMigrationScopes]) - : scopes; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientPairingMetadata, - scopes: pairingRequestScopes, + scopes, ...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles, @@ -443,8 +371,7 @@ export async function authorizeGatewayConnectDevice( return replacementPending?.requestId; }; const inlineApprovalAttempted = - !allowControlUiDeviceAuthMigrationForUnpairedInstall && - (trustedProxyAutoApproveScopes !== null || pairing.request.silent === true); + trustedProxyAutoApproveScopes !== null || pairing.request.silent === true; if (inlineApprovalAttempted) { approved = trustedProxyAutoApproveScopes !== null @@ -562,21 +489,6 @@ export async function authorizeGatewayConnectDevice( const pairingResolved = inlineApprovalAttempted && (approved?.status === "approved" || resolvedByConcurrentApproval); - if ( - allowControlUiDeviceAuthMigrationForUnpairedInstall && - deviceAuthMigrationScopes.length > 0 && - reason === "not-paired" && - !existingPairedDevice && - !pairingResolved && - recoveryRequestId - ) { - // A shipped break-glass configuration can leave an install with no - // approved browser. Keep only this signed, shared-authenticated - // operator online long enough to approve its own pending request. - connectionAdmittedForControlUiDeviceAuthMigration = true; - scopes = deviceAuthMigrationScopes; - return true; - } if (!pairingResolved) { const exposeApprovedAccess = existingPairedDevice?.publicKey === devicePublicKey; const approvedRoles = exposeApprovedAccess @@ -666,16 +578,12 @@ export async function authorizeGatewayConnectDevice( if (!ok) { return undefined; } - if (!connectionAdmittedForControlUiDeviceAuthMigration) { - const approvedDevice = await getPairedDevice(device.id); - pairedClientId = - approvedDevice?.publicKey === devicePublicKey ? approvedDevice.clientId : undefined; - pairedBrowserOrigin = - approvedDevice?.publicKey === devicePublicKey - ? approvedDevice.browserOrigin - : undefined; - hasServerApprovedDeviceTokenBaseline = true; - } + const approvedDevice = await getPairedDevice(device.id); + pairedClientId = + approvedDevice?.publicKey === devicePublicKey ? approvedDevice.clientId : undefined; + pairedBrowserOrigin = + approvedDevice?.publicKey === devicePublicKey ? approvedDevice.browserOrigin : undefined; + hasServerApprovedDeviceTokenBaseline = true; } else { hasServerApprovedDeviceTokenBaseline = true; } @@ -720,12 +628,6 @@ export async function authorizeGatewayConnectDevice( return undefined; } - if (connectionAdmittedForControlUiDeviceAuthMigration) { - // Temporary upgrade admission is gateway-wide, so cap authority here before - // client registration, hello publication, or token issuance can observe it. - scopes = deviceAuthMigrationScopes; - } - const { deviceToken, bootstrapDeviceTokens } = await issueGatewayConnectDeviceTokens({ state: { ...state, scopes, handoffBootstrapProfile }, scopes, @@ -738,6 +640,5 @@ export async function authorizeGatewayConnectDevice( handoffBootstrapProfile, deviceToken, bootstrapDeviceTokens, - controlUiDeviceAuthMigrationPending: connectionAdmittedForControlUiDeviceAuthMigration, }; } diff --git a/src/gateway/server/ws-connection/connect-hello.setup-completion.test.ts b/src/gateway/server/ws-connection/connect-hello.setup-completion.test.ts index 3842faabfcdf..94baa1b6a6e8 100644 --- a/src/gateway/server/ws-connection/connect-hello.setup-completion.test.ts +++ b/src/gateway/server/ws-connection/connect-hello.setup-completion.test.ts @@ -131,7 +131,6 @@ describe("sendGatewayHello setup completion ordering", () => { handoffBootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE, deviceToken: null, bootstrapDeviceTokens: [], - controlUiDeviceAuthMigrationPending: false, }; const hello = sendGatewayHello(context as never, state as never, {}); @@ -233,7 +232,6 @@ describe("sendGatewayHello setup completion ordering", () => { handoffBootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE, deviceToken: null, bootstrapDeviceTokens: [], - controlUiDeviceAuthMigrationPending: false, }; await sendGatewayHello(context as never, state as never, {}); @@ -339,7 +337,6 @@ describe("sendGatewayHello setup completion ordering", () => { handoffBootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE, deviceToken: null, bootstrapDeviceTokens: [], - controlUiDeviceAuthMigrationPending: false, }; await sendGatewayHello(context as never, state as never, {}); @@ -431,7 +428,6 @@ describe("sendGatewayHello setup completion ordering", () => { handoffBootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE, deviceToken: null, bootstrapDeviceTokens: [], - controlUiDeviceAuthMigrationPending: false, }; await sendGatewayHello(context as never, state as never, {}); diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index 1d773ff763f2..5b7558d52ce9 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -82,7 +82,6 @@ export async function sendGatewayHello( handoffBootstrapProfile, deviceToken, bootstrapDeviceTokens, - controlUiDeviceAuthMigrationPending, } = state; // Prefer the authenticated human; principal scopes never inherit device-token rows. const authenticatedPrincipal = authenticatedUserProfileId ?? authResult.user; @@ -146,9 +145,6 @@ export async function sendGatewayHello( ...(controlUiTabs.length > 0 ? { controlUiTabs } : {}), ...(controlUiWidgetKinds.length > 0 ? { controlUiWidgetKinds } : {}), ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}), - ...(controlUiDeviceAuthMigrationPending - ? { deviceAuthMigration: { pending: true as const } } - : {}), auth: { role, scopes, diff --git a/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts b/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts index 6e44841e39a1..6ceadca0462e 100644 --- a/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts +++ b/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts @@ -119,7 +119,6 @@ function makeState(role: "operator" | "node", scopes: string[]) { handoffBootstrapProfile: null, deviceToken: null, bootstrapDeviceTokens: [], - controlUiDeviceAuthMigrationPending: false, }; } diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 8bdc6af0f9ac..17f2c8b67521 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -3,19 +3,14 @@ import { describe, expect, test } from "vitest"; import { evaluateMissingDeviceIdentity, isTrustedProxyControlUiOperatorAuth, - resolveControlUiAuthPolicy, - shouldAllowControlUiDeviceAuthMigration, shouldClearUnboundScopesForMissingDeviceIdentity, shouldSkipControlUiPairing, } from "./connect-policy.js"; -type ControlUiAuthPolicyInput = Parameters[0]; -type DeviceRaw = NonNullable; +type SkipPairingInput = Parameters[0]; +type DeviceRaw = NonNullable; type MissingDeviceIdentityInput = Parameters[0]; type MissingDeviceDecisionKind = ReturnType["kind"]; -type PairingRole = Parameters[1]; -type PairingAuthMode = Parameters[3]; -type PairingAuthMethod = Parameters[4]; type ClearUnboundScopesInput = Parameters< typeof shouldClearUnboundScopesForMissingDeviceIdentity >[0]; @@ -30,24 +25,14 @@ function deviceRaw(id: string): DeviceRaw { }; } -function authPolicy(params: Partial = {}) { - return resolveControlUiAuthPolicy({ - isControlUi: params.isControlUi ?? false, - deviceRaw: params.deviceRaw ?? null, - deviceAuthMigrationPending: params.deviceAuthMigrationPending, - }); -} - function expectMissingDeviceDecision( overrides: Partial, expected: MissingDeviceDecisionKind, ) { - const isControlUi = overrides.isControlUi ?? false; const params: MissingDeviceIdentityInput = { hasDeviceIdentity: false, role: "operator", - isControlUi, - controlUiAuthPolicy: overrides.controlUiAuthPolicy ?? authPolicy({ isControlUi }), + isControlUi: false, trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, @@ -59,31 +44,22 @@ function expectMissingDeviceDecision( } function expectSkipPairing( - policy: Parameters[0], - role: PairingRole, + overrides: Partial, expected: ReturnType, - params: { - pairingComplete?: boolean; - authMode?: PairingAuthMode; - authMethod?: PairingAuthMethod; - } = {}, ) { expect( - shouldSkipControlUiPairing( - policy, - role, - params.pairingComplete ?? false, - params.authMode, - params.authMethod, - ), + shouldSkipControlUiPairing({ + isControlUi: false, + device: null, + role: "operator", + ...overrides, + }), ).toBe(expected); } function expectClearsUnboundScopes(overrides: Partial, expected: boolean) { const params: ClearUnboundScopesInput = { decision: { kind: "allow" }, - controlUiAuthPolicy: authPolicy(), - preserveInsecureLocalControlUiScopes: false, authMethod: "token", ...overrides, }; @@ -91,108 +67,23 @@ function expectClearsUnboundScopes(overrides: Partial, } describe("ws connect policy", () => { - test("resolves control-ui auth policy", () => { - const controlUi = authPolicy({ - isControlUi: true, - deviceRaw: deviceRaw("dev-1"), - }); - expect(controlUi.device?.id).toBe("dev-1"); - - const regular = authPolicy({ - isControlUi: false, - deviceRaw: deviceRaw("dev-2"), - }); - expect(regular.device?.id).toBe("dev-2"); - }); - - test("limits upgrade migration to shared-auth Control UI operators", () => { - const policy = authPolicy({ - isControlUi: true, - deviceRaw: deviceRaw("dev-migration"), - deviceAuthMigrationPending: true, - }); - expect( - shouldAllowControlUiDeviceAuthMigration({ - policy, - role: "operator", - sharedAuthOk: true, - authMethod: "token", - }), - ).toBe(true); - expect( - shouldAllowControlUiDeviceAuthMigration({ - policy: authPolicy({ - isControlUi: true, - deviceAuthMigrationPending: true, - }), - role: "operator", - sharedAuthOk: true, - authMethod: "password", - }), - ).toBe(true); - expect( - shouldAllowControlUiDeviceAuthMigration({ - policy, - role: "operator", - sharedAuthOk: false, - trustedProxyAuthOk: true, - authMethod: "trusted-proxy", - }), - ).toBe(true); - for (const candidate of [ - { policy, role: "node" as const, sharedAuthOk: true, authMethod: "token" }, - { policy, role: "operator" as const, sharedAuthOk: false, authMethod: "token" }, - { policy, role: "operator" as const, sharedAuthOk: true, authMethod: "device-token" }, - { - policy, - role: "operator" as const, - sharedAuthOk: false, - trustedProxyAuthOk: false, - authMethod: "trusted-proxy", - }, - ]) { - expect(shouldAllowControlUiDeviceAuthMigration(candidate)).toBe(false); - } - }); - test("evaluates missing-device decisions", () => { - const policy = authPolicy(); - const controlUi = authPolicy({ isControlUi: true }); + expectMissingDeviceDecision({ hasDeviceIdentity: true, role: "node" }, "allow"); expectMissingDeviceDecision( - { - hasDeviceIdentity: true, - role: "node", - controlUiAuthPolicy: policy, - }, - "allow", - ); - - expectMissingDeviceDecision( - { - role: "operator", - isControlUi: true, - controlUiAuthPolicy: controlUi, - isLocalClient: false, - }, + { role: "operator", isControlUi: true, isLocalClient: false }, "reject-control-ui-insecure-auth", ); expectMissingDeviceDecision( - { - role: "operator", - isControlUi: true, - controlUiAuthPolicy: controlUi, - isLocalClient: true, - }, + { role: "operator", isControlUi: true, isLocalClient: true }, "reject-control-ui-insecure-auth", ); - expectMissingDeviceDecision({ controlUiAuthPolicy: policy }, "allow"); + expectMissingDeviceDecision({}, "allow"); expectMissingDeviceDecision( { - controlUiAuthPolicy: policy, localBackendSelfPairingOk: true, sharedAuthOk: false, hasSharedAuth: false, @@ -204,7 +95,6 @@ describe("ws connect policy", () => { expectMissingDeviceDecision( { role: "node", - controlUiAuthPolicy: policy, localBackendSelfPairingOk: true, sharedAuthOk: false, hasSharedAuth: false, @@ -214,29 +104,17 @@ describe("ws connect policy", () => { ); expectMissingDeviceDecision( - { - controlUiAuthPolicy: policy, - sharedAuthOk: false, - authOk: false, - hasSharedAuth: true, - }, + { sharedAuthOk: false, authOk: false, hasSharedAuth: true }, "reject-unauthorized", ); - expectMissingDeviceDecision( - { - role: "node", - controlUiAuthPolicy: policy, - }, - "reject-device-required", - ); + expectMissingDeviceDecision({ role: "node" }, "reject-device-required"); // Trusted-proxy authenticated Control UI should bypass device-identity gating. expectMissingDeviceDecision( { role: "operator", isControlUi: true, - controlUiAuthPolicy: controlUi, trustedProxyAuthOk: true, sharedAuthOk: false, hasSharedAuth: false, @@ -248,7 +126,6 @@ describe("ws connect policy", () => { { role: "operator", isControlUi: true, - controlUiAuthPolicy: controlUi, sharedAuthOk: false, authOk: false, hasSharedAuth: false, @@ -260,7 +137,6 @@ describe("ws connect policy", () => { { role: "node", isControlUi: true, - controlUiAuthPolicy: controlUi, sharedAuthOk: false, authOk: false, hasSharedAuth: false, @@ -270,61 +146,47 @@ describe("ws connect policy", () => { }); test("strict control-ui policy does not skip pairing", () => { - const strict = authPolicy({ isControlUi: true }); - - expectSkipPairing(strict, "node", null); - expectSkipPairing(strict, "operator", null); - expectSkipPairing(strict, "operator", null, { pairingComplete: true }); + expectSkipPairing({ isControlUi: true, role: "node" }, null); + expectSkipPairing({ isControlUi: true, role: "operator" }, null); }); test("auth.mode=none skips pairing for operator control-ui only", () => { - const controlUi = authPolicy({ isControlUi: true }); - const nonControlUi = authPolicy(); - // Control UI + operator + auth.mode=none: skip pairing (the fix for #42931) - expectSkipPairing(controlUi, "operator", "auth-none", { authMode: "none" }); + expectSkipPairing({ isControlUi: true, role: "operator", authMode: "none" }, "auth-none"); // Control UI + node role + auth.mode=none: still require pairing - expectSkipPairing(controlUi, "node", null, { authMode: "none" }); + expectSkipPairing({ isControlUi: true, role: "node", authMode: "none" }, null); // Non-Control-UI + operator + auth.mode=none: still require pairing // (prevents #43478 regression where ALL clients bypassed pairing) - expectSkipPairing(nonControlUi, "operator", null, { authMode: "none" }); + expectSkipPairing({ role: "operator", authMode: "none" }, null); // Control UI + operator + auth.mode=shared-key: no change - expectSkipPairing(controlUi, "operator", null, { authMode: "shared-key" }); + expectSkipPairing({ isControlUi: true, role: "operator", authMode: "shared-key" }, null); // Control UI + operator + no authMode: no change - expectSkipPairing(controlUi, "operator", null); + expectSkipPairing({ isControlUi: true, role: "operator" }, null); }); test("tailscale auth skips pairing only for operator control-ui with device identity", () => { const device = deviceRaw("dev-1"); - const controlUiWithDevice = authPolicy({ - isControlUi: true, - deviceRaw: device, - }); - const controlUiWithoutDevice = authPolicy({ isControlUi: true }); - const nonControlUiWithDevice = authPolicy({ - deviceRaw: device, - }); - expectSkipPairing(controlUiWithDevice, "operator", "tailscale-device", { - authMode: "token", - authMethod: "tailscale", - }); - expectSkipPairing(controlUiWithoutDevice, "operator", null, { - authMode: "token", - authMethod: "tailscale", - }); - expectSkipPairing(controlUiWithDevice, "node", null, { - authMode: "token", - authMethod: "tailscale", - }); - expectSkipPairing(nonControlUiWithDevice, "operator", null, { - authMode: "token", - authMethod: "tailscale", - }); - expectSkipPairing(controlUiWithDevice, "operator", null, { - authMode: "token", - authMethod: "token", - }); + expectSkipPairing( + { isControlUi: true, device, role: "operator", authMode: "token", authMethod: "tailscale" }, + "tailscale-device", + ); + expectSkipPairing( + { isControlUi: true, role: "operator", authMode: "token", authMethod: "tailscale" }, + null, + ); + expectSkipPairing( + { isControlUi: true, device, role: "node", authMode: "token", authMethod: "tailscale" }, + null, + ); + expectSkipPairing( + { device, role: "operator", authMode: "token", authMethod: "tailscale" }, + null, + ); + expectSkipPairing( + { isControlUi: true, device, role: "operator", authMode: "token", authMethod: "token" }, + null, + ); }); test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { @@ -379,45 +241,12 @@ describe("ws connect policy", () => { }); test("clears unbound scopes for device-less shared auth outside explicit preservation cases", () => { - const nonControlUi = authPolicy(); - const controlUi = authPolicy({ isControlUi: true }); - - expectClearsUnboundScopes({ controlUiAuthPolicy: nonControlUi }, true); + expectClearsUnboundScopes({}, true); + expectClearsUnboundScopes({ authMethod: "password" }, true); + expectClearsUnboundScopes({ authMethod: "trusted-proxy" }, true); + expectClearsUnboundScopes({ authMethod: undefined }, false); expectClearsUnboundScopes( - { - controlUiAuthPolicy: nonControlUi, - authMethod: "password", - }, - true, - ); - expectClearsUnboundScopes( - { - controlUiAuthPolicy: nonControlUi, - authMethod: "trusted-proxy", - }, - true, - ); - expectClearsUnboundScopes( - { - controlUiAuthPolicy: nonControlUi, - authMethod: "trusted-proxy", - trustedProxyAuthOk: true, - }, - true, - ); - expectClearsUnboundScopes( - { - controlUiAuthPolicy: controlUi, - preserveInsecureLocalControlUiScopes: true, - }, - false, - ); - expectClearsUnboundScopes( - { - decision: { kind: "reject-device-required" }, - controlUiAuthPolicy: nonControlUi, - authMethod: undefined, - }, + { decision: { kind: "reject-device-required" }, authMethod: undefined }, true, ); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 7acfec50b5d8..5bc954f2a37a 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -3,63 +3,31 @@ import type { ConnectParams } from "../../../../packages/gateway-protocol/src/in import type { GatewayRole } from "../../role-policy.js"; import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; -type ControlUiAuthPolicy = { - isControlUi: boolean; - device: ConnectParams["device"] | null | undefined; - deviceAuthMigrationPending: boolean; -}; - export type ControlUiPairingKind = "tailscale-device" | "auth-none" | null; -export function resolveControlUiAuthPolicy(params: { +export function shouldSkipControlUiPairing(params: { isControlUi: boolean; - deviceRaw: ConnectParams["device"] | null | undefined; - deviceAuthMigrationPending?: boolean; -}): ControlUiAuthPolicy { - return { - isControlUi: params.isControlUi, - device: params.deviceRaw, - deviceAuthMigrationPending: params.deviceAuthMigrationPending === true, - }; -} - -export function shouldAllowControlUiDeviceAuthMigration(params: { - policy: ControlUiAuthPolicy; + device: ConnectParams["device"] | null | undefined; role: GatewayRole; - sharedAuthOk: boolean; - trustedProxyAuthOk?: boolean; + authMode?: string; authMethod?: string; -}): boolean { - const sharedAuthOk = - params.sharedAuthOk && (params.authMethod === "token" || params.authMethod === "password"); - const trustedProxyAuthOk = - params.trustedProxyAuthOk === true && params.authMethod === "trusted-proxy"; - return ( - params.policy.deviceAuthMigrationPending && - params.policy.isControlUi && +}): ControlUiPairingKind { + if ( + params.isControlUi && params.role === "operator" && - (sharedAuthOk || trustedProxyAuthOk) - ); -} - -export function shouldSkipControlUiPairing( - policy: ControlUiAuthPolicy, - role: GatewayRole, - _trustedProxyAuthOk = false, - authMode?: string, - authMethod?: string, -): ControlUiPairingKind { - if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) { + params.authMethod === "tailscale" && + params.device + ) { return "tailscale-device"; } // When auth is completely disabled (mode=none), there is no shared secret // or token to gate pairing. Requiring pairing in this configuration adds // friction without security value since any client can already connect - // without credentials. Guard with policy.isControlUi because this function - // is called for ALL clients (not just Control UI) at the call site. + // without credentials. Guard with isControlUi because this function is + // called for ALL clients (not just Control UI) at the call site. // Scope to operator role so node-role sessions still need device identity // (#43478 was reverted for skipping ALL clients). - if (policy.isControlUi && role === "operator" && authMode === "none") { + if (params.isControlUi && params.role === "operator" && params.authMode === "none") { return "auth-none"; } return null; @@ -89,17 +57,13 @@ type MissingDeviceIdentityDecision = export function shouldClearUnboundScopesForMissingDeviceIdentity(params: { decision: MissingDeviceIdentityDecision; - controlUiAuthPolicy: ControlUiAuthPolicy; - preserveInsecureLocalControlUiScopes: boolean; authMethod: string | undefined; - trustedProxyAuthOk?: boolean; }): boolean { return ( params.decision.kind !== "allow" || - (!params.preserveInsecureLocalControlUiScopes && - (params.authMethod === "token" || - params.authMethod === "password" || - params.authMethod === "trusted-proxy")) + params.authMethod === "token" || + params.authMethod === "password" || + params.authMethod === "trusted-proxy" ); } @@ -107,7 +71,6 @@ export function evaluateMissingDeviceIdentity(params: { hasDeviceIdentity: boolean; role: GatewayRole; isControlUi: boolean; - controlUiAuthPolicy: ControlUiAuthPolicy; trustedProxyAuthOk?: boolean; localBackendSelfPairingOk?: boolean; sharedAuthOk: boolean; diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index 0c8530e6aecc..f4ee8c38564e 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -373,17 +373,10 @@ export async function attachAuthenticatedGatewayConnect( clearHandshakeTimer(); const nextClient: GatewayWsClient = { socket, - connect: state.controlUiDeviceAuthMigrationPending - ? { ...connectParams, scopes } - : connectParams, + connect: connectParams, connId, connectionKind: "gateway", isDeviceTokenAuth: authMethod === "device-token", - isControlUiDeviceAuthMigrationSession: state.controlUiDeviceAuthMigrationPending, - // Only identity-bearing migration sessions may use bounded self-pairing. - // Device-less sessions remain pairing-scoped until reopened securely. - isControlUiDeviceAuthMigration: - state.controlUiDeviceAuthMigrationPending && Boolean(connectParams.device), pairedClientId: isBrowserCopilotClient(connectParams.client) ? connectParams.client.id : undefined, @@ -467,31 +460,6 @@ export async function attachAuthenticatedGatewayConnect( } } - if ( - state.controlUiDeviceAuthMigrationPending && - context.handler.isControlUiDeviceAuthMigrationPending?.() !== true - ) { - const hasDeviceIdentity = Boolean(device); - const message = "device auth migration completed during connect; reconnect"; - markHandshakeFailure("control-ui-device-auth-migration-completed", { - device: hasDeviceIdentity ? "yes" : "no", - }); - sendHandshakeErrorResponse( - hasDeviceIdentity ? ErrorCodes.NOT_PAIRED : ErrorCodes.INVALID_REQUEST, - message, - { - details: { - code: hasDeviceIdentity - ? ConnectErrorDetailCodes.PAIRING_REQUIRED - : ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - }, - }, - ); - await releasePendingNodePairingCleanup(); - close(1008, truncateCloseReason(message)); - return; - } - if (!setClient(nextClient)) { await releasePendingNodePairingCleanup(); setCloseCause("connect-aborted-before-register", { diff --git a/src/gateway/server/ws-connection/message-handler-types.ts b/src/gateway/server/ws-connection/message-handler-types.ts index 29683d7ffc52..8c656f78c333 100644 --- a/src/gateway/server/ws-connection/message-handler-types.ts +++ b/src/gateway/server/ws-connection/message-handler-types.ts @@ -18,12 +18,11 @@ import type { PluginNodeCapabilitySurface } from "../../plugin-node-capability.j import type { GatewayRole } from "../../role-policy.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import type { GatewayWsClient, WsHandshakePhase } from "../ws-types.js"; -import type { ControlUiPairingKind, resolveControlUiAuthPolicy } from "./connect-policy.js"; +import type { ControlUiPairingKind } from "./connect-policy.js"; import type { resolvePairingLocality } from "./handshake-auth-helpers.js"; import type { GatewayNodeLifecycleDispatchTracker } from "./node-lifecycle-dispatch.js"; type SubsystemLogger = ReturnType; -type ControlUiAuthPolicy = ReturnType; type PairingLocalityKind = ReturnType; export type WsOriginCheckMetrics = { @@ -57,7 +56,6 @@ export type GatewayWsMessageHandlerParams = { browserRateLimiter?: AuthRateLimiter; nodeReapprovalCoordinator?: NodeReapprovalCoordinator; isStartupPending?: () => boolean; - isControlUiDeviceAuthMigrationPending?: () => boolean; gatewayMethods: string[]; events: string[]; extraHandlers: GatewayRequestHandlers; @@ -129,7 +127,6 @@ export type AuthenticatedGatewayConnect = { isBrowserOperatorUi: boolean; isWebchat: boolean; isNativeAppUi: boolean; - controlUiAuthPolicy: ControlUiAuthPolicy; device: ConnectParams["device"] | null | undefined; devicePublicKey: string | null; deviceAuthPayloadVersion: "v2" | "v3" | null; @@ -147,7 +144,6 @@ export type AuthenticatedGatewayConnect = { issuedBootstrapProfile: DeviceBootstrapProfile | null; handoffBootstrapProfile: DeviceBootstrapProfile | null; trustedProxyAuthOk: boolean; - allowControlUiDeviceAuthMigration: boolean; controlUiPairingKind: ControlUiPairingKind; skipLocalBackendSelfPairing: boolean; rejectUnauthorized: (failedAuth: GatewayAuthResult) => void; @@ -155,7 +151,6 @@ export type AuthenticatedGatewayConnect = { export type DeviceAuthorizedGatewayConnect = AuthenticatedGatewayConnect & { deviceToken: DeviceAuthToken | null; - controlUiDeviceAuthMigrationPending: boolean; bootstrapDeviceTokens: Array<{ deviceToken: string; role: string; diff --git a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts index 356df2e15074..94cf8d26af95 100644 --- a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts @@ -1,5 +1,8 @@ // Raw WebSocket proof for the pre-registration Control UI build admission boundary. +import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; @@ -80,6 +83,37 @@ vi.mock("../../../version.js", async (importOriginal) => { import { attachGatewayWsMessageHandler } from "./message-handler.js"; +// A stale Control UI browser still owns a device identity; the build check is +// only reachable once the device passes connect auth and silent local pairing. +const temporaryIdentityPaths: string[] = []; + +async function buildSignedControlUiDevice(nonce: string) { + const { buildDeviceAuthPayload } = await import("../../device-auth.js"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../../../infra/device-identity.js"); + const identityPath = path.join(tmpdir(), `openclaw-build-admission-${randomUUID()}.sqlite`); + temporaryIdentityPaths.push(identityPath); + const identity = loadOrCreateDeviceIdentity({ path: identityPath }); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: "openclaw-control-ui", + clientMode: "webchat", + role: "operator", + scopes: [], + signedAtMs, + token: "test-token", + nonce, + }); + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; +} + function createLogger() { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; } @@ -94,9 +128,19 @@ function withDeadline(promise: Promise, label: string): Promise { ]); } -afterEach(() => { +afterEach(async () => { vi.clearAllMocks(); resolveRuntimeServiceBuildIdMock.mockReturnValue("gateway-build"); + const { rm } = await import("node:fs/promises"); + await Promise.all( + temporaryIdentityPaths + .splice(0) + .flatMap((identityPath) => + [identityPath, `${identityPath}-wal`, `${identityPath}-shm`].map((file) => + rm(file, { force: true }), + ), + ), + ); }); describe("Control UI build admission over WebSocket", () => { @@ -147,7 +191,6 @@ describe("Control UI build admission over WebSocket", () => { requestOrigin: typeof request.headers.origin === "string" ? request.headers.origin : undefined, connectNonce: "legacy-build-nonce", - isControlUiDeviceAuthMigrationPending: () => true, getResolvedAuth: () => ({ mode: "token", token: "test-token", @@ -156,7 +199,7 @@ describe("Control UI build admission over WebSocket", () => { gatewayMethods: [], events: [], extraHandlers: {}, - buildRequestContext: () => ({}) as GatewayRequestContext, + buildRequestContext: () => ({ broadcast: vi.fn() }) as unknown as GatewayRequestContext, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: vi.fn(), send, @@ -190,6 +233,7 @@ describe("Control UI build admission over WebSocket", () => { }); }); + const device = await buildSignedControlUiDevice("legacy-build-nonce"); const ws = new WebSocket(`ws://127.0.0.1:${address.port}`, { headers: { origin, @@ -234,6 +278,7 @@ describe("Control UI build admission over WebSocket", () => { role: "operator", caps: [], auth: { token: "test-token" }, + device, }, }), ); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index 2abe1ff402c0..9c8f4e9f5831 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -30,10 +30,6 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { connectionKind?: GatewayWsConnectionKind; worker?: WorkerConnectionIdentity; isDeviceTokenAuth?: boolean; - /** Temporary legacy migration session closed when normal enforcement resumes. */ - isControlUiDeviceAuthMigrationSession?: boolean; - /** Signed shared-auth session admitted only to approve its own upgrade pairing. */ - isControlUiDeviceAuthMigration?: boolean; /** Client id verified against the server-approved device pairing record. */ pairedClientId?: string; usesSharedGatewayAuth: boolean; diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 4dc4263ae9f5..0755c16a6300 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -20,14 +20,12 @@ import { } from "./device-pairing-store.js"; import { approveBootstrapDevicePairing, - approveControlUiDeviceAuthMigrationPairing, approveDevicePairing, ensureDeviceToken, getPairedDevice, hasEffectivePairedDeviceRole, listEffectivePairedDeviceRoles, listDevicePairing, - onEffectiveOperatorDevicePaired, removePairedDevice, requestDevicePairing, rejectDevicePairing, @@ -203,107 +201,6 @@ async function makeDevicePairingDir(): Promise { } describe("device pairing tokens", () => { - test("notifies effective-operator listeners for owner and bootstrap approvals", async () => { - const baseDir = await makeDevicePairingDir(); - const pairedDevices: Array<{ deviceId: string; publicKey: string; scopes: string[] }> = []; - const unsubscribe = onEffectiveOperatorDevicePaired((device) => { - pairedDevices.push(device); - }); - try { - const nodeRequest = await requestDevicePairing( - { - deviceId: "listener-node", - publicKey: "listener-node-key", - role: "node", - scopes: [], - }, - baseDir, - ); - await approveDevicePairing(nodeRequest.request.requestId, { callerScopes: [] }, baseDir); - - const ownerRequest = await requestDevicePairing( - { - deviceId: "listener-owner", - publicKey: "listener-owner-key", - role: "operator", - scopes: ["operator.read"], - }, - baseDir, - ); - await approveDevicePairing( - ownerRequest.request.requestId, - { callerScopes: ["operator.read"] }, - baseDir, - ); - - const bootstrapRequest = await requestDevicePairing( - { - deviceId: "listener-bootstrap", - publicKey: "listener-bootstrap-key", - role: "operator", - scopes: ["operator.read"], - silent: true, - }, - baseDir, - ); - await approveBootstrapDevicePairing( - bootstrapRequest.request.requestId, - FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, - baseDir, - ); - - expect(pairedDevices).toEqual([ - { - deviceId: "listener-owner", - publicKey: "listener-owner-key", - scopes: ["operator.read"], - }, - { - deviceId: "listener-bootstrap", - publicKey: "listener-bootstrap-key", - scopes: ["operator.read"], - }, - ]); - } finally { - unsubscribe(); - } - }); - - test("allows migration approval when existing operators cannot manage pairings", async () => { - const baseDir = await makeDevicePairingDir(); - const readOnlyRequest = await requestDevicePairing( - { - deviceId: "read-only-owner", - publicKey: "read-only-owner-key", - role: "operator", - scopes: ["operator.read"], - }, - baseDir, - ); - await approveDevicePairing( - readOnlyRequest.request.requestId, - { callerScopes: ["operator.read"] }, - baseDir, - ); - const migrationRequest = await requestDevicePairing( - { - deviceId: "migration-owner", - publicKey: "migration-owner-key", - role: "operator", - scopes: ["operator.pairing"], - }, - baseDir, - ); - - await expect( - approveControlUiDeviceAuthMigrationPairing( - migrationRequest.request.requestId, - { callerScopes: ["operator.pairing"] }, - baseDir, - ), - ).resolves.toMatchObject({ status: "approved" }); - }); - beforeAll(async () => { suiteBaseDir = await suiteRootTracker.setup(); }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 5e93d555084e..277a6c9a2190 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -124,7 +124,6 @@ type DevicePairingForbiddenReason = | "caller-scopes-required" | "caller-missing-scope" | "scope-outside-requested-roles" - | "effective-operator-already-paired" | "bootstrap-role-not-allowed" | "bootstrap-scope-not-allowed"; @@ -161,36 +160,6 @@ const BROWSER_DEVICE_CLIENT_IDS = new Set(["openclaw-control-ui", "webchat-ui"]) const BROWSER_DEVICE_CLIENT_MODE = "webchat"; const withLock = createAsyncLock(); -export type EffectiveOperatorDeviceIdentity = Pick & { - scopes: string[]; -}; - -const effectiveOperatorPairingListeners = new Set< - (device: EffectiveOperatorDeviceIdentity) => void ->(); - -/** Subscribe to canonical pairing mutations that establish an effective operator. */ -export function onEffectiveOperatorDevicePaired( - listener: (device: EffectiveOperatorDeviceIdentity) => void, -): () => void { - effectiveOperatorPairingListeners.add(listener); - return () => effectiveOperatorPairingListeners.delete(listener); -} - -function notifyEffectiveOperatorDevicePaired(device: PairedDevice): void { - const identity = resolveEffectiveOperatorDeviceIdentity(device); - if (!identity) { - return; - } - for (const listener of effectiveOperatorPairingListeners) { - try { - listener(identity); - } catch { - // Pairing is already durable; observer failures cannot roll it back. - } - } -} - /** Format a device-pairing authorization failure for CLI/API callers. */ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbiddenResult): string { switch (result.reason) { @@ -200,8 +169,6 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd return `missing scope: ${result.scope ?? "unknown"}`; case "scope-outside-requested-roles": return `invalid scope for requested roles: ${result.scope ?? "unknown"}`; - case "effective-operator-already-paired": - return "an effective operator device is already paired"; case "bootstrap-role-not-allowed": return `bootstrap profile does not allow role: ${result.role ?? "unknown"}`; case "bootstrap-scope-not-allowed": @@ -331,40 +298,6 @@ export function hasEffectivePairedDeviceRole( return listEffectivePairedDeviceRoles(device).includes(normalized); } -function hasEffectivePairedDeviceScope( - device: Pick, - role: string, - scope: string, -): boolean { - const normalizedRole = normalizeRole(role); - const token = normalizedRole ? device.tokens?.[normalizedRole] : undefined; - return Boolean( - normalizedRole && - token && - !token.revokedAtMs && - hasEffectivePairedDeviceRole(device, normalizedRole) && - roleScopesAllow({ - role: normalizedRole, - requestedScopes: [scope], - allowedScopes: token.scopes, - }), - ); -} - -export function resolveEffectiveOperatorDeviceIdentity( - device: PairedDevice, -): EffectiveOperatorDeviceIdentity | null { - const token = device.tokens?.[OPERATOR_ROLE]; - if (!token || token.revokedAtMs || !hasEffectivePairedDeviceRole(device, OPERATOR_ROLE)) { - return null; - } - return { - deviceId: device.deviceId, - publicKey: device.publicKey, - scopes: normalizeDeviceAuthScopes(token.scopes), - }; -} - /** Resolve the authenticated node pairing independently of surface approval. */ function resolveNodePairingIdentity(device: PairedDevice | null): NodePairingIdentity | null { if (!device || !hasEffectivePairedDeviceRole(device, "node")) { @@ -1022,19 +955,6 @@ export async function approveDevicePairing( return await approveDevicePairingWithOptions(requestId, options, baseDir); } -/** Approve the legacy Control UI migration only while no pairing-capable operator is paired. */ -export async function approveControlUiDeviceAuthMigrationPairing( - requestId: string, - options: { callerScopes: readonly string[] }, - baseDir?: string, -): Promise { - return await approveDevicePairingWithOptions( - requestId, - { ...options, requireNoPairingCapableOperator: true }, - baseDir, - ); -} - async function approveDevicePairingWithOptions( requestId: string, options: @@ -1046,7 +966,6 @@ async function approveDevicePairingWithOptions( "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" >; autoApproveNewDeviceScopes?: readonly string[]; - requireNoPairingCapableOperator?: boolean; } | undefined, baseDir?: string, @@ -1057,14 +976,6 @@ async function approveDevicePairingWithOptions( if (!pendingRecord) { return null; } - if ( - options?.requireNoPairingCapableOperator && - Object.values(state.pairedByDeviceId).some((device) => - hasEffectivePairedDeviceScope(device, OPERATOR_ROLE, "operator.pairing"), - ) - ) { - return { status: "forbidden", reason: "effective-operator-already-paired" }; - } const autoApproveScopes = options?.autoApproveNewDeviceScopes; const requestedRoles = resolveRequestedRoles(pendingRecord); const knownDevice = state.pairedByDeviceId[pendingRecord.deviceId]; @@ -1182,7 +1093,6 @@ async function approveDevicePairingWithOptions( "both", installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, ); - notifyEffectiveOperatorDevicePaired(device); return { status: "approved", requestId, @@ -1303,7 +1213,6 @@ export async function approveBootstrapDevicePairing( "both", installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, ); - notifyEffectiveOperatorDevicePaired(device); return { status: "approved", requestId, diff --git a/src/infra/state-migrations.config-machine-state.test.ts b/src/infra/state-migrations.config-machine-state.test.ts index 5dac704d87e4..3f9c5171bdfe 100644 --- a/src/infra/state-migrations.config-machine-state.test.ts +++ b/src/infra/state-migrations.config-machine-state.test.ts @@ -3,13 +3,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { readConfigMachineState, writeConfigMachineState } from "../state/config-machine-state.js"; -import { - claimControlUiDeviceAuthMigration, - completeControlUiDeviceAuthMigration, - recoverControlUiDeviceAuthMigrationClaim, - readControlUiDeviceAuthMigrationState, - releaseControlUiDeviceAuthMigrationClaim, -} from "../state/control-ui-device-auth-migration.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { migrateLegacyConfigMachineState } from "./state-migrations.config-machine-state.js"; @@ -114,89 +107,6 @@ describe("legacy config machine-state migration", () => { expect(readConfigMachineState("plugins.bundledDiscovery", { env })).toBeUndefined(); }); - it("preserves the shipped device-auth bypass as pending migration state", () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - - migrateLegacyConfigMachineState({ - env, - config: { - gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } }, - }, - }); - - expect(readControlUiDeviceAuthMigrationState({ env })).toMatchObject({ - version: 1, - status: "pending", - }); - }); - - it("never reopens a completed migration from stale legacy config", () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - completeControlUiDeviceAuthMigration("browser-1", { env }); - - migrateLegacyConfigMachineState({ - env, - config: { - gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } }, - }, - }); - - expect(readControlUiDeviceAuthMigrationState({ env })).toMatchObject({ - status: "completed", - deviceId: "browser-1", - }); - }); - - it("allows one claim and recovers a released or interrupted claim", () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - migrateLegacyConfigMachineState({ - env, - config: { gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }, - }); - - expect(claimControlUiDeviceAuthMigration("browser-1", { env })).toBe(true); - expect(claimControlUiDeviceAuthMigration("browser-2", { env })).toBe(false); - releaseControlUiDeviceAuthMigrationClaim("browser-1", { env }); - expect(claimControlUiDeviceAuthMigration("browser-2", { env })).toBe(true); - - expect(recoverControlUiDeviceAuthMigrationClaim({ env })).toMatchObject({ - status: "pending", - }); - expect(claimControlUiDeviceAuthMigration("browser-3", { env })).toBe(true); - }); - - it("does not create migration state when the retired bypass was disabled", () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - - migrateLegacyConfigMachineState({ - env, - config: { - gateway: { controlUi: { dangerouslyDisableDeviceAuth: false } }, - }, - }); - - expect(readControlUiDeviceAuthMigrationState({ env })).toBeUndefined(); - }); - - it("does not treat a newly written current config as upgrade input", () => { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - - migrateLegacyConfigMachineState({ - env, - config: { - meta: { lastTouchedVersion: "2026.7.2" }, - gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } }, - }, - }); - - expect(readControlUiDeviceAuthMigrationState({ env })).toBeUndefined(); - }); - it("does not re-report inferred bundledDiscovery on second pass with beta version", () => { const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/infra/state-migrations.config-machine-state.ts b/src/infra/state-migrations.config-machine-state.ts index 95d3501d0d0f..1032c4bb17cf 100644 --- a/src/infra/state-migrations.config-machine-state.ts +++ b/src/infra/state-migrations.config-machine-state.ts @@ -7,11 +7,6 @@ import { readConfigMachineState, updateConfigMachineState, } from "../state/config-machine-state.js"; -import { - CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, - isLegacyControlUiDeviceAuthMigrationInput, - type ControlUiDeviceAuthMigrationState, -} from "../state/control-ui-device-auth-migration.js"; const BUNDLED_DISCOVERY_STATE_CUTOVER_VERSION = "2026.7.2"; @@ -22,22 +17,7 @@ export function migrateLegacyConfigMachineState(params: { }): { changes: string[]; warnings: string[] } { const raw = params.config as Record; const entries: Array = []; - const controlUi = asOptionalRecord(asOptionalRecord(raw.gateway)?.controlUi); const meta = asOptionalRecord(raw.meta); - if ( - isLegacyControlUiDeviceAuthMigrationInput({ - disabledDeviceAuth: controlUi?.dangerouslyDisableDeviceAuth === true, - lastTouchedVersion: - typeof meta?.lastTouchedVersion === "string" ? meta.lastTouchedVersion : undefined, - }) - ) { - const pending: ControlUiDeviceAuthMigrationState = { - version: 1, - status: "pending", - detectedAtMs: Date.now(), - }; - entries.push([CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, pending]); - } if (meta && Object.hasOwn(meta, "lastTouchedAt")) { entries.push(["config.lastTouchedAt", meta.lastTouchedAt]); } diff --git a/src/security/audit-control-ui-device-auth-migration.test.ts b/src/security/audit-control-ui-device-auth-migration.test.ts deleted file mode 100644 index e2042119baf0..000000000000 --- a/src/security/audit-control-ui-device-auth-migration.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Verifies pending Control UI device-auth migrations remain visible to security audits. -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - completeControlUiDeviceAuthMigration, - importPendingControlUiDeviceAuthMigration, -} from "../state/control-ui-device-auth-migration.js"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { runSecurityAuditCore } from "./audit.js"; - -const stateDirs: string[] = []; - -function createStateEnv(): { stateDir: string; env: NodeJS.ProcessEnv } { - const stateDir = mkdtempSync(join(tmpdir(), "openclaw-audit-device-auth-migration-")); - stateDirs.push(stateDir); - return { stateDir, env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } }; -} - -async function collectMigrationFinding(params: { stateDir: string; env: NodeJS.ProcessEnv }) { - const report = await runSecurityAuditCore({ - config: {}, - env: params.env, - stateDir: params.stateDir, - includeFilesystem: false, - includeChannelSecurity: false, - loadPluginSecurityCollectors: false, - }); - return report.findings.find( - (finding) => finding.checkId === "gateway.control_ui.device_auth_disabled", - ); -} - -afterEach(() => { - closeOpenClawStateDatabaseForTest(); - for (const stateDir of stateDirs.splice(0)) { - rmSync(stateDir, { recursive: true, force: true }); - } -}); - -describe("security audit Control UI device-auth migration", () => { - it("reports the pending compatibility window as a critical downgrade", async () => { - const options = createStateEnv(); - importPendingControlUiDeviceAuthMigration({ env: options.env }); - - const finding = await collectMigrationFinding(options); - - expect(finding).toMatchObject({ - severity: "critical", - title: "Control UI device-auth migration is pending", - }); - }); - - it("clears the finding after pairing completes", async () => { - const options = createStateEnv(); - importPendingControlUiDeviceAuthMigration({ env: options.env }); - completeControlUiDeviceAuthMigration("browser-1", { env: options.env }); - - await expect(collectMigrationFinding(options)).resolves.toBeUndefined(); - }); - - it("does not report a migration that was never required", async () => { - const options = createStateEnv(); - - await expect(collectMigrationFinding(options)).resolves.toBeUndefined(); - }); -}); diff --git a/src/security/audit.ts b/src/security/audit.ts index 1ec4ca7d5b69..9e48f590c807 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -39,7 +39,6 @@ import { import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { resolvePluginControlPlaneWorkspace } from "../plugins/control-plane-workspace.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; -import { readControlUiDeviceAuthMigrationState } from "../state/control-ui-device-auth-migration.js"; import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; import { @@ -482,30 +481,6 @@ function collectGatewayConfigFindings( }); } -function collectControlUiDeviceAuthMigrationFindings(params: { - env: NodeJS.ProcessEnv; - stateDir: string; -}): SecurityAuditFinding[] { - const migration = readControlUiDeviceAuthMigrationState({ - env: { ...params.env, OPENCLAW_STATE_DIR: params.stateDir }, - }); - if (migration?.status !== "pending") { - return []; - } - return [ - { - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - title: "Control UI device-auth migration is pending", - detail: - "The retired device-auth bypass was imported into pending migration state. " + - "Device-less Control UI sessions with valid shared auth can still connect for remediation until an operator browser completes pairing.", - remediation: - "Reopen the Control UI over HTTPS or localhost and click Secure this browser to complete pairing and end the compatibility window.", - }, - ]; -} - async function collectPluginSecurityAuditFindings( context: AuditExecutionContext, ): Promise { @@ -1361,7 +1336,6 @@ export async function runSecurityAuditCore( gatewayAuthOverride: context.auditGatewayAuthOverride, }), ); - findings.push(...collectControlUiDeviceAuthMigrationFindings({ env, stateDir })); findings.push(...(await collectPluginSecurityAuditFindings(context))); findings.push(...collectElevatedFindings(cfg)); findings.push(...collectExecRuntimeFindings(cfg)); diff --git a/src/state/control-ui-device-auth-migration.ts b/src/state/control-ui-device-auth-migration.ts deleted file mode 100644 index e01a9b5843c4..000000000000 --- a/src/state/control-ui-device-auth-migration.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { compareOpenClawVersions } from "../config/version.js"; -// Durable transition state for installs upgrading from the retired Control UI device-auth bypass. -import { - importConfigMachineState, - readConfigMachineState, - updateConfigMachineState, - writeConfigMachineState, -} from "./config-machine-state.js"; -import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js"; - -export const CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY = "gateway.controlUi.deviceAuthMigration"; -const DEVICE_AUTH_MIGRATION_CUTOVER_VERSION = "2026.7.2"; - -export type ControlUiDeviceAuthMigrationState = - | { - version: 1; - status: "pending"; - detectedAtMs: number; - claimedDeviceId?: string; - claimedAtMs?: number; - } - | { - version: 1; - status: "completed"; - detectedAtMs: number; - completedAtMs: number; - deviceId: string; - }; - -function isFiniteTimestamp(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value >= 0; -} - -function normalizeState(value: unknown): ControlUiDeviceAuthMigrationState | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const candidate = value as Partial; - if (candidate.version !== 1 || !isFiniteTimestamp(candidate.detectedAtMs)) { - return undefined; - } - if (candidate.status === "pending") { - const claimedDeviceId = - typeof candidate.claimedDeviceId === "string" && candidate.claimedDeviceId.trim() - ? candidate.claimedDeviceId.trim() - : undefined; - const claimedAtMs = isFiniteTimestamp(candidate.claimedAtMs) - ? candidate.claimedAtMs - : undefined; - return { - version: 1, - status: "pending", - detectedAtMs: candidate.detectedAtMs, - ...(claimedDeviceId && claimedAtMs !== undefined ? { claimedDeviceId, claimedAtMs } : {}), - }; - } - if ( - candidate.status === "completed" && - isFiniteTimestamp(candidate.completedAtMs) && - typeof candidate.deviceId === "string" && - candidate.deviceId.trim() - ) { - return { - version: 1, - status: "completed", - detectedAtMs: candidate.detectedAtMs, - completedAtMs: candidate.completedAtMs, - deviceId: candidate.deviceId.trim(), - }; - } - return undefined; -} - -export function isLegacyControlUiDeviceAuthMigrationInput(params: { - disabledDeviceAuth: boolean; - lastTouchedVersion?: string; -}): boolean { - return ( - params.disabledDeviceAuth && - (typeof params.lastTouchedVersion !== "string" || - compareOpenClawVersions(params.lastTouchedVersion, DEVICE_AUTH_MIGRATION_CUTOVER_VERSION) === - -1) - ); -} - -export function readControlUiDeviceAuthMigrationState( - options: OpenClawStateDatabaseOptions = {}, -): ControlUiDeviceAuthMigrationState | undefined { - return normalizeState( - readConfigMachineState(CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, options), - ); -} - -/** - * Capture the shipped break-glass flag before Doctor removes it. Import semantics - * preserve a completed receipt so stale config cannot reopen migration access. - */ -export function importPendingControlUiDeviceAuthMigration( - options: OpenClawStateDatabaseOptions = {}, -): ControlUiDeviceAuthMigrationState { - const pending: ControlUiDeviceAuthMigrationState = { - version: 1, - status: "pending", - detectedAtMs: Date.now(), - }; - importConfigMachineState([[CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, pending]], options); - return readControlUiDeviceAuthMigrationState(options) ?? pending; -} - -export function completeControlUiDeviceAuthMigration( - deviceId: string, - options: OpenClawStateDatabaseOptions = {}, -): ControlUiDeviceAuthMigrationState { - const normalizedDeviceId = deviceId.trim(); - if (!normalizedDeviceId) { - throw new Error("device auth migration completion requires a device id"); - } - const current = readControlUiDeviceAuthMigrationState(options); - const completed: ControlUiDeviceAuthMigrationState = { - version: 1, - status: "completed", - detectedAtMs: current?.detectedAtMs ?? Date.now(), - completedAtMs: Date.now(), - deviceId: normalizedDeviceId, - }; - writeConfigMachineState(CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, completed, options); - return completed; -} - -export function recoverControlUiDeviceAuthMigrationClaim( - options: OpenClawStateDatabaseOptions = {}, -): ControlUiDeviceAuthMigrationState | undefined { - const initial = readControlUiDeviceAuthMigrationState(options); - if (initial?.status !== "pending" || !initial.claimedDeviceId) { - return initial; - } - return updateConfigMachineState( - CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, - (raw) => { - const current = normalizeState(raw) ?? initial; - if (current?.status !== "pending" || !current.claimedDeviceId) { - return current; - } - return { - version: 1, - status: "pending", - detectedAtMs: current.detectedAtMs, - }; - }, - options, - ); -} - -export function claimControlUiDeviceAuthMigration( - deviceId: string, - options: OpenClawStateDatabaseOptions = {}, -): boolean { - const normalizedDeviceId = deviceId.trim(); - if (!normalizedDeviceId) { - return false; - } - const initial = readControlUiDeviceAuthMigrationState(options); - if (initial?.status !== "pending" || initial.claimedDeviceId) { - return false; - } - let claimed = false; - updateConfigMachineState( - CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, - (raw) => { - const current = normalizeState(raw) ?? initial; - if (current?.status !== "pending" || current.claimedDeviceId) { - return current; - } - claimed = true; - return { - ...current, - claimedDeviceId: normalizedDeviceId, - claimedAtMs: Date.now(), - }; - }, - options, - ); - return claimed; -} - -export function releaseControlUiDeviceAuthMigrationClaim( - deviceId: string, - options: OpenClawStateDatabaseOptions = {}, -): void { - const normalizedDeviceId = deviceId.trim(); - const initial = readControlUiDeviceAuthMigrationState(options); - if (initial?.status !== "pending" || initial.claimedDeviceId !== normalizedDeviceId) { - return; - } - updateConfigMachineState( - CONTROL_UI_DEVICE_AUTH_MIGRATION_STATE_KEY, - (raw) => { - const current = normalizeState(raw) ?? initial; - if (current?.status !== "pending" || current.claimedDeviceId !== normalizedDeviceId) { - return current; - } - return { - version: 1, - status: "pending", - detectedAtMs: current.detectedAtMs, - }; - }, - options, - ); -} diff --git a/ui/src/app/app-host.dock-suppression.test.ts b/ui/src/app/app-host.dock-suppression.test.ts index 67c4697d3275..7f326a091144 100644 --- a/ui/src/app/app-host.dock-suppression.test.ts +++ b/ui/src/app/app-host.dock-suppression.test.ts @@ -107,7 +107,6 @@ describe("OpenClaw shell dock suppression", () => { devicePairSetupOpen: false, devicePairSetupLifecycle: { phase: "selection", access: "full" }, devicePairPendingCount: 0, - deviceAuthMigration: { error: null }, }, runUpdate: vi.fn(), }, diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 06c221bcae4e..b123231b90da 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -583,26 +583,6 @@ export function renderApplicationShell(host: ShellViewHost) { .tabIndex=${-1} > ${renderScopeUpgradeBanner(host, gatewaySnapshot)} - ${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true - ? // The migration banner is registered by a rare-flow dynamic import after first render. - customElements.get("openclaw-device-auth-migration-banner") - ? html` void context.overlays.secureThisBrowser(), - }} - >` - : html`` - : nothing} ${renderFloatingUpdateCard({ navigationSurfaceHidden, onboarding, diff --git a/ui/src/app/device-auth-migration-loader.ts b/ui/src/app/device-auth-migration-loader.ts deleted file mode 100644 index 7f10e1a9b8eb..000000000000 --- a/ui/src/app/device-auth-migration-loader.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { GatewayBrowserClient } from "../api/gateway.ts"; -import { t } from "../i18n/index.ts"; -import { formatUiError } from "../lib/format-error.ts"; -import type { - DeviceAuthMigrationController, - DeviceAuthMigrationSnapshot, -} from "./device-auth-migration.ts"; -import type { ApplicationGateway } from "./gateway.ts"; - -export const EMPTY_DEVICE_AUTH_MIGRATION: DeviceAuthMigrationSnapshot = { - requestId: null, - busy: false, - error: null, -}; - -export function createDeviceAuthMigrationLoader(params: { - gateway: ApplicationGateway; - isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean; - onChange: (snapshot: DeviceAuthMigrationSnapshot) => void; -}) { - let controllerPromise: Promise | null = null; - let disposed = false; - - const reset = () => { - void controllerPromise?.then((controller) => controller?.reset()); - params.onChange(EMPTY_DEVICE_AUTH_MIGRATION); - }; - const load = (client: GatewayBrowserClient, epoch: number) => { - // This is a rare upgrade-only flow. Load it once when hello reports the - // pending transition so ordinary Control UI startup does not pay its cost. - controllerPromise ??= import("./device-auth-migration.ts") - .then(({ createDeviceAuthMigrationController }) => { - if (disposed) { - return null; - } - return createDeviceAuthMigrationController(params); - }) - .catch((error: unknown) => { - controllerPromise = null; - if (params.isCurrent(client, epoch)) { - params.onChange({ - ...EMPTY_DEVICE_AUTH_MIGRATION, - error: t("login.deviceAuthMigration.loadFailed", { - error: formatUiError(error), - }), - }); - } - return null; - }); - return controllerPromise; - }; - - return { - reset, - async refresh(client: GatewayBrowserClient, epoch: number) { - if (params.gateway.snapshot.hello?.deviceAuthMigration?.pending !== true) { - reset(); - return; - } - const controller = await load(client, epoch); - if (!controller || !params.isCurrent(client, epoch)) { - controller?.reset(); - return; - } - await controller.refresh(client, epoch); - }, - async secure(client: GatewayBrowserClient | null, epoch: number) { - await (await controllerPromise)?.secure(client, epoch); - }, - dispose() { - disposed = true; - void controllerPromise?.then((controller) => controller?.dispose()); - }, - }; -} diff --git a/ui/src/app/device-auth-migration.ts b/ui/src/app/device-auth-migration.ts deleted file mode 100644 index 08171ed77566..000000000000 --- a/ui/src/app/device-auth-migration.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { GatewayBrowserClient } from "../api/gateway.ts"; -import { t } from "../i18n/index.ts"; -import { formatUiError } from "../lib/format-error.ts"; -import { peekStoredDeviceIdentityId } from "../lib/nodes/index.ts"; -import type { ApplicationGateway } from "./gateway.ts"; -import "../components/device-auth-migration-banner.ts"; - -export type DeviceAuthMigrationSnapshot = { - requestId: string | null; - busy: boolean; - error: string | null; -}; - -export type DeviceAuthMigrationController = ReturnType; - -const EMPTY_SNAPSHOT: DeviceAuthMigrationSnapshot = { - requestId: null, - busy: false, - error: null, -}; - -export function createDeviceAuthMigrationController(params: { - gateway: ApplicationGateway; - isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean; - onChange: (snapshot: DeviceAuthMigrationSnapshot) => void; -}) { - let snapshot = EMPTY_SNAPSHOT; - let generation = 0; - let disposed = false; - - const update = (patch: Partial) => { - snapshot = { ...snapshot, ...patch }; - params.onChange(snapshot); - }; - - return { - reset() { - generation += 1; - update(EMPTY_SNAPSHOT); - }, - async refresh(client: GatewayBrowserClient, epoch: number) { - const migrationPending = params.gateway.snapshot.hello?.deviceAuthMigration?.pending === true; - const deviceId = peekStoredDeviceIdentityId(); - if (!migrationPending || !params.isCurrent(client, epoch)) { - generation += 1; - update(EMPTY_SNAPSHOT); - return; - } - if (!deviceId) { - generation += 1; - update({ - ...EMPTY_SNAPSHOT, - error: t("login.deviceAuthMigration.secureContextRequired"), - }); - return; - } - const refreshGeneration = ++generation; - try { - const result = await client.request<{ - pending?: Array<{ requestId?: unknown; deviceId?: unknown }>; - }>("device.pair.list", {}); - if (disposed || refreshGeneration !== generation || !params.isCurrent(client, epoch)) { - return; - } - const ownRequest = result.pending?.find( - (entry) => entry.deviceId === deviceId && typeof entry.requestId === "string", - ); - update({ - requestId: typeof ownRequest?.requestId === "string" ? ownRequest.requestId : null, - error: ownRequest ? null : t("login.deviceAuthMigration.pendingUnavailable"), - }); - } catch (error) { - if (refreshGeneration === generation && params.isCurrent(client, epoch)) { - const message = formatUiError(error); - update({ - error: t("login.deviceAuthMigration.loadFailed", { - error: message, - }), - }); - } - } - }, - async secure(client: GatewayBrowserClient | null, epoch: number) { - const requestId = snapshot.requestId; - if ( - !client || - !requestId || - params.gateway.snapshot.phase !== "connected" || - !params.isCurrent(client, epoch) || - snapshot.busy || - disposed - ) { - return; - } - update({ busy: true, error: null }); - try { - await client.request("device.pair.approve", { requestId }); - if (disposed || !params.isCurrent(client, epoch)) { - return; - } - update({ requestId: null, busy: false }); - // Reconnect once so the newly approved browser receives and stores its - // device token; shared auth is no longer its baseline. - params.gateway.connect(); - } catch (error) { - if (!disposed && params.isCurrent(client, epoch)) { - const message = formatUiError(error); - update({ - busy: false, - error: t("login.deviceAuthMigration.approvalFailed", { - error: message, - }), - }); - } - } - }, - dispose() { - disposed = true; - generation += 1; - }, - }; -} diff --git a/ui/src/app/overlays-types.ts b/ui/src/app/overlays-types.ts index 14a7aff9ee93..4a856cbb3ef7 100644 --- a/ui/src/app/overlays-types.ts +++ b/ui/src/app/overlays-types.ts @@ -1,6 +1,5 @@ import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import type { DevicePairSetupAccess, DevicePairSetupLifecycle } from "../lib/device-pair-setup.ts"; -import type { DeviceAuthMigrationSnapshot } from "./device-auth-migration.ts"; import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts"; import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts"; @@ -19,7 +18,6 @@ export type ApplicationOverlaySnapshot = { devicePairSetupOpen: boolean; devicePairSetupLifecycle: DevicePairSetupLifecycle; devicePairPendingCount: number; - deviceAuthMigration: DeviceAuthMigrationSnapshot; }; export type ApplicationOverlays = { @@ -33,6 +31,5 @@ export type ApplicationOverlays = { refreshDevicePairSetup: () => Promise; setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise; closeDevicePairSetup: () => void; - secureThisBrowser: () => Promise; dispose: () => void; }; diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index 109aeec90744..383289213197 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node // Control UI tests cover application-owned overlay races. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import type { ApplicationGatewaySnapshot } from "./gateway.ts"; import { @@ -30,13 +30,6 @@ vi.mock("../build-info.ts", () => ({ reloadControlUiIfStale: vi.fn(), })); vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() })); -const { peekStoredDeviceIdentityIdMock } = vi.hoisted(() => ({ - peekStoredDeviceIdentityIdMock: vi.fn((): string | null => "browser-1"), -})); -vi.mock("../lib/nodes/index.ts", () => ({ - peekStoredDeviceIdentityId: peekStoredDeviceIdentityIdMock, -})); - const HANDOFF_POLL_MS = 1_000; const RESTART_VERIFICATION_TIMEOUT_MS = 10_000; const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; @@ -66,190 +59,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("device-auth upgrade migration", () => { - beforeEach(() => { - peekStoredDeviceIdentityIdMock.mockReturnValue("browser-1"); - }); - - it("guides a device-less legacy browser to a secure context", async () => { - peekStoredDeviceIdentityIdMock.mockReturnValue(null); - const request = vi.fn(() => Promise.resolve({})); - const harness = createGatewayHarness(null, false); - const overlays = createApplicationOverlays(harness.gateway); - const migrationError = new Promise((resolve) => { - const unsubscribe = overlays.subscribe((snapshot) => { - if (snapshot.deviceAuthMigration.error) { - unsubscribe(); - resolve(snapshot.deviceAuthMigration.error); - } - }); - }); - harness.update({ - client: client(request), - phase: "connected", - hello: { - server: { version: "1.0.0" }, - deviceAuthMigration: { pending: true }, - } as ApplicationGatewaySnapshot["hello"], - }); - - await expect(migrationError).resolves.toContain("HTTPS or localhost"); - expect(overlays.snapshot.deviceAuthMigration.requestId).toBeNull(); - expect(request).not.toHaveBeenCalledWith("device.pair.list", expect.anything()); - overlays.dispose(); - }); - - it("approves only this browser and reconnects for its device token", async () => { - const request = vi.fn((method, params) => { - if (method === "device.pair.list") { - return Promise.resolve({ - pending: [ - { requestId: "other-request", deviceId: "browser-2" }, - { requestId: "self-request", deviceId: "browser-1" }, - ], - }); - } - if (method === "device.pair.approve") { - expect(params).toEqual({ requestId: "self-request" }); - return Promise.resolve({ requestId: "self-request" }); - } - if (method.endsWith(".list")) { - return Promise.resolve([]); - } - return Promise.resolve({}); - }); - const harness = createGatewayHarness(null, false); - const overlays = createApplicationOverlays(harness.gateway); - harness.update({ - client: client(request), - phase: "connected", - hello: { - server: { version: "1.0.0" }, - deviceAuthMigration: { pending: true }, - } as ApplicationGatewaySnapshot["hello"], - }); - - await vi.waitFor(() => { - expect(overlays.snapshot.deviceAuthMigration.requestId).toBe("self-request"); - }); - await overlays.secureThisBrowser(); - - expect(request).toHaveBeenCalledWith("device.pair.approve", { - requestId: "self-request", - }); - expect(harness.connect).toHaveBeenCalledOnce(); - expect(overlays.snapshot.deviceAuthMigration.requestId).toBeNull(); - overlays.dispose(); - }); - - it("does not reconnect when approval finishes after disposal", async () => { - let resolveApproval: (() => void) | undefined; - const approvalRequest = new Promise((resolve) => { - resolveApproval = resolve; - }); - const request = vi.fn((method) => { - if (method === "device.pair.list") { - return Promise.resolve({ - pending: [{ requestId: "self-request", deviceId: "browser-1" }], - }); - } - if (method === "device.pair.approve") { - return approvalRequest; - } - return Promise.resolve([]); - }); - const harness = createGatewayHarness(null, false); - const overlays = createApplicationOverlays(harness.gateway); - harness.update({ - client: client(request), - phase: "connected", - hello: { - server: { version: "1.0.0" }, - deviceAuthMigration: { pending: true }, - } as ApplicationGatewaySnapshot["hello"], - }); - - await vi.waitFor(() => { - expect(overlays.snapshot.deviceAuthMigration.requestId).toBe("self-request"); - }); - const securing = overlays.secureThisBrowser(); - await vi.waitFor(() => { - expect(request).toHaveBeenCalledWith("device.pair.approve", { - requestId: "self-request", - }); - }); - overlays.dispose(); - resolveApproval?.(); - await securing; - - expect(harness.connect).not.toHaveBeenCalled(); - }); - - it("does not approve through a replacement gateway session", async () => { - const firstRequest = vi.fn((method) => - Promise.resolve( - method === "device.pair.list" - ? { pending: [{ requestId: "self-request", deviceId: "browser-1" }] } - : {}, - ), - ); - const replacementRequest = vi.fn(() => Promise.resolve({ pending: [] })); - const harness = createGatewayHarness(null, false); - const overlays = createApplicationOverlays(harness.gateway); - harness.update({ - client: client(firstRequest), - phase: "connected", - hello: { - server: { version: "1.0.0" }, - deviceAuthMigration: { pending: true }, - } as ApplicationGatewaySnapshot["hello"], - }); - - await vi.waitFor(() => { - expect(overlays.snapshot.deviceAuthMigration.requestId).toBe("self-request"); - }); - const securing = overlays.secureThisBrowser(); - harness.update({ client: client(replacementRequest) }); - await securing; - - expect(firstRequest).not.toHaveBeenCalledWith("device.pair.approve", expect.anything()); - expect(replacementRequest).not.toHaveBeenCalledWith("device.pair.approve", expect.anything()); - expect(harness.connect).not.toHaveBeenCalled(); - overlays.dispose(); - }); - - it("does not expose an action for another browser's request", async () => { - const request = vi.fn((method) => - Promise.resolve( - method === "device.pair.list" - ? { pending: [{ requestId: "other-request", deviceId: "browser-2" }] } - : [], - ), - ); - const harness = createGatewayHarness(null, false); - const overlays = createApplicationOverlays(harness.gateway); - harness.update({ - client: client(request), - phase: "connected", - hello: { - server: { version: "1.0.0" }, - deviceAuthMigration: { pending: true }, - } as ApplicationGatewaySnapshot["hello"], - }); - - await vi.waitFor(() => { - expect(overlays.snapshot.deviceAuthMigration.error).toContain( - "pairing request is not available", - ); - }); - expect(overlays.snapshot.deviceAuthMigration.requestId).toBeNull(); - await overlays.secureThisBrowser(); - expect(request).not.toHaveBeenCalledWith("device.pair.approve", expect.anything()); - expect(harness.connect).not.toHaveBeenCalled(); - overlays.dispose(); - }); -}); - describe("Control UI refresh nudge", () => { it("does not flag an independently built configured UI root", () => { const gatewayClient = client(async () => []); diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 927cba0be40b..9729159ae946 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -20,10 +20,6 @@ import { syncDevicePairSetupCountdown, } from "../lib/device-pair-setup.ts"; import { formatUiError } from "../lib/format-error.ts"; -import { - createDeviceAuthMigrationLoader, - EMPTY_DEVICE_AUTH_MIGRATION, -} from "./device-auth-migration-loader.ts"; import { clearExecApprovalTimers, clearResolvedExecApprovalPrompt, @@ -95,7 +91,6 @@ export function createApplicationOverlays( devicePairSetupOpen: false, devicePairSetupLifecycle: { phase: "selection", access: "full" }, devicePairPendingCount: 0, - deviceAuthMigration: EMPTY_DEVICE_AUTH_MIGRATION, }; const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>(); let disposed = false; @@ -167,19 +162,6 @@ export function createApplicationOverlays( gateway.snapshot.client === client && gateway.snapshot.phase === "connected"; - const isCurrentDeviceAuthMigration = (client: NonNullable, epoch: number) => - epoch === connectedEpoch && - isCurrentClient(client) && - gateway.snapshot.hello?.deviceAuthMigration?.pending === true; - const deviceAuthMigration = createDeviceAuthMigrationLoader({ - gateway, - isCurrent: isCurrentDeviceAuthMigration, - onChange: (next) => { - snapshot = { ...snapshot, deviceAuthMigration: next }; - publish(); - }, - }); - const refreshApprovals = createOverlayApprovalRefresher({ gateway, state: promptState, @@ -289,7 +271,6 @@ export function createApplicationOverlays( if (previousClient !== next.client || !connected) { approvalDecision = null; pairingPendingCount.invalidate({ clear: true }); - deviceAuthMigration.reset(); closeDevicePairSetupState(devicePairSetupState); } if (connected && !operatorAccess.canReviewApprovals) { @@ -356,7 +337,6 @@ export function createApplicationOverlays( if (operatorAccess.canReviewApprovals) { void refreshApprovals(next.client, connectedEpoch, approvalAccessGeneration); } - void deviceAuthMigration.refresh(next.client, connectedEpoch); void updateVerification.verify(next.client, connectedEpoch); } else if (accessTransition.reviewChanged && operatorAccess.canReviewApprovals) { void refreshApprovals(next.client, connectedEpoch, approvalAccessGeneration); @@ -384,9 +364,6 @@ export function createApplicationOverlays( } if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") { void pairingPendingCount.refresh(); - if (activeClient) { - void deviceAuthMigration.refresh(activeClient, connectedEpoch); - } return; } if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) { @@ -676,15 +653,11 @@ export function createApplicationOverlays( closeDevicePairSetupState(devicePairSetupState); publish(); }, - async secureThisBrowser() { - await deviceAuthMigration.secure(activeClient, connectedEpoch); - }, dispose() { disposed = true; approvalDecision = null; updateRunGeneration += 1; pairingPendingCount.invalidate(); - deviceAuthMigration.dispose(); updateVerification.cancel(); updateCampaignPoller.stop(); closeDevicePairSetupState(devicePairSetupState); diff --git a/ui/src/components/device-auth-migration-banner.ts b/ui/src/components/device-auth-migration-banner.ts deleted file mode 100644 index 30ac88a30d95..000000000000 --- a/ui/src/components/device-auth-migration-banner.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Upgrade-only banner keeps migration presentation off the ordinary startup path. -import { html, nothing } from "lit"; -import { property } from "lit/decorators.js"; -import { t } from "../i18n/index.ts"; -import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; -import "./update-banner.ts"; - -type DeviceAuthMigrationBannerProps = { - state: { requestId: string | null; busy: boolean; error: string | null }; - onSecure: () => void; -}; - -class DeviceAuthMigrationBanner extends OpenClawLightDomContentsElement { - @property({ attribute: false }) props?: DeviceAuthMigrationBannerProps; - - override render() { - const props = this.props; - if (!props) { - return nothing; - } - return html``; - } -} - -if (!customElements.get("openclaw-device-auth-migration-banner")) { - customElements.define("openclaw-device-auth-migration-banner", DeviceAuthMigrationBanner); -} diff --git a/ui/src/e2e/device-auth-migration.e2e.test.ts b/ui/src/e2e/device-auth-migration.e2e.test.ts deleted file mode 100644 index 09f402ca1cb8..000000000000 --- a/ui/src/e2e/device-auth-migration.e2e.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Control UI E2E proves the retired bypass upgrade is an explicit browser action. -import { mkdir } from "node:fs/promises"; -import path from "node:path"; -import { chromium, type Browser } from "playwright"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - canRunPlaywrightChromium, - installMockGateway, - resolvePlaywrightChromiumExecutablePath, - startControlUiE2eServer, - type ControlUiE2eServer, -} from "../test-helpers/control-ui-e2e.ts"; - -const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); -const captureProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; -const artifactDir = path.join( - process.cwd(), - ".artifacts", - "control-ui-e2e", - "device-auth-migration", -); - -let browser: Browser; -let server: ControlUiE2eServer; - -describe("Control UI device-auth migration E2E", () => { - beforeAll(async () => { - if (!canRunPlaywrightChromium(chromiumExecutablePath)) { - throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`); - } - server = await startControlUiE2eServer(); - browser = await chromium.launch({ executablePath: chromiumExecutablePath }); - }); - - afterAll(async () => { - await browser?.close(); - await server?.close(); - }); - - it("shows an explicit secure action and removes it only after approval", async () => { - if (captureProof) { - await mkdir(path.join(artifactDir, "video"), { recursive: true }); - } - const context = await browser.newContext({ - locale: "en-US", - recordVideo: captureProof - ? { dir: path.join(artifactDir, "video"), size: { height: 900, width: 1440 } } - : undefined, - serviceWorkers: "block", - viewport: { height: 900, width: 1440 }, - }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { deviceAuthMigrationPending: true }); - - try { - await page.goto(`${server.baseUrl}chat`); - const action = page.getByRole("button", { name: "Secure this browser" }); - await action.waitFor(); - await expect.poll(() => gateway.getRequests("device.pair.list")).toHaveLength(1); - if (captureProof) { - await page.screenshot({ - animations: "disabled", - path: path.join(artifactDir, "pending.png"), - }); - } - - await action.click(); - await expect.poll(() => gateway.getRequests("device.pair.approve")).toHaveLength(1); - await expect.poll(() => gateway.getSocketCount()).toBeGreaterThanOrEqual(2); - await expect.poll(() => action.isVisible()).toBe(false); - if (captureProof) { - await page.screenshot({ - animations: "disabled", - path: path.join(artifactDir, "paired.png"), - }); - } - } finally { - await context.close(); - } - }); -}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 17d9b56ce5de..f9e7107c7eee 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1501,7 +1501,6 @@ export const en: TranslationMap = { title: "Security", gatewayAuth: "Gateway auth", execPolicy: "Exec policy", - deviceAuth: "Device auth", browserEnabled: "Browser enabled", toolProfile: "Tool profile", }, @@ -4516,15 +4515,6 @@ export const en: TranslationMap = { }, login: { subtitle: "Gateway Dashboard", - deviceAuthMigration: { - banner: "This browser still needs one-time device approval after the update.", - action: "Secure this browser", - secureContextRequired: - "This legacy browser remains available temporarily. Reopen it over HTTPS or localhost to secure it with device identity.", - pendingUnavailable: "The browser pairing request is not available yet. Retry in a moment.", - loadFailed: "Could not load this browser's pairing request: {error}", - approvalFailed: "Could not secure this browser: {error}", - }, passwordPlaceholder: "optional", showToken: "Show token", hideToken: "Hide token", diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index cfa4bb8e5daf..045b4cf99082 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -912,26 +912,6 @@ async function generateIdentity(): Promise { }; } -/** - * Synchronous identity probe for render gating: reads the stored device id - * without creating, repairing, or fingerprint-verifying an identity, so a - * "do we hold credentials?" check stays side-effect free before connect(). - */ -export function peekStoredDeviceIdentityId(): string | null { - try { - const raw = getSafeLocalStorage()?.getItem(DEVICE_IDENTITY_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as StoredIdentity; - return parsed?.version === 1 && typeof parsed.deviceId === "string" && parsed.deviceId - ? parsed.deviceId - : null; - } catch { - return null; - } -} - export async function loadOrCreateDeviceIdentity(): Promise { const storage = getSafeLocalStorage(); try { diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index cfcb7c61f069..d6e38661fd6a 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -190,7 +190,6 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview return { gatewayAuth: "unknown", execPolicy: "unknown", - deviceAuth: false, browserEnabled: true, browserEnabledOverridden: false, toolProfile: "full", @@ -202,7 +201,6 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview const tools = asConfigRecord(root.tools); const exec = asConfigRecord(tools?.exec) ?? {}; const browser = asConfigRecord(root.browser); - const controlUi = asConfigRecord(gateway?.controlUi); let gatewayAuth = "unknown"; if (auth) { const mode = typeof auth.mode === "string" ? auth.mode.trim() : ""; @@ -221,7 +219,6 @@ export function extractQuickSettingsSecurity(config: unknown): SecurityOverview return { gatewayAuth, execPolicy: typeof security === "string" && security.trim() ? security.trim() : "allowlist", - deviceAuth: controlUi?.dangerouslyDisableDeviceAuth !== true, browserEnabled: browser?.enabled !== false, browserEnabledOverridden: browser !== null && Object.hasOwn(browser, "enabled"), toolProfile: typeof profile === "string" && profile.trim() ? profile.trim() : "full", diff --git a/ui/src/pages/config/security.test.ts b/ui/src/pages/config/security.test.ts index f0879cc4675c..465972dd4710 100644 --- a/ui/src/pages/config/security.test.ts +++ b/ui/src/pages/config/security.test.ts @@ -46,7 +46,6 @@ function createProps(overrides: Partial = {}): SecurityViewPr security: { gatewayAuth: "token", execPolicy: "allowlist", - deviceAuth: true, browserEnabled: true, browserEnabledOverridden: true, toolProfile: "coding", @@ -74,7 +73,6 @@ describe("renderSecurity", () => { security: { gatewayAuth: "token", execPolicy: "allowlist", - deviceAuth: true, browserEnabled: false, browserEnabledOverridden: true, toolProfile: "messaging", @@ -120,7 +118,7 @@ describe("renderSecurity", () => { expect(browserRow.querySelector("wa-switch")?.hasAttribute("disabled")).toBe(true); }); - it("shows gateway auth and device auth as dot statuses, not pills", () => { + it("shows gateway auth as a dot status, not a pill", () => { const container = document.createElement("div"); render( @@ -129,7 +127,6 @@ describe("renderSecurity", () => { security: { gatewayAuth: "none", execPolicy: "allowlist", - deviceAuth: true, browserEnabled: true, browserEnabledOverridden: false, toolProfile: "full", @@ -144,8 +141,6 @@ describe("renderSecurity", () => { const authStatus = authRow.querySelector(".settings-status"); expect(authStatus?.textContent?.trim()).toBe("none"); expect(authStatus?.classList.contains("settings-status--warn")).toBe(true); - const deviceRow = expectRowByTitle(container, "Device auth"); - expect(deviceRow.querySelector(".settings-status--ok")?.textContent?.trim()).toBe("Enabled"); }); it("opens mobile pairing from the overview", () => { @@ -183,7 +178,6 @@ describe("renderSecurity", () => { security: { gatewayAuth: "token", execPolicy: "allowlist", - deviceAuth: true, browserEnabled: true, browserEnabledOverridden: false, toolProfile: "full", @@ -214,7 +208,6 @@ describe("renderSecurity", () => { security: { gatewayAuth: "token", execPolicy: "allowlist", - deviceAuth: true, browserEnabled: true, browserEnabledOverridden: true, toolProfile: "full", diff --git a/ui/src/pages/config/security.ts b/ui/src/pages/config/security.ts index 8634069f1cec..9ea64422b85f 100644 --- a/ui/src/pages/config/security.ts +++ b/ui/src/pages/config/security.ts @@ -21,7 +21,6 @@ const SECURITY_DOCS_URL = "https://docs.openclaw.ai/gateway/security"; export type SecurityOverview = { gatewayAuth: string; execPolicy: string; - deviceAuth: boolean; browserEnabled: boolean; browserEnabledOverridden: boolean; toolProfile: string; @@ -45,7 +44,6 @@ function renderSecurityOverview(props: SecurityViewProps) { const { gatewayAuth, execPolicy, - deviceAuth, browserEnabled, browserEnabledOverridden, toolProfile, @@ -106,13 +104,6 @@ function renderSecurityOverview(props: SecurityViewProps) { })} `, }), - renderSettingsRow({ - title: t("quickSettings.security.deviceAuth"), - control: renderSettingsStatus({ - kind: deviceAuth ? "ok" : "warn", - label: deviceAuth ? t("common.enabled") : t("common.disabled"), - }), - }), renderSettingsRow({ title: t("devices.pairing.title"), control: html` diff --git a/ui/src/pages/config/settings-targets.ts b/ui/src/pages/config/settings-targets.ts index b12f15ebd5b2..4ae37a2f7c90 100644 --- a/ui/src/pages/config/settings-targets.ts +++ b/ui/src/pages/config/settings-targets.ts @@ -54,7 +54,6 @@ export const SETTINGS_SEARCH_TARGETS = { searchKeys: [ "quickSettings.security.gatewayAuth", "quickSettings.security.execPolicy", - "quickSettings.security.deviceAuth", "quickSettings.security.browserEnabled", "quickSettings.security.toolProfile", ], diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 34ba4e8fefed..3c7f2076fe9c 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -148,11 +148,7 @@ .shell:not(.shell--mobile-nav, .shell--nav-collapsed, .shell--settings) ) .content - > :is( - openclaw-update-banner, - openclaw-device-scope-upgrade-banner, - openclaw-device-auth-migration-banner - ) + > :is(openclaw-update-banner, openclaw-device-scope-upgrade-banner) .callout { margin-left: var(--shell-chrome-safe-area-left); } diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 719fdd7b2226..e7755117c5b7 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -271,8 +271,6 @@ export type ControlUiMockGatewayScenario = { serverBuildId?: string; controlUiBuildSource?: "bundled" | "configured"; serverVersion?: string; - /** Simulate the one-time legacy Control UI device-auth pairing transition. */ - deviceAuthMigrationPending?: boolean; deviceToken?: string; featureMethods?: string[]; /** Simulate a legacy Gateway that predates the advertised method catalog. */ @@ -854,7 +852,6 @@ function normalizeScenario( serverBuildId: scenario.serverBuildId?.trim() || "e2e", controlUiBuildSource: scenario.controlUiBuildSource ?? "bundled", serverVersion: scenario.serverVersion?.trim() || "e2e", - deviceAuthMigrationPending: scenario.deviceAuthMigrationPending ?? false, deviceToken: scenario.deviceToken?.trim() || "e2e-device-token", // Baseline scenarios represent a current Gateway. Tests for unsupported or // mixed-version methods provide an explicit narrower catalog. @@ -1016,8 +1013,6 @@ function installControlUiMockGateway( close: (code?: number, reason?: string) => void; openConnection: () => void; }> = []; - let deviceAuthMigrationPending = scenario.deviceAuthMigrationPending; - let deviceAuthMigrationDeviceId = ""; let sessionMessageEventIndex = 0; let sessionMessageEventTimer: number | null = null; const offlineStateKey = "openclaw.control-ui-e2e.gatewayOffline"; @@ -1521,28 +1516,6 @@ function installControlUiMockGateway( } function buildResponse(method: string, params: unknown): unknown { - if (method === "connect") { - const device = isRecord(params) ? params.device : undefined; - deviceAuthMigrationDeviceId = - isRecord(device) && typeof device.id === "string" ? device.id : ""; - } - if (deviceAuthMigrationPending && method === "device.pair.list") { - return { - paired: [], - pending: deviceAuthMigrationDeviceId - ? [ - { - requestId: "mock-device-auth-migration-request", - deviceId: deviceAuthMigrationDeviceId, - }, - ] - : [], - }; - } - if (deviceAuthMigrationPending && method === "device.pair.approve") { - deviceAuthMigrationPending = false; - return { requestId: "mock-device-auth-migration-request" }; - } if (method === "sessions.patch") { recordSessionPatch(params); } @@ -1647,9 +1620,8 @@ function installControlUiMockGateway( auth && typeof auth.deviceToken === "string" ? auth.deviceToken : scenario.deviceToken; return { auth: { - ...(deviceAuthMigrationPending - ? {} - : { deviceToken: connectedDeviceToken, recoveryMigrationAllowed: true as const }), + deviceToken: connectedDeviceToken, + recoveryMigrationAllowed: true as const, recoveryScope: "e2e-recovery-scope", role: "operator", scopes: scenario.operatorScopes, @@ -1661,9 +1633,6 @@ function installControlUiMockGateway( }, controlUiTabs: scenario.controlUiTabs, controlUiWidgetKinds: scenario.controlUiWidgetKinds, - ...(deviceAuthMigrationPending - ? { deviceAuthMigration: { pending: true as const } } - : {}), protocol: protocolVersion, server: { buildId: serverBuildId,