mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
refactor(gateway): retire the Control UI device-auth migration window (#124667)
* refactor(gateway): delete the retired Control UI device-auth migration window The one-time remediation window for the retired gateway.controlUi.dangerouslyDisableDeviceAuth break-glass threaded a pending state machine through the connect pipeline, startup bootstrap, lifecycle, request context, device management, security audit, the hello frame, and a Control UI banner flow — steady-state runtime carrying a retired-shape shim the architecture reserves for doctor. The window is closed: the retired key is now fully inert, doctor still detects and removes it, and a browser that never completed the migration pairs through the normal device flow (one approval, no data loss). Deleted with it: the config-machine-state import of the legacy flag, the hello deviceAuthMigration field (optional; clients validate envelopes only, so older gateways emitting it stay compatible), the migration-bound device management authz states, the requireNoPairingCapableOperator approval mode, the effective-operator pairing event emitter whose only subscriber was the migration completion, and the Control UI banner, loader, overlay wiring, i18n strings, and e2e scenario support. Swift and Kotlin protocol models regenerated. Live-verified on an isolated gateway with the retired key present in config: clean boot with no migration warning, silent CLI pairing and silent local scope widening unaffected, and doctor reporting the key as retired-and-inert. Net -1111 production LOC. * fix(ci): finish migration-window cleanup surfaced by the gates The doctor migration for the retired key kept its "Preserved for remediation" change text and describe; both now state plain removal, with the retired tests updated. peekStoredDeviceIdentityId lost its only production consumer with the deleted banner loader and is removed with its test mock. Also fixes the unrelated no-unnecessary-boolean-literal-compare lint break that #124636 landed on main in scripts/check-changed.mts (truthiness is equivalent for the boolean-or-undefined TTY probe). * fix(ui): drop the retired-key device-auth reader from the security summary Review findings on the migration-window removal: the Control UI security summary still derived its "Device auth" row from the retired dangerouslyDisableDeviceAuth key, rendering device auth as disabled on configurations that merely retain the inert key. Device auth is now unconditionally enforced, so the row and its derivation are removed rather than pinned to a constant. The build-admission test also tracks and removes its temporary device-identity databases after each run.
This commit is contained in:
committed by
GitHub
parent
fa29984096
commit
bdcc6836e0
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags tracked by the audit today">
|
||||
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`
|
||||
- pending Control UI device-auth migration imported from retired `gateway.controlUi.dangerouslyDisableDeviceAuth=true`
|
||||
- `security.audit.suppressions configured (<count>)`
|
||||
- `hooks.gmail.allowUnsafeExternalContent=true`
|
||||
- `hooks.mappings[<index>].allowUnsafeExternalContent=true`
|
||||
@@ -713,7 +712,7 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id
|
||||
<Accordion title="All dangerous*/dangerously* keys in the config schema">
|
||||
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.<accountId>` where applicable):
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="WebSocket still failing">
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
<Steps>
|
||||
<Step title="List pending requests">
|
||||
```bash
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,55 +28,19 @@ function client(overrides: Partial<GatewayClient>): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<ReturnType<typeof approveDevicePairing>>;
|
||||
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) {
|
||||
|
||||
@@ -100,10 +100,6 @@ export type GatewayClient = {
|
||||
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
||||
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
|
||||
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<string, number>;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OpenClawConfig["gateway"]>;
|
||||
const LOCAL_MIGRATION_GATEWAY = {
|
||||
controlUi: {
|
||||
allowedOrigins: [BROWSER_ORIGIN],
|
||||
dangerouslyDisableDeviceAuth: true,
|
||||
},
|
||||
} satisfies NonNullable<OpenClawConfig["gateway"]>;
|
||||
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<OpenClawConfig["gateway"]>;
|
||||
const STATE_ONLY_MIGRATION_GATEWAY = {
|
||||
controlUi: { dangerouslyDisableDeviceAuth: true },
|
||||
} satisfies NonNullable<OpenClawConfig["gateway"]>;
|
||||
const TOKEN_AUTH = { mode: "token", token: "secret" };
|
||||
|
||||
type MigrationHarness = Awaited<ReturnType<typeof createGatewaySuiteHarness>>;
|
||||
type TrackedMigrationHarness = Omit<MigrationHarness, "openWs"> & {
|
||||
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<T, Prepared = void>(
|
||||
run: (harness: TrackedMigrationHarness, prepared: Prepared) => Promise<T>,
|
||||
options: {
|
||||
gateway?: NonNullable<OpenClawConfig["gateway"]>;
|
||||
auth?: Record<string, unknown> | null;
|
||||
prepare?: () => Promise<Prepared>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
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<WebSocket>();
|
||||
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<ReturnType<typeof signedDevice>>["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<void>((resolve) => {
|
||||
releasePrepare = resolve;
|
||||
});
|
||||
let markPrepareEntered: () => void = () => {};
|
||||
const prepareEntered = new Promise<void>((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<number>((resolve) => {
|
||||
firstWs!.once("close", resolve);
|
||||
});
|
||||
const competingClosed = new Promise<number>((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<number>((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<number>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,7 +119,6 @@ function makeState(role: "operator" | "node", scopes: string[]) {
|
||||
handoffBootstrapProfile: null,
|
||||
deviceToken: null,
|
||||
bootstrapDeviceTokens: [],
|
||||
controlUiDeviceAuthMigrationPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,14 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
evaluateMissingDeviceIdentity,
|
||||
isTrustedProxyControlUiOperatorAuth,
|
||||
resolveControlUiAuthPolicy,
|
||||
shouldAllowControlUiDeviceAuthMigration,
|
||||
shouldClearUnboundScopesForMissingDeviceIdentity,
|
||||
shouldSkipControlUiPairing,
|
||||
} from "./connect-policy.js";
|
||||
|
||||
type ControlUiAuthPolicyInput = Parameters<typeof resolveControlUiAuthPolicy>[0];
|
||||
type DeviceRaw = NonNullable<ControlUiAuthPolicyInput["deviceRaw"]>;
|
||||
type SkipPairingInput = Parameters<typeof shouldSkipControlUiPairing>[0];
|
||||
type DeviceRaw = NonNullable<SkipPairingInput["device"]>;
|
||||
type MissingDeviceIdentityInput = Parameters<typeof evaluateMissingDeviceIdentity>[0];
|
||||
type MissingDeviceDecisionKind = ReturnType<typeof evaluateMissingDeviceIdentity>["kind"];
|
||||
type PairingRole = Parameters<typeof shouldSkipControlUiPairing>[1];
|
||||
type PairingAuthMode = Parameters<typeof shouldSkipControlUiPairing>[3];
|
||||
type PairingAuthMethod = Parameters<typeof shouldSkipControlUiPairing>[4];
|
||||
type ClearUnboundScopesInput = Parameters<
|
||||
typeof shouldClearUnboundScopesForMissingDeviceIdentity
|
||||
>[0];
|
||||
@@ -30,24 +25,14 @@ function deviceRaw(id: string): DeviceRaw {
|
||||
};
|
||||
}
|
||||
|
||||
function authPolicy(params: Partial<ControlUiAuthPolicyInput> = {}) {
|
||||
return resolveControlUiAuthPolicy({
|
||||
isControlUi: params.isControlUi ?? false,
|
||||
deviceRaw: params.deviceRaw ?? null,
|
||||
deviceAuthMigrationPending: params.deviceAuthMigrationPending,
|
||||
});
|
||||
}
|
||||
|
||||
function expectMissingDeviceDecision(
|
||||
overrides: Partial<MissingDeviceIdentityInput>,
|
||||
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<typeof shouldSkipControlUiPairing>[0],
|
||||
role: PairingRole,
|
||||
overrides: Partial<SkipPairingInput>,
|
||||
expected: ReturnType<typeof shouldSkipControlUiPairing>,
|
||||
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<ClearUnboundScopesInput>, expected: boolean) {
|
||||
const params: ClearUnboundScopesInput = {
|
||||
decision: { kind: "allow" },
|
||||
controlUiAuthPolicy: authPolicy(),
|
||||
preserveInsecureLocalControlUiScopes: false,
|
||||
authMethod: "token",
|
||||
...overrides,
|
||||
};
|
||||
@@ -91,108 +67,23 @@ function expectClearsUnboundScopes(overrides: Partial<ClearUnboundScopesInput>,
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<typeof createSubsystemLogger>;
|
||||
type ControlUiAuthPolicy = ReturnType<typeof resolveControlUiAuthPolicy>;
|
||||
type PairingLocalityKind = ReturnType<typeof resolvePairingLocality>;
|
||||
|
||||
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;
|
||||
|
||||
+48
-3
@@ -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<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<PairedDevice, "deviceId" | "publicKey"> & {
|
||||
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<PairedDevice, "role" | "roles" | "tokens">,
|
||||
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<ApproveDevicePairingResult> {
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const entries: Array<readonly [string, unknown]> = [];
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<SecurityAuditFinding[]> {
|
||||
@@ -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));
|
||||
|
||||
@@ -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<ControlUiDeviceAuthMigrationState>;
|
||||
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<ControlUiDeviceAuthMigrationState>(
|
||||
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<ControlUiDeviceAuthMigrationState>(
|
||||
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<ControlUiDeviceAuthMigrationState>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -107,7 +107,6 @@ describe("OpenClaw shell dock suppression", () => {
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLifecycle: { phase: "selection", access: "full" },
|
||||
devicePairPendingCount: 0,
|
||||
deviceAuthMigration: { error: null },
|
||||
},
|
||||
runUpdate: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -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`<openclaw-device-auth-migration-banner
|
||||
.props=${{
|
||||
state: overlaySnapshot.deviceAuthMigration,
|
||||
onSecure: () => void context.overlays.secureThisBrowser(),
|
||||
}}
|
||||
></openclaw-device-auth-migration-banner>`
|
||||
: html`<openclaw-update-banner
|
||||
.props=${{
|
||||
statusBanner: {
|
||||
tone: overlaySnapshot.deviceAuthMigration.error ? "danger" : "warn",
|
||||
text:
|
||||
overlaySnapshot.deviceAuthMigration.error ??
|
||||
t("login.deviceAuthMigration.banner"),
|
||||
},
|
||||
}}
|
||||
></openclaw-update-banner>`
|
||||
: nothing}
|
||||
${renderFloatingUpdateCard({
|
||||
navigationSurfaceHidden,
|
||||
onboarding,
|
||||
|
||||
@@ -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<DeviceAuthMigrationController | null> | 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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createDeviceAuthMigrationController>;
|
||||
|
||||
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<DeviceAuthMigrationSnapshot>) => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise<void>;
|
||||
closeDevicePairSetup: () => void;
|
||||
secureThisBrowser: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
+1
-192
@@ -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<RequestFn>(() => Promise.resolve({}));
|
||||
const harness = createGatewayHarness(null, false);
|
||||
const overlays = createApplicationOverlays(harness.gateway);
|
||||
const migrationError = new Promise<string>((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<RequestFn>((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<void>((resolve) => {
|
||||
resolveApproval = resolve;
|
||||
});
|
||||
const request = vi.fn<RequestFn>((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<RequestFn>((method) =>
|
||||
Promise.resolve(
|
||||
method === "device.pair.list"
|
||||
? { pending: [{ requestId: "self-request", deviceId: "browser-1" }] }
|
||||
: {},
|
||||
),
|
||||
);
|
||||
const replacementRequest = vi.fn<RequestFn>(() => 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<RequestFn>((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 () => []);
|
||||
|
||||
@@ -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<typeof activeClient>, 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);
|
||||
|
||||
@@ -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`<openclaw-update-banner
|
||||
.props=${{
|
||||
statusBanner: {
|
||||
tone: props.state.error ? "danger" : "warn",
|
||||
text: props.state.error ?? t("login.deviceAuthMigration.banner"),
|
||||
},
|
||||
action:
|
||||
props.state.requestId && !props.state.busy
|
||||
? { label: t("login.deviceAuthMigration.action"), onClick: props.onSecure }
|
||||
: undefined,
|
||||
}}
|
||||
></openclaw-update-banner>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-device-auth-migration-banner")) {
|
||||
customElements.define("openclaw-device-auth-migration-banner", DeviceAuthMigrationBanner);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -912,26 +912,6 @@ async function generateIdentity(): Promise<DeviceIdentity> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DeviceIdentity> {
|
||||
const storage = getSafeLocalStorage();
|
||||
try {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -46,7 +46,6 @@ function createProps(overrides: Partial<SecurityViewProps> = {}): 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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user