diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 56d35e6496a5..8cae2dd6711e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -684,7 +684,6 @@ src/gateway/server-restart-sentinel.test.ts src/gateway/server-startup-config.secrets.test.ts src/gateway/server-startup-post-attach.test.ts src/gateway/server-startup-post-attach.ts -src/gateway/server.auth.control-ui.suite.ts src/gateway/server.chat.gateway-server-chat-b.test.ts src/gateway/server.chat.gateway-server-chat.test.ts src/gateway/server.config-patch.test.ts diff --git a/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts b/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts new file mode 100644 index 000000000000..7e1c9f300ea2 --- /dev/null +++ b/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts @@ -0,0 +1,403 @@ +import { expect, test, vi } from "vitest"; +import { + createOperatorIdentityFixture, + expectArrayIncludes, + REMOTE_BOOTSTRAP_HEADERS, + startControlUiServer, +} from "./server.auth.control-ui.fixtures.test-support.js"; +import { + connectReq, + ConnectErrorDetailCodes, + openWs, + restoreGatewayToken, + waitForWsClose, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiBootstrapLifecycleSuite(): void { + test("qr bootstrap retry keeps full operator handoff after paired approval", async () => { + const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = + await import("../infra/device-bootstrap.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { approveBootstrapDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-retry-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const pending = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + role: "node", + roles: ["node", "operator"], + scopes: [ + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + clientId: client.id, + clientMode: client.mode, + displayName: client.id, + platform: client.platform, + deviceFamily: client.deviceFamily, + silent: true, + }); + await approveBootstrapDevicePairing( + pending.request.requestId, + FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, + ); + + const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const retry = await connectReq(wsRetry, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(retry.ok).toBe(true); + const payload = retry.payload as + | { + auth?: { + deviceToken?: string; + deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; + }; + } + | undefined; + expect(payload?.auth?.deviceToken).toBeTruthy(); + const operatorHandoff = payload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + ); + expect(operatorHandoff?.deviceToken).toBeTruthy(); + expect(operatorHandoff?.scopes).toEqual([ + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]); + expect(operatorHandoff?.scopes).toContain("operator.admin"); + wsRetry.close(); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: identity.deviceId, + publicKey, + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-reject-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); + const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsInitial, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect( + initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined, + ).toMatchObject({ + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + pauseReconnect: false, + }); + wsInitial.close(); + + const pending = (await listDevicePairing()).pending.find( + (entry) => entry.deviceId === identity.deviceId, + ); + if (!pending) { + throw new Error("expected pending bootstrap pairing request"); + } + await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({ + requestId: pending.requestId, + deviceId: identity.deviceId, + }); + + const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const retry = await connectReq(wsRetry, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(retry.ok).toBe(false); + expect((retry.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsRetry.close(); + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); + const reconcileModule = await import("./node-connect-reconcile.js"); + const reconcileSpy = vi + .spyOn(reconcileModule, "reconcileNodePairingOnConnect") + .mockRejectedValueOnce(new Error("boom")); + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, client } = await createOperatorIdentityFixture( + "openclaw-bootstrap-reconcile-fail-", + ); + const nodeClient = { + ...client, + id: "openclaw-android", + mode: "node", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); + + const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsInitial, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + wsInitial.close(); + const pending = (await listDevicePairing()).pending.find( + (entry) => entry.clientId === nodeClient.id, + ); + if (!pending) { + throw new Error("expected pending bootstrap pairing request"); + } + await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] }); + + const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + await expect( + connectReq(wsFail, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + timeoutMs: 500, + }), + ).rejects.toThrow(); + // The full agentic shard can saturate the event loop enough that the + // server-side close after a pre-hello failure arrives later than 1s. + await expect(waitForWsClose(wsFail, 5_000)).resolves.toBe(true); + + const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const retry = await connectReq(wsRetry, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: nodeClient, + deviceIdentityPath: identityPath, + }); + expect(retry.ok).toBe(true); + wsRetry.close(); + } finally { + reconcileSpy.mockRestore(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-role-upgrade-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const seededRequest = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "operator", + scopes: ["operator.read"], + clientId: client.id, + clientMode: client.mode, + platform: client.platform, + deviceFamily: client.deviceFamily, + }); + await approveDevicePairing(seededRequest.request.requestId, { + callerScopes: ["operator.read"], + }); + + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + }, + }); + const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const upgrade = await connectReq(wsUpgrade, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(upgrade.ok).toBe(false); + expect(upgrade.error?.message ?? "").toContain("pairing required"); + expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + expect( + (upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason, + ).toBe("role-upgrade"); + expect( + ( + upgrade.error?.details as + | { + requestedRole?: string; + approvedRoles?: string[]; + } + | undefined + )?.requestedRole, + ).toBe("node"); + expect( + ( + upgrade.error?.details as + | { + requestedRole?: string; + approvedRoles?: string[]; + } + | undefined + )?.approvedRoles, + ).toEqual(["operator"]); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("node"); + expect(pending[0]?.roles).toEqual(["node"]); + const paired = await getPairedDevice(identity.deviceId); + expectArrayIncludes(paired?.roles, ["operator"]); + wsUpgrade.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity, client } = await createOperatorIdentityFixture( + "openclaw-bootstrap-operator-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["operator"], + scopes: ["operator.read"], + }, + }); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: ["operator.read"], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + expect((initial.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("operator"); + expectArrayIncludes(pending[0]?.scopes, ["operator.read"]); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + wsBootstrap.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); +} diff --git a/src/gateway/server.auth.control-ui.device-token.suite.ts b/src/gateway/server.auth.control-ui.device-token.suite.ts new file mode 100644 index 000000000000..c5155516d72f --- /dev/null +++ b/src/gateway/server.auth.control-ui.device-token.suite.ts @@ -0,0 +1,198 @@ +import { expect, test } from "vitest"; +import { startControlUiServerWithClient } from "./server.auth.control-ui.fixtures.test-support.js"; +import { + connectReq, + ConnectErrorDetailCodes, + ensurePairedDeviceTokenForCurrentIdentity, + openWs, + restoreGatewayToken, + startRateLimitedTokenServerWithPairedDeviceToken, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiDeviceTokenSuite(): void { + test("device token auth matrix", async () => { + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { identity, deviceToken, deviceIdentityPath } = + await ensurePairedDeviceTokenForCurrentIdentity(ws); + const { getPairedDevice } = await import("../infra/device-pairing.js"); + ws.close(); + + const scenarios: Array<{ + name: string; + opts: Parameters[1]; + assert: (res: Awaited>) => void; + }> = [ + { + name: "accepts device token auth for paired device", + opts: { token: deviceToken }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "accepts explicit auth.deviceToken when shared token is omitted", + opts: { + skipDefaultAuth: true, + deviceToken, + }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "uses explicit auth.deviceToken fallback when shared token is wrong", + opts: { + token: "wrong", + deviceToken, + }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "keeps shared token mismatch reason when fallback device-token check fails", + opts: { token: "wrong" }, + assert: (res) => { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("gateway token mismatch"); + expect(res.error?.message ?? "").not.toContain("device token mismatch"); + const details = res.error?.details as + | { + code?: string; + canRetryWithDeviceToken?: boolean; + recommendedNextStep?: string; + } + | undefined; + expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH); + expect(details?.canRetryWithDeviceToken).toBe(true); + expect(details?.recommendedNextStep).toBe("retry_with_device_token"); + }, + }, + { + name: "reports device token mismatch when explicit auth.deviceToken is wrong", + opts: { + skipDefaultAuth: true, + deviceToken: "not-a-valid-device-token", + }, + assert: (res) => { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device token mismatch"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, + ); + }, + }, + ]; + + try { + for (const scenario of scenarios) { + const ws2 = await openWs(port); + try { + const res = await connectReq(ws2, { + ...scenario.opts, + deviceIdentityPath, + }); + scenario.assert(res); + } finally { + ws2.close(); + } + } + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.lastSeenReason).toBe("connect"); + expect(typeof paired?.lastSeenAtMs).toBe("number"); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("keeps shared-secret lockout separate from device-token auth", async () => { + const { server, port, prevToken, deviceToken, deviceIdentityPath } = + await startRateLimitedTokenServerWithPairedDeviceToken(); + try { + const wsBadShared = await openWs(port); + const badShared = await connectReq(wsBadShared, { token: "wrong", device: null }); + expect(badShared.ok).toBe(false); + wsBadShared.close(); + + const wsSharedLocked = await openWs(port); + const sharedLocked = await connectReq(wsSharedLocked, { token: "secret", device: null }); + expect(sharedLocked.ok).toBe(false); + expect(sharedLocked.error?.message ?? "").toContain("retry later"); + wsSharedLocked.close(); + + const wsDevice = await openWs(port); + const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath }); + expect(deviceOk.ok).toBe(true); + wsDevice.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("keeps device-token lockout separate from shared-secret auth", async () => { + const { server, port, prevToken, deviceToken, deviceIdentityPath } = + await startRateLimitedTokenServerWithPairedDeviceToken(); + try { + const wsBadDevice = await openWs(port); + const badDevice = await connectReq(wsBadDevice, { + skipDefaultAuth: true, + deviceToken: "wrong", + deviceIdentityPath, + }); + expect(badDevice.ok).toBe(false); + wsBadDevice.close(); + + const wsDeviceLocked = await openWs(port); + const deviceLocked = await connectReq(wsDeviceLocked, { + skipDefaultAuth: true, + deviceToken: "wrong", + deviceIdentityPath, + }); + expect(deviceLocked.ok).toBe(false); + expect(deviceLocked.error?.message ?? "").toContain("retry later"); + wsDeviceLocked.close(); + + const wsShared = await openWs(port); + const sharedOk = await connectReq(wsShared, { token: "secret", device: null }); + expect(sharedOk.ok).toBe(true); + wsShared.close(); + + const wsDeviceReal = await openWs(port); + const deviceStillLocked = await connectReq(wsDeviceReal, { + token: deviceToken, + deviceIdentityPath, + }); + expect(deviceStillLocked.ok).toBe(false); + expect(deviceStillLocked.error?.message ?? "").toContain("retry later"); + wsDeviceReal.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("rejects revoked device token", async () => { + const { revokeDeviceToken } = await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { identity, deviceToken, deviceIdentityPath } = + await ensurePairedDeviceTokenForCurrentIdentity(ws); + + await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" }); + + ws.close(); + + const ws2 = await openWs(port); + const res2 = await connectReq(ws2, { token: deviceToken, deviceIdentityPath }); + expect(res2.ok).toBe(false); + + ws2.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + }); +} diff --git a/src/gateway/server.auth.control-ui.fixtures.test-support.ts b/src/gateway/server.auth.control-ui.fixtures.test-support.ts new file mode 100644 index 000000000000..a4fbfdea5fb5 --- /dev/null +++ b/src/gateway/server.auth.control-ui.fixtures.test-support.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { expect } from "vitest"; +import { + createSignedDevice, + restoreGatewayToken, + startTestGatewayServer, + startServer, + startServerWithClient, + TEST_OPERATOR_CLIENT, + withGatewayServer, +} from "./server.auth.test-helpers.js"; + +export function expectArrayIncludes(actual: unknown, expectedValues: string[]): void { + expect(Array.isArray(actual)).toBe(true); + const values = actual as unknown[]; + for (const expected of expectedValues) { + expect(values).toContain(expected); + } +} + +export const buildSignedDeviceForIdentity = async (params: { + identityPath: string; + client: { id: string; mode: string }; + nonce: string; + scopes: string[]; + role?: "operator" | "node"; +}) => { + const { device } = await createSignedDevice({ + token: "secret", + scopes: params.scopes, + clientId: params.client.id, + clientMode: params.client.mode, + role: params.role ?? "operator", + identityPath: params.identityPath, + nonce: params.nonce, + }); + return device; +}; + +export const REMOTE_BOOTSTRAP_HEADERS = { + "x-forwarded-for": "10.0.0.14", +}; + +export const createOperatorIdentityFixture = async (identityPrefix: string) => { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR must be set by the gateway test hooks"); + } + const identityPath = path.join(stateDir, `${identityPrefix}${randomUUID()}.sqlite`); + const identity = loadOrCreateDeviceIdentity({ path: identityPath }); + return { + identityPath, + identity, + client: { ...TEST_OPERATOR_CLIENT }, + }; +}; + +export const startControlUiServerWithOperatorIdentity = async ( + identityPrefix = "openclaw-device-scope-", +) => { + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix); + return { server, port, prevToken, identityPath, identity, client }; +}; + +export const withControlUiGatewayServer = async ( + fn: (ctx: { + port: number; + server: Awaited>; + }) => Promise, +): Promise => { + return await withGatewayServer(fn, { + serverOptions: { controlUiEnabled: true }, + }); +}; + +export const withControlUiServer = async ( + fn: (ctx: { port: number }) => Promise, + token = "secret", + opts?: Parameters[1], +): Promise => { + const { server, port, prevToken } = await startServer(token, { + ...opts, + controlUiEnabled: true, + }); + try { + return await fn({ port }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } +}; + +export const startControlUiServerWithClient = async ( + token?: string, + opts?: Parameters[1], +) => { + return await startServerWithClient(token, { + ...opts, + controlUiEnabled: true, + }); +}; + +export const startControlUiServer = async ( + token?: string, + opts?: Parameters[1], +) => { + return await startServer(token, { + ...opts, + controlUiEnabled: true, + }); +}; + +export const seedApprovedOperatorReadPairing = async (params: { + identityPrefix: string; + clientId: string; + clientMode: string; + displayName: string; + platform: string; + scopes?: string[]; +}): Promise<{ identityPath: string; identity: { deviceId: string } }> => { + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); + const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix); + const scopes = params.scopes ?? ["operator.read"]; + const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const seeded = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: devicePublicKey, + role: "operator", + scopes, + clientId: params.clientId, + clientMode: params.clientMode, + displayName: params.displayName, + platform: params.platform, + }); + await approveDevicePairing(seeded.request.requestId, { + callerScopes: ["operator.admin"], + }); + return { identityPath, identity: { deviceId: identity.deviceId } }; +}; diff --git a/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts b/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts new file mode 100644 index 000000000000..b189741e1c0d --- /dev/null +++ b/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts @@ -0,0 +1,550 @@ +import { expect, test } from "vitest"; +import { + createOperatorIdentityFixture, + REMOTE_BOOTSTRAP_HEADERS, + startControlUiServer, + withControlUiServer, +} from "./server.auth.control-ui.fixtures.test-support.js"; +import { + connectReq, + ConnectErrorDetailCodes, + openWs, + restoreGatewayToken, + rpcReq, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiMobileBootstrapSuite(): void { + const FULL_OPERATOR_SCOPES = [ + "operator.admin", + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]; + + const connectSetupCodeBootstrapNode = async (params: { + identityPrefix: string; + client: { + id: string; + version: string; + platform: string; + mode: "node"; + deviceFamily: string; + }; + limited?: boolean; + identityFixture?: Awaited>; + }) => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const identityFixture = + params.identityFixture ?? (await createOperatorIdentityFixture(params.identityPrefix)); + const { identityPath, identity } = identityFixture; + return await withControlUiServer(async ({ port }) => { + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + try { + const issued = await issueDeviceBootstrapToken({ + profile: params.limited + ? PAIRING_SETUP_BOOTSTRAP_PROFILE + : FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: params.client, + deviceIdentityPath: identityPath, + }); + return { identity, initial }; + } finally { + wsBootstrap.close(); + } + }); + }; + test("voice-node setup code reconnects with node and Talk-only operator tokens", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-voice-node-", + ); + const client = { + id: "node-host", + version: "1.0.0", + platform: "esp32", + mode: "node" as const, + deviceFamily: "ESP32", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + if (!initial.ok) { + throw new Error(`voice-node bootstrap failed: ${JSON.stringify(initial.error)}`); + } + expect(initial.ok).toBe(true); + const auth = ( + initial.payload as + | { + auth?: { + role?: string; + scopes?: string[]; + deviceToken?: string; + deviceTokens?: Array<{ + role?: string; + scopes?: string[]; + deviceToken?: string; + }>; + }; + } + | undefined + )?.auth; + expect(auth?.role).toBe("node"); + expect(auth?.scopes).toEqual([]); + const nodeToken = auth?.deviceToken; + if (!nodeToken) { + throw new Error("expected issued voice-node device token"); + } + const operatorHandoff = auth?.deviceTokens?.find((entry) => entry.role === "operator"); + expect(operatorHandoff).toMatchObject({ + scopes: ["operator.read", "operator.talk"], + deviceToken: expect.any(String), + }); + const operatorToken = operatorHandoff?.deviceToken; + if (!operatorToken) { + throw new Error("expected handed-off voice-node operator token"); + } + expect((await listDevicePairing()).pending).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.approvedScopes).toEqual(["operator.read", "operator.talk"]); + wsBootstrap.close(); + + const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const nodeReconnect = await connectReq(wsNode, { + skipDefaultAuth: true, + deviceToken: nodeToken, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(nodeReconnect.ok).toBe(true); + wsNode.close(); + + const wsOperator = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const operatorReconnect = await connectReq(wsOperator, { + skipDefaultAuth: true, + deviceToken: operatorToken, + role: "operator", + scopes: ["operator.read", "operator.talk"], + client, + deviceIdentityPath: identityPath, + }); + expect(operatorReconnect.ok).toBe(true); + expect((await rpcReq(wsOperator, "health")).ok).toBe(true); + const talkMode = await rpcReq(wsOperator, "talk.mode", { + enabled: true, + phase: "listening", + }); + expect(talkMode.ok).toBe(true); + expect(talkMode.payload).toMatchObject({ enabled: true, phase: "listening" }); + const adminMutation = await rpcReq(wsOperator, "set-heartbeats", { enabled: false }); + expect(adminMutation.ok).toBe(false); + expect(adminMutation.error?.message ?? "").toContain("missing scope"); + wsOperator.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("qr setup code returns node token plus full operator handoff", async () => { + const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = + await import("../infra/device-bootstrap.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { getPairedDevice, listDevicePairing, verifyDeviceToken } = + await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-node-", + ); + const client = { + id: "openclaw-ios", + version: "2026.3.30", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(true); + const approvedPayload = initial.payload as + | { + type?: string; + auth?: { + deviceToken?: string; + recoveryScope?: string; + role?: string; + scopes?: string[]; + deviceTokens?: Array<{ + deviceToken?: string; + role?: string; + scopes?: string[]; + }>; + }; + } + | undefined; + expect(approvedPayload?.type).toBe("hello-ok"); + const issuedDeviceToken = approvedPayload?.auth?.deviceToken; + if (!issuedDeviceToken) { + throw new Error("expected issued device token"); + } + expect(approvedPayload?.auth?.role).toBe("node"); + expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); + const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + ); + const issuedOperatorToken = operatorHandoff?.deviceToken; + if (!issuedOperatorToken) { + throw new Error("expected handed-off operator device token"); + } + expect(operatorHandoff?.scopes).toEqual(FULL_OPERATOR_SCOPES); + + const pendingAfterInitial = await listDevicePairing(); + const pendingForDevice = pendingAfterInitial.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingForDevice).toEqual([]); + wsBootstrap.close(); + + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.approvedScopes).toEqual(FULL_OPERATOR_SCOPES); + expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); + expect(paired?.tokens?.node?.scopes).toEqual([]); + expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); + expect(paired?.tokens?.operator?.scopes).toEqual(FULL_OPERATOR_SCOPES); + + const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const replay = await connectReq(wsReplay, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(replay.ok).toBe(false); + expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsReplay.close(); + + const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const reconnect = await connectReq(wsReconnect, { + skipDefaultAuth: true, + deviceToken: issuedDeviceToken, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + expect(reconnect.ok).toBe(true); + wsReconnect.close(); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedDeviceToken, + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: true }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedOperatorToken, + role: "operator", + scopes: [ + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + }), + ).resolves.toEqual({ ok: true }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedOperatorToken, + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: true }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedOperatorToken, + role: "operator", + scopes: ["operator.pairing"], + }), + ).resolves.toEqual({ ok: true }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test.each([ + { + name: "Android", + identityPrefix: "openclaw-bootstrap-android-node-", + client: { + id: "openclaw-android", + version: "2026.6.2", + platform: "Android 16", + mode: "node" as const, + deviceFamily: "Android", + }, + }, + { + name: "iPadOS", + identityPrefix: "openclaw-bootstrap-ipados-node-", + client: { + id: "openclaw-ios", + version: "2026.6.2", + platform: "iPadOS 26.3.1", + mode: "node" as const, + deviceFamily: "iPad", + }, + }, + ])( + "qr setup code auto-approves $name clients when mobile metadata matches", + async ({ client, identityPrefix }) => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { identity, initial } = await connectSetupCodeBootstrapNode({ + identityPrefix, + client, + }); + expect(initial.ok).toBe(true); + const approvedPayload = initial.payload as + | { + type?: string; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; + }; + } + | undefined; + expect(approvedPayload?.type).toBe("hello-ok"); + expect(approvedPayload?.auth?.deviceToken).toBeTruthy(); + expect(approvedPayload?.auth?.role).toBe("node"); + expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); + const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + ); + expect(operatorHandoff?.deviceToken).toBeTruthy(); + expect(operatorHandoff?.scopes).toEqual(FULL_OPERATOR_SCOPES); + + const pendingAfterInitial = await listDevicePairing(); + expect( + pendingAfterInitial.pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.approvedScopes).toEqual(FULL_OPERATOR_SCOPES); + }, + ); + + test("limited qr setup keeps the previous bounded operator handoff", async () => { + const { identity, initial } = await connectSetupCodeBootstrapNode({ + identityPrefix: "openclaw-bootstrap-limited-node-", + client: { + id: "openclaw-ios", + version: "2026.7.13", + platform: "iOS 26.3.1", + mode: "node", + deviceFamily: "iPhone", + }, + limited: true, + }); + expect(initial.ok).toBe(true); + const payload = initial.payload as + | { + auth?: { + deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; + }; + } + | undefined; + const operatorHandoff = payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator"); + const operatorToken = operatorHandoff?.deviceToken; + if (!operatorToken) { + throw new Error("expected handed-off limited operator device token"); + } + expect(operatorHandoff?.scopes).toEqual([ + "operator.approvals", + "operator.questions", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]); + expect(operatorHandoff?.scopes).not.toContain("operator.admin"); + + const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js"); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.approvedScopes).not.toContain("operator.admin"); + expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin"); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: operatorToken, + role: "operator", + scopes: ["operator.admin"], + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: operatorToken, + role: "operator", + scopes: ["operator.pairing"], + }), + ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); + }); + + test("full qr setup upgrades an existing limited mobile pairing", async () => { + const identityPrefix = "openclaw-bootstrap-limited-upgrade-node-"; + const client = { + id: "openclaw-ios", + version: "2026.7.13", + platform: "iOS 26.3.1", + mode: "node" as const, + deviceFamily: "iPhone", + }; + const identityFixture = await createOperatorIdentityFixture(identityPrefix); + const limited = await connectSetupCodeBootstrapNode({ + identityPrefix, + client, + limited: true, + identityFixture, + }); + const upgraded = await connectSetupCodeBootstrapNode({ + identityPrefix, + client, + identityFixture, + }); + expect(upgraded.identity.deviceId).toBe(limited.identity.deviceId); + expect(upgraded.initial.ok).toBe(true); + + const payload = upgraded.initial.payload as + | { + auth?: { + deviceTokens?: Array<{ role?: string; scopes?: string[] }>; + }; + } + | undefined; + expect( + payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes, + ).toContain("operator.admin"); + + const { getPairedDevice } = await import("../infra/device-pairing.js"); + const paired = await getPairedDevice(upgraded.identity.deviceId); + expect(paired?.approvedScopes).toContain("operator.admin"); + expect(paired?.tokens?.operator?.scopes).toContain("operator.admin"); + }); + + test.each([ + { + name: "mobile client id with mismatched platform metadata", + identityPrefix: "openclaw-bootstrap-mobile-spoof-", + client: { + id: "openclaw-android", + version: "2026.6.2", + platform: "iOS 26.3.1", + mode: "node" as const, + deviceFamily: "iPhone", + }, + }, + { + name: "valid non-mobile client id with mobile metadata", + identityPrefix: "openclaw-bootstrap-node-host-spoof-", + client: { + id: "node-host", + version: "2026.6.2", + platform: "Android 16", + mode: "node" as const, + deviceFamily: "Android", + }, + }, + ])( + "requires owner approval for setup-code bootstrap spoof: $name", + async ({ client, identityPrefix }) => { + const { listDevicePairing } = await import("../infra/device-pairing.js"); + const { identity, initial } = await connectSetupCodeBootstrapNode({ + identityPrefix, + client, + }); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + expect( + initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined, + ).toMatchObject({ + code: ConnectErrorDetailCodes.PAIRING_REQUIRED, + pauseReconnect: false, + }); + + const pending = (await listDevicePairing()).pending.find( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toMatchObject({ + clientId: client.id, + clientMode: client.mode, + role: "node", + scopes: [], + }); + }, + ); +} diff --git a/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts b/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts new file mode 100644 index 000000000000..b8a05bf2d247 --- /dev/null +++ b/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts @@ -0,0 +1,394 @@ +import { expect, test } from "vitest"; +import { + createOperatorIdentityFixture, + seedApprovedOperatorReadPairing, + startControlUiServer, +} from "./server.auth.control-ui.fixtures.test-support.js"; +import { + connectReq, + CONTROL_UI_CLIENT, + ConnectErrorDetailCodes, + openWs, + restoreGatewayToken, + rpcReq, + testState, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiOwnerBootstrapSuite(): void { + test("silently approves host-authorized control ui owner bootstrap tokens", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing, verifyDeviceToken } = + await import("../infra/device-pairing.js"); + const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { resolveSharedGatewaySessionGeneration } = + await import("./server/ws-shared-generation.js"); + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-control-ui-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + }); + const wsBootstrap = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.50", + }); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(true); + const payload = initial.payload as + | { + type?: string; + auth?: { + deviceToken?: string; + recoveryScope?: string; + role?: string; + scopes?: string[]; + }; + } + | undefined; + expect(payload?.type).toBe("hello-ok"); + expect(payload?.auth?.role).toBe("operator"); + expect(payload?.auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + const deviceToken = payload?.auth?.deviceToken; + const recoveryScope = payload?.auth?.recoveryScope; + if (!deviceToken) { + throw new Error("expected control ui owner device token"); + } + expect(recoveryScope).toMatch(/^[A-Za-z0-9_-]+$/u); + expect((await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false })).ok).toBe(true); + wsBootstrap.close(); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(["operator"]); + expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + const wsReload = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.50", + }); + const reload = await connectReq(wsReload, { + skipDefaultAuth: true, + deviceToken, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(reload.ok).toBe(true); + expect( + (reload.payload as { auth?: { recoveryScope?: string } } | undefined)?.auth?.recoveryScope, + ).toBe(recoveryScope); + wsReload.close(); + + const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration({ + mode: "token", + token: "secret", + allowTailscale: false, + }); + if (!sharedGatewaySessionGeneration) { + throw new Error("expected shared gateway session generation"); + } + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: deviceToken, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + requiredSharedGatewaySessionGeneration: sharedGatewaySessionGeneration, + }), + ).resolves.toEqual({ + ok: true, + issuer: { + kind: "shared-gateway-auth", + generation: sharedGatewaySessionGeneration, + }, + }); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: deviceToken, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + requiredSharedGatewaySessionGeneration: "rotated-generation", + }), + ).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" }); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("keeps generic control ui bootstrap tokens on the bounded profile", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = + await import("../shared/device-bootstrap-profile.js"); + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-control-ui-bounded-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["operator"], + scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + purpose: "control-ui", + }, + }); + const wsBootstrap = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.51", + }); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(true); + const auth = ( + initial.payload as + | { + auth?: { + scopes?: string[]; + }; + } + | undefined + )?.auth; + expect(auth?.scopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]); + expect(auth?.scopes).not.toContain("operator.admin"); + expect(auth?.scopes).not.toContain("operator.pairing"); + const adminMutation = await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false }); + expect(adminMutation.ok).toBe(false); + expect(adminMutation.error?.message ?? "").toContain("missing scope"); + wsBootstrap.close(); + + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + expect((await getPairedDevice(identity.deviceId))?.approvedScopes).toEqual([ + ...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + ]); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("silently upgrades the same control ui key with a host-authorized bootstrap", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-control-ui-owner-upgrade-", + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + displayName: "control-ui-owner-upgrade", + platform: CONTROL_UI_CLIENT.platform, + }); + const before = await getPairedDevice(identity.deviceId); + const previousToken = before?.tokens?.operator?.token; + if (!previousToken) { + throw new Error("expected limited operator token"); + } + + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath: secondIdentityPath } = await createOperatorIdentityFixture( + "openclaw-control-ui-owner-upgrade-second-browser-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, + }); + const wsUpgrade = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.52", + }); + const upgraded = await connectReq(wsUpgrade, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(upgraded.ok).toBe(true); + const auth = ( + upgraded.payload as + | { + auth?: { + deviceToken?: string; + scopes?: string[]; + }; + } + | undefined + )?.auth; + const upgradedToken = auth?.deviceToken; + if (!upgradedToken) { + throw new Error("expected upgraded operator token"); + } + expect(upgradedToken).not.toBe(previousToken); + expect(auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + expect((await rpcReq(wsUpgrade, "set-heartbeats", { enabled: false })).ok).toBe(true); + wsUpgrade.close(); + + expect( + (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), + ).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); + expect(paired?.tokens?.operator?.token).toBe(upgradedToken); + + const wsReload = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.52", + }); + const reload = await connectReq(wsReload, { + skipDefaultAuth: true, + deviceToken: upgradedToken, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(reload.ok).toBe(true); + wsReload.close(); + + const wsSecondBrowser = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.53", + }); + const replay = await connectReq(wsSecondBrowser, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: secondIdentityPath, + }); + expect(replay.ok).toBe(false); + expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, + ); + wsSecondBrowser.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires pairing for control ui bootstrap token without control-ui purpose", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = + await import("../shared/device-bootstrap-profile.js"); + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-control-ui-missing-purpose-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["operator"], + scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + }, + }); + const wsBootstrap = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.51", + }); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "operator", + scopes: ["operator.read"], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("operator"); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + wsBootstrap.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires pairing for control ui node bootstrap tokens", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; + const { server, port, prevToken } = await startControlUiServer("secret"); + + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-control-ui-node-profile-", + ); + + try { + const issued = await issueDeviceBootstrapToken({ + profile: { + roles: ["node"], + scopes: [], + purpose: "control-ui", + }, + }); + const wsBootstrap = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.52", + }); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client: CONTROL_UI_CLIENT, + deviceIdentityPath: identityPath, + }); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + + const pending = (await listDevicePairing()).pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.role).toBe("node"); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + wsBootstrap.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); +} diff --git a/src/gateway/server.auth.control-ui.pairing.suite.ts b/src/gateway/server.auth.control-ui.pairing.suite.ts new file mode 100644 index 000000000000..72c5a645b46a --- /dev/null +++ b/src/gateway/server.auth.control-ui.pairing.suite.ts @@ -0,0 +1,601 @@ +import { expect, test } from "vitest"; +import type { WebSocket } from "ws"; +import { + buildSignedDeviceForIdentity, + createOperatorIdentityFixture, + expectArrayIncludes, + seedApprovedOperatorReadPairing, + startControlUiServer, + startControlUiServerWithOperatorIdentity, + withControlUiServer, +} from "./server.auth.control-ui.fixtures.test-support.js"; +import { + BACKEND_GATEWAY_CLIENT, + connectReq, + CONTROL_UI_CLIENT, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + openTailscaleWs, + openWs, + originForPort, + readConnectChallengeNonce, + restoreGatewayToken, + TEST_OPERATOR_CLIENT, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiPairingSuite(): void { + const tamperPairedMetadata = async ( + deviceId: string, + mutate: (metadata: Record) => void, + ) => { + const { withPairedDeviceRecords } = await import("../infra/device-pairing.js"); + await withPairedDeviceRecords(undefined, (pairedByDeviceId) => { + const metadata = pairedByDeviceId[deviceId] as Record | undefined; + if (!metadata) { + throw new Error(`Expected paired metadata for deviceId=${deviceId}`); + } + mutate(metadata); + return { value: undefined, persist: true }; + }); + }; + + const stripPairedMetadataRolesAndScopes = async (deviceId: string) => { + await tamperPairedMetadata(deviceId, (metadata) => { + delete metadata.roles; + delete metadata.scopes; + }); + }; + + const overwritePairedPublicKey = async (deviceId: string, publicKey: string) => { + await tamperPairedMetadata(deviceId, (metadata) => { + metadata.publicKey = publicKey; + }); + }; + + const injectMalformedPairedAccessLists = async (deviceId: string) => { + await tamperPairedMetadata(deviceId, (metadata) => { + metadata.roles = ["operator", null, 42, ""]; + metadata.scopes = ["operator.read", null, 42, ""]; + metadata.approvedScopes = ["operator.read", null, 42, ""]; + }); + }; + test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken, identityPath, identity, client } = + await startControlUiServerWithOperatorIdentity(); + + const wsRemoteRead = await openWs(port, { host: "gateway.example" }); + const initialNonce = await readConnectChallengeNonce(wsRemoteRead); + const initial = await connectReq(wsRemoteRead, { + token: "secret", + scopes: ["operator.read"], + client, + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.read"], + nonce: initialNonce, + }), + }); + expect(initial.ok).toBe(true); + let pairing = await listDevicePairing(); + const pendingAfterRead = pairing.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingAfterRead).toHaveLength(0); + const pairedAfterRead = await getPairedDevice(identity.deviceId); + if (!pairedAfterRead) { + throw new Error(`expected paired device ${identity.deviceId}`); + } + expect(pairedAfterRead.lastSeenReason).toBe("connect"); + expect(typeof pairedAfterRead.lastSeenAtMs).toBe("number"); + wsRemoteRead.close(); + + const ws2 = await openWs(port, { host: "gateway.example" }); + const nonce2 = await readConnectChallengeNonce(ws2); + const res = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client, + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.admin"], + nonce: nonce2, + }), + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + pairing = await listDevicePairing(); + const pendingAfterAdmin = pairing.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingAfterAdmin).toHaveLength(1); + expectArrayIncludes(pendingAfterAdmin[0]?.scopes, ["operator.admin"]); + if (!(await getPairedDevice(identity.deviceId))) { + throw new Error(`expected paired device ${identity.deviceId}`); + } + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("requires approval for loopback scope upgrades for control ui clients", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-device-token-scope-", + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + displayName: "loopback-control-ui-upgrade", + platform: CONTROL_UI_CLIENT.platform, + }); + + const ws2 = await openWs(port, { origin: originForPort(port) }); + const nonce2 = await readConnectChallengeNonce(ws2); + const upgraded = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client: { ...CONTROL_UI_CLIENT }, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: CONTROL_UI_CLIENT, + scopes: ["operator.admin"], + nonce: nonce2, + }), + }); + expect(upgraded.ok).toBe(false); + expect(upgraded.error?.message ?? "").toContain("pairing required"); + const pending = await listDevicePairing(); + const pendingUpgrade = pending.pending.filter((entry) => entry.deviceId === identity.deviceId); + expect(pendingUpgrade).toHaveLength(1); + expectArrayIncludes(pendingUpgrade[0]?.scopes, ["operator.admin"]); + const updated = await getPairedDevice(identity.deviceId); + expect(updated?.tokens?.operator?.scopes ?? []).not.toContain("operator.admin"); + + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("returns pairing-required for malformed persisted access lists", async () => { + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-device-malformed-access-", + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + displayName: "malformed-access-upgrade", + platform: TEST_OPERATOR_CLIENT.platform, + }); + await injectMalformedPairedAccessLists(identity.deviceId); + + const { server, port, prevToken } = await startControlUiServer("secret"); + let ws: WebSocket | undefined; + try { + ws = await openWs(port); + const nonce = await readConnectChallengeNonce(ws); + const result = await connectReq(ws, { + token: "secret", + scopes: ["operator.admin"], + client: { ...TEST_OPERATOR_CLIENT }, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: TEST_OPERATOR_CLIENT, + scopes: ["operator.admin"], + nonce, + }), + }); + + expect(result.ok).toBe(false); + expect(result.error?.message ?? "").toContain("pairing required"); + expect((result.error?.details as { reason?: string } | undefined)?.reason).toBe( + "scope-upgrade", + ); + } finally { + ws?.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("does not expose approved access when a paired device id reconnects with a different key", async () => { + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-device-key-mismatch-", + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + displayName: "remote-key-mismatch", + platform: TEST_OPERATOR_CLIENT.platform, + }); + await overwritePairedPublicKey(identity.deviceId, "mismatched-public-key"); + + const { server, port, prevToken } = await startControlUiServer("secret"); + const ws2 = await openTailscaleWs(port); + try { + const nonce2 = await readConnectChallengeNonce(ws2); + const mismatched = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client: { ...TEST_OPERATOR_CLIENT }, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: TEST_OPERATOR_CLIENT, + scopes: ["operator.admin"], + nonce: nonce2, + }), + }); + expect(mismatched.ok).toBe(false); + expect(mismatched.error?.message ?? "").toContain("pairing required"); + expect( + ( + mismatched.error?.details as + | { + reason?: string; + requestedRole?: string; + requestedScopes?: string[]; + approvedRoles?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.reason, + ).toBe("not-paired"); + expect( + ( + mismatched.error?.details as + | { + requestedRole?: string; + requestedScopes?: string[]; + } + | undefined + )?.requestedRole, + ).toBe("operator"); + expect( + ( + mismatched.error?.details as + | { + requestedRole?: string; + requestedScopes?: string[]; + } + | undefined + )?.requestedScopes, + ).toEqual(["operator.admin"]); + expect( + ( + mismatched.error?.details as + | { + approvedRoles?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.approvedRoles, + ).toBeUndefined(); + expect( + ( + mismatched.error?.details as + | { + approvedRoles?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.approvedScopes, + ).toBeUndefined(); + } finally { + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { identityPath, identity, client } = + await createOperatorIdentityFixture("openclaw-device-scope-"); + await withControlUiServer(async ({ port }) => { + const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { + const socket = await openWs(port, { host: "gateway.example" }); + try { + const nonce = await readConnectChallengeNonce(socket); + return await connectReq(socket, { + token: "secret", + role, + scopes, + client, + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + role, + scopes, + nonce, + }), + }); + } finally { + socket.close(); + } + }; + + const nodeConnect = await connectWithNonce("node", []); + expect(nodeConnect.ok).toBe(true); + + const operatorConnect = await connectWithNonce("operator", [ + "operator.read", + "operator.write", + ]); + expect(operatorConnect.ok).toBe(false); + expect(operatorConnect.error?.message ?? "").toContain("pairing required"); + + const pending = await listDevicePairing(); + const pendingForTestDevice = pending.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingForTestDevice).toHaveLength(1); + expectArrayIncludes(pendingForTestDevice[0]?.scopes, ["operator.read", "operator.write"]); + + const paired = await getPairedDevice(identity.deviceId); + expectArrayIncludes(paired?.roles, ["node", "operator"]); + expectArrayIncludes(paired?.approvedScopes, ["operator.read", "operator.write"]); + + const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]); + expect(approvedOperatorConnect.ok).toBe(true); + }); + }); + + test("allows operator.read connect when device is paired with operator.admin", async () => { + const { listDevicePairing } = await import("../infra/device-pairing.js"); + const { identityPath, identity } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-device-admin-superset-", + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + displayName: "operator-admin-superset", + platform: TEST_OPERATOR_CLIENT.platform, + scopes: ["operator.admin"], + }); + + const { server, port, prevToken } = await startControlUiServer("secret"); + + const ws2 = await openWs(port); + const nonce2 = await readConnectChallengeNonce(ws2); + const res = await connectReq(ws2, { + token: "secret", + scopes: ["operator.read"], + client: TEST_OPERATOR_CLIENT, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: TEST_OPERATOR_CLIENT, + scopes: ["operator.read"], + nonce: nonce2, + }), + }); + expect(res.ok).toBe(true); + ws2.close(); + + const list = await listDevicePairing(); + expect(list.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + + await server.close(); + restoreGatewayToken(prevToken); + }); + + test("allows operator shared auth with legacy paired metadata", async () => { + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-device-legacy-meta-", + ); + const deviceId = identity.deviceId; + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const pending = await requestDevicePairing({ + deviceId, + publicKey, + role: "operator", + scopes: ["operator.read"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + displayName: "legacy-test", + platform: "test", + }); + await approveDevicePairing(pending.request.requestId, { + callerScopes: pending.request.scopes ?? ["operator.admin"], + }); + + await stripPairedMetadataRolesAndScopes(deviceId); + + const { server, port, prevToken } = await startControlUiServer("secret"); + let ws2: WebSocket | undefined; + try { + const wsReconnect = await openWs(port); + ws2 = wsReconnect; + const reconnectNonce = await readConnectChallengeNonce(wsReconnect); + const reconnect = await connectReq(wsReconnect, { + token: "secret", + scopes: ["operator.read"], + client: TEST_OPERATOR_CLIENT, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: TEST_OPERATOR_CLIENT, + scopes: ["operator.read"], + nonce: reconnectNonce, + }), + }); + expect(reconnect.ok).toBe(true); + + const repaired = await getPairedDevice(deviceId); + expect(repaired?.role).toBe("operator"); + expect(repaired?.approvedScopes ?? []).toContain("operator.read"); + expect(repaired?.tokens?.operator?.scopes ?? []).toContain("operator.read"); + const list = await listDevicePairing(); + expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + ws2?.close(); + } + }); + + test("requires approval for local scope upgrades even when paired metadata is legacy-shaped", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { identity, identityPath } = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-device-legacy-", + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + displayName: "legacy-upgrade-test", + platform: "test", + }); + + await stripPairedMetadataRolesAndScopes(identity.deviceId); + + const { server, port, prevToken } = await startControlUiServer("secret"); + let ws2: WebSocket | undefined; + try { + const client = { ...TEST_OPERATOR_CLIENT }; + + const wsUpgrade = await openWs(port); + ws2 = wsUpgrade; + const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); + const upgraded = await connectReq(wsUpgrade, { + token: "secret", + scopes: ["operator.admin"], + client, + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.admin"], + nonce: upgradeNonce, + }), + }); + expect(upgraded.ok).toBe(false); + expect(upgraded.error?.message ?? "").toContain("pairing required"); + expect( + ( + upgraded.error?.details as + | { + reason?: string; + requestedRole?: string; + requestedScopes?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.reason, + ).toBe("scope-upgrade"); + expect( + ( + upgraded.error?.details as + | { + reason?: string; + requestedRole?: string; + requestedScopes?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.requestedRole, + ).toBe("operator"); + expect( + ( + upgraded.error?.details as + | { + reason?: string; + requestedRole?: string; + requestedScopes?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.requestedScopes, + ).toEqual(["operator.admin"]); + expect( + ( + upgraded.error?.details as + | { + reason?: string; + requestedRole?: string; + requestedScopes?: string[]; + approvedScopes?: string[]; + } + | undefined + )?.approvedScopes, + ).toEqual(["operator.read"]); + wsUpgrade.close(); + + const pendingUpgrade = (await listDevicePairing()).pending.find( + (entry) => entry.deviceId === identity.deviceId, + ); + if (!pendingUpgrade) { + throw new Error(`expected pending upgrade for device ${identity.deviceId}`); + } + expectArrayIncludes(pendingUpgrade.scopes, ["operator.admin"]); + const repaired = await getPairedDevice(identity.deviceId); + expect(repaired?.role).toBe("operator"); + expectArrayIncludes(repaired?.approvedScopes, ["operator.read"]); + } finally { + ws2?.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test.each([ + { + name: "allows gateway backend loopback shared-auth connections without device pairing", + client: BACKEND_GATEWAY_CLIENT, + hosts: [undefined, "gateway.example", "172.17.0.2:18789"], + }, + { + name: "allows CLI clients on loopback even when the host header is not private-or-loopback", + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "linux", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + hosts: ["gateway.example"], + }, + ])("$name", async ({ client, hosts }) => { + await withControlUiServer(async ({ port }) => { + for (const host of hosts) { + const socket = await openWs(port, host ? { host } : undefined); + try { + const result = await connectReq(socket, { token: "secret", client }); + expect(result.ok, host ?? "default host").toBe(true); + } finally { + socket.close(); + } + } + }); + }); + + test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); + try { + const { identity, identityPath } = + await createOperatorIdentityFixture("openclaw-cli-docker-"); + const nonce = await readConnectChallengeNonce(wsDockerCli); + const dockerCli = await connectReq(wsDockerCli, { + token: "secret", + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "linux", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + scopes: ["operator.admin"], + nonce, + }), + }); + expect(dockerCli.ok).toBe(true); + const pending = await listDevicePairing(); + expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + if (!(await getPairedDevice(identity.deviceId))) { + throw new Error(`expected paired device ${identity.deviceId}`); + } + } finally { + wsDockerCli.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); +} diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts deleted file mode 100644 index 62c2e1d2d8a1..000000000000 --- a/src/gateway/server.auth.control-ui.suite.ts +++ /dev/null @@ -1,2582 +0,0 @@ -// Control UI auth suite covers trusted-proxy, pairing, device identity, and -// operator/node role checks for browser-facing gateway connections. -import os from "node:os"; -import path from "node:path"; -import { beforeAll, expect, test, vi } from "vitest"; -import { WebSocket } from "ws"; -import { - BACKEND_GATEWAY_CLIENT, - connectReq, - configureTrustedProxyControlUiAuth, - CONTROL_UI_CLIENT, - ConnectErrorDetailCodes, - createSignedDevice, - ensurePairedDeviceTokenForCurrentIdentity, - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - onceMessage, - openTailscaleWs, - openWs, - originForPort, - readConnectChallengeNonce, - restoreGatewayToken, - rpcReq, - startRateLimitedTokenServerWithPairedDeviceToken, - startTestGatewayServer, - startServer, - startServerWithClient, - TEST_OPERATOR_CLIENT, - testState, - TRUSTED_PROXY_CONTROL_UI_HEADERS, - waitForWsClose, - withGatewayServer, -} from "./server.auth.test-helpers.js"; - -const operatorIdentityPathByPrefix = new Map(); - -function expectArrayIncludes(actual: unknown, expectedValues: string[]): void { - expect(Array.isArray(actual)).toBe(true); - const values = actual as unknown[]; - for (const expected of expectedValues) { - expect(values).toContain(expected); - } -} - -export function registerControlUiAndPairingSuite(): void { - const trustedProxyControlUiCases: Array<{ - name: string; - role: "operator" | "node"; - withUnpairedNodeDevice: boolean; - expectedOk: boolean; - expectedErrorSubstring?: string; - expectedErrorCode?: string; - }> = [ - { - name: "rejects loopback trusted-proxy control ui operator without device identity", - role: "operator", - withUnpairedNodeDevice: false, - expectedOk: false, - expectedErrorSubstring: "control ui requires device identity", - expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - }, - { - name: "rejects trusted-proxy control ui node role without device identity", - role: "node", - withUnpairedNodeDevice: false, - expectedOk: false, - expectedErrorSubstring: "control ui requires device identity", - expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - }, - { - name: "rejects loopback trusted-proxy control ui node role before pairing", - role: "node", - withUnpairedNodeDevice: true, - expectedOk: false, - expectedErrorSubstring: "unauthorized", - }, - ]; - const trustedProxyControlUiResults = new Map>>(); - - const buildSignedDeviceForIdentity = async (params: { - identityPath: string; - client: { id: string; mode: string }; - nonce: string; - scopes: string[]; - role?: "operator" | "node"; - }) => { - const { device } = await createSignedDevice({ - token: "secret", - scopes: params.scopes, - clientId: params.client.id, - clientMode: params.client.mode, - role: params.role ?? "operator", - identityPath: params.identityPath, - nonce: params.nonce, - }); - return device; - }; - - const REMOTE_BOOTSTRAP_HEADERS = { - "x-forwarded-for": "10.0.0.14", - }; - - const connectSetupCodeBootstrapNode = async (params: { - identityPrefix: string; - client: { - id: string; - version: string; - platform: string; - mode: "node"; - deviceFamily: string; - }; - limited?: boolean; - }) => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix); - const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - try { - const issued = await issueDeviceBootstrapToken({ - profile: params.limited - ? PAIRING_SETUP_BOOTSTRAP_PROFILE - : FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: params.client, - deviceIdentityPath: identityPath, - }); - return { identity, initial }; - } finally { - wsBootstrap.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }; - - const createOperatorIdentityFixture = async (identityPrefix: string) => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - let identityPath = operatorIdentityPathByPrefix.get(identityPrefix); - if (!identityPath) { - const poolId = process.env.VITEST_POOL_ID ?? "0"; - identityPath = path.join(os.tmpdir(), `${identityPrefix}${process.pid}-${poolId}.sqlite`); - operatorIdentityPathByPrefix.set(identityPrefix, identityPath); - } - const identity = loadOrCreateDeviceIdentity({ path: identityPath }); - return { - identityPath, - identity, - client: { ...TEST_OPERATOR_CLIENT }, - }; - }; - - const startControlUiServerWithOperatorIdentity = async ( - identityPrefix = "openclaw-device-scope-", - ) => { - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix); - return { server, port, prevToken, identityPath, identity, client }; - }; - - const withControlUiGatewayServer = async ( - fn: (ctx: { - port: number; - server: Awaited>; - }) => Promise, - ): Promise => { - return await withGatewayServer(fn, { - serverOptions: { controlUiEnabled: true }, - }); - }; - - const startControlUiServerWithClient = async ( - token?: string, - opts?: Parameters[1], - ) => { - return await startServerWithClient(token, { - ...opts, - controlUiEnabled: true, - }); - }; - - const startControlUiServer = async (token?: string, opts?: Parameters[1]) => { - return await startServer(token, { - ...opts, - controlUiEnabled: true, - }); - }; - - // Tampers with the persisted paired record through the store seam to - // simulate legacy or hand-edited state the runtime must normalize. - const tamperPairedMetadata = async ( - deviceId: string, - mutate: (metadata: Record) => void, - ) => { - const { withPairedDeviceRecords } = await import("../infra/device-pairing.js"); - await withPairedDeviceRecords(undefined, (pairedByDeviceId) => { - const metadata = pairedByDeviceId[deviceId] as Record | undefined; - if (!metadata) { - throw new Error(`Expected paired metadata for deviceId=${deviceId}`); - } - mutate(metadata); - return { value: undefined, persist: true }; - }); - }; - - const stripPairedMetadataRolesAndScopes = async (deviceId: string) => { - await tamperPairedMetadata(deviceId, (metadata) => { - delete metadata.roles; - delete metadata.scopes; - }); - }; - - const overwritePairedPublicKey = async (deviceId: string, publicKey: string) => { - await tamperPairedMetadata(deviceId, (metadata) => { - metadata.publicKey = publicKey; - }); - }; - - const injectMalformedPairedAccessLists = async (deviceId: string) => { - await tamperPairedMetadata(deviceId, (metadata) => { - metadata.roles = ["operator", null, 42, ""]; - metadata.scopes = ["operator.read", null, 42, ""]; - metadata.approvedScopes = ["operator.read", null, 42, ""]; - }); - }; - - const seedApprovedOperatorReadPairing = async (params: { - identityPrefix: string; - clientId: string; - clientMode: string; - displayName: string; - platform: string; - scopes?: string[]; - }): Promise<{ identityPath: string; identity: { deviceId: string } }> => { - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix); - const scopes = params.scopes ?? ["operator.read"]; - const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); - const seeded = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey: devicePublicKey, - role: "operator", - scopes, - clientId: params.clientId, - clientMode: params.clientMode, - displayName: params.displayName, - platform: params.platform, - }); - await approveDevicePairing(seeded.request.requestId, { - callerScopes: ["operator.admin"], - }); - return { identityPath, identity: { deviceId: identity.deviceId } }; - }; - - beforeAll(async () => { - await configureTrustedProxyControlUiAuth(); - await withControlUiGatewayServer(async ({ port }) => { - for (const tc of trustedProxyControlUiCases) { - const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); - try { - const scopes = tc.withUnpairedNodeDevice ? [] : undefined; - let device: Awaited>["device"] | null = null; - if (tc.withUnpairedNodeDevice) { - const challengeNonce = await readConnectChallengeNonce(ws); - if (!challengeNonce) { - throw new Error(`expected connect challenge nonce for ${tc.name}`); - } - ({ device } = await createSignedDevice({ - token: null, - role: "node", - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - nonce: challengeNonce, - })); - } - trustedProxyControlUiResults.set( - tc.name, - await connectReq(ws, { - skipDefaultAuth: true, - role: tc.role, - scopes, - device, - client: { ...CONTROL_UI_CLIENT }, - }), - ); - } finally { - ws.close(); - } - } - }); - }); - - test.each(trustedProxyControlUiCases)("$name", (tc) => { - const res = trustedProxyControlUiResults.get(tc.name); - if (!res) { - throw new Error(`missing trusted-proxy result for ${tc.name}`); - } - expect(res.ok, tc.name).toBe(tc.expectedOk); - if (!tc.expectedOk) { - if (tc.expectedErrorSubstring) { - expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring); - } - if (tc.expectedErrorCode) { - expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe( - tc.expectedErrorCode, - ); - } - } - }); - - test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => { - await configureTrustedProxyControlUiAuth(); - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { rejectDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { identity } = await createOperatorIdentityFixture("openclaw-control-ui-trusted-proxy-"); - const pendingRequest = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - role: "operator", - scopes: ["operator.admin"], - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - }); - await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); - try { - const res = await connectReq(ws, { - skipDefaultAuth: true, - scopes: ["operator.admin"], - device: null, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("control ui requires device identity"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - ); - } finally { - ws.close(); - await rejectDevicePairing(pendingRequest.request.requestId); - } - }); - }); - - test("requires pairing for trusted-proxy control ui device identity", async () => { - const { replaceConfigFile } = await import("../config/config.js"); - testState.gatewayAuth = undefined; - testState.gatewayControlUi = { - ...testState.gatewayControlUi, - allowedOrigins: ["https://localhost"], - }; - await replaceConfigFile({ - nextConfig: { - gateway: { - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, - }, - }, - afterWrite: { mode: "auto" }, - }); - await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); - try { - const challengeNonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token: null, - role: "operator", - scopes: ["operator.admin", "operator.read"], - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - nonce: challengeNonce, - }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - scopes: ["operator.admin", "operator.read"], - device, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - } finally { - ws.close(); - } - }); - }); - - test("clears trusted-proxy control ui scopes without device identity", async () => { - const { replaceConfigFile } = await import("../config/config.js"); - testState.gatewayAuth = undefined; - testState.gatewayControlUi = { - ...testState.gatewayControlUi, - allowedOrigins: ["https://localhost"], - }; - await replaceConfigFile({ - nextConfig: { - gateway: { - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, - }, - }, - afterWrite: { mode: "auto" }, - }); - await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); - try { - const res = await connectReq(ws, { - skipDefaultAuth: true, - scopes: ["operator.admin", "operator.read"], - device: null, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(true); - const payload = res.payload as - | { - auth?: { scopes?: string[]; deviceToken?: string }; - } - | undefined; - expect(payload?.auth?.scopes).toEqual([]); - expect(payload?.auth?.deviceToken).toBeUndefined(); - - const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); - expect(admin.ok).toBe(false); - expect(admin.error?.message ?? "").toContain("missing scope"); - } finally { - ws.close(); - } - }); - }); - - test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => { - const { replaceConfigFile } = await import("../config/config.js"); - testState.gatewayAuth = undefined; - testState.gatewayControlUi = { - ...testState.gatewayControlUi, - allowedOrigins: ["https://localhost"], - }; - await replaceConfigFile({ - nextConfig: { - gateway: { - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, - }, - }, - afterWrite: { mode: "auto" }, - }); - await withControlUiGatewayServer(async ({ port }) => { - const seeded = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-control-ui-trusted-proxy-bounded-", - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - displayName: "Control UI", - platform: "web", - scopes: ["operator.admin", "operator.read"], - }); - const ws = await openWs(port, { - ...TRUSTED_PROXY_CONTROL_UI_HEADERS, - "x-openclaw-scopes": "operator.read", - }); - try { - const challengeNonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token: null, - role: "operator", - scopes: ["operator.admin", "operator.read"], - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - identityPath: seeded.identityPath, - nonce: challengeNonce, - }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - scopes: ["operator.admin", "operator.read"], - device, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(true); - const payload = res.payload as - | { - auth?: { scopes?: string[]; deviceToken?: string }; - } - | undefined; - expect(payload?.auth?.scopes).toEqual(["operator.read"]); - expect(payload?.auth?.deviceToken).toBeUndefined(); - - const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); - expect(admin.ok).toBe(false); - expect(admin.error?.message ?? "").toContain("missing scope"); - - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - } finally { - ws.close(); - } - }); - }); - - test("device token auth matrix", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - const { identity, deviceToken, deviceIdentityPath } = - await ensurePairedDeviceTokenForCurrentIdentity(ws); - const { getPairedDevice } = await import("../infra/device-pairing.js"); - ws.close(); - - const scenarios: Array<{ - name: string; - opts: Parameters[1]; - assert: (res: Awaited>) => void; - }> = [ - { - name: "accepts device token auth for paired device", - opts: { token: deviceToken }, - assert: (res) => { - expect(res.ok).toBe(true); - }, - }, - { - name: "accepts explicit auth.deviceToken when shared token is omitted", - opts: { - skipDefaultAuth: true, - deviceToken, - }, - assert: (res) => { - expect(res.ok).toBe(true); - }, - }, - { - name: "uses explicit auth.deviceToken fallback when shared token is wrong", - opts: { - token: "wrong", - deviceToken, - }, - assert: (res) => { - expect(res.ok).toBe(true); - }, - }, - { - name: "keeps shared token mismatch reason when fallback device-token check fails", - opts: { token: "wrong" }, - assert: (res) => { - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("gateway token mismatch"); - expect(res.error?.message ?? "").not.toContain("device token mismatch"); - const details = res.error?.details as - | { - code?: string; - canRetryWithDeviceToken?: boolean; - recommendedNextStep?: string; - } - | undefined; - expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH); - expect(details?.canRetryWithDeviceToken).toBe(true); - expect(details?.recommendedNextStep).toBe("retry_with_device_token"); - }, - }, - { - name: "reports device token mismatch when explicit auth.deviceToken is wrong", - opts: { - skipDefaultAuth: true, - deviceToken: "not-a-valid-device-token", - }, - assert: (res) => { - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("device token mismatch"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, - ); - }, - }, - ]; - - try { - for (const scenario of scenarios) { - const ws2 = await openWs(port); - try { - const res = await connectReq(ws2, { - ...scenario.opts, - deviceIdentityPath, - }); - scenario.assert(res); - } finally { - ws2.close(); - } - } - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.lastSeenReason).toBe("connect"); - expect(typeof paired?.lastSeenAtMs).toBe("number"); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("keeps shared-secret lockout separate from device-token auth", async () => { - const { server, port, prevToken, deviceToken, deviceIdentityPath } = - await startRateLimitedTokenServerWithPairedDeviceToken(); - try { - const wsBadShared = await openWs(port); - const badShared = await connectReq(wsBadShared, { token: "wrong", device: null }); - expect(badShared.ok).toBe(false); - wsBadShared.close(); - - const wsSharedLocked = await openWs(port); - const sharedLocked = await connectReq(wsSharedLocked, { token: "secret", device: null }); - expect(sharedLocked.ok).toBe(false); - expect(sharedLocked.error?.message ?? "").toContain("retry later"); - wsSharedLocked.close(); - - const wsDevice = await openWs(port); - const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath }); - expect(deviceOk.ok).toBe(true); - wsDevice.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("keeps device-token lockout separate from shared-secret auth", async () => { - const { server, port, prevToken, deviceToken, deviceIdentityPath } = - await startRateLimitedTokenServerWithPairedDeviceToken(); - try { - const wsBadDevice = await openWs(port); - const badDevice = await connectReq(wsBadDevice, { - skipDefaultAuth: true, - deviceToken: "wrong", - deviceIdentityPath, - }); - expect(badDevice.ok).toBe(false); - wsBadDevice.close(); - - const wsDeviceLocked = await openWs(port); - const deviceLocked = await connectReq(wsDeviceLocked, { - skipDefaultAuth: true, - deviceToken: "wrong", - deviceIdentityPath, - }); - expect(deviceLocked.ok).toBe(false); - expect(deviceLocked.error?.message ?? "").toContain("retry later"); - wsDeviceLocked.close(); - - const wsShared = await openWs(port); - const sharedOk = await connectReq(wsShared, { token: "secret", device: null }); - expect(sharedOk.ok).toBe(true); - wsShared.close(); - - const wsDeviceReal = await openWs(port); - const deviceStillLocked = await connectReq(wsDeviceReal, { - token: deviceToken, - deviceIdentityPath, - }); - expect(deviceStillLocked.ok).toBe(false); - expect(deviceStillLocked.error?.message ?? "").toContain("retry later"); - wsDeviceReal.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken, identityPath, identity, client } = - await startControlUiServerWithOperatorIdentity(); - - const wsRemoteRead = await openWs(port, { host: "gateway.example" }); - const initialNonce = await readConnectChallengeNonce(wsRemoteRead); - const initial = await connectReq(wsRemoteRead, { - token: "secret", - scopes: ["operator.read"], - client, - device: await buildSignedDeviceForIdentity({ - identityPath, - client, - scopes: ["operator.read"], - nonce: initialNonce, - }), - }); - expect(initial.ok).toBe(true); - let pairing = await listDevicePairing(); - const pendingAfterRead = pairing.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingAfterRead).toHaveLength(0); - const pairedAfterRead = await getPairedDevice(identity.deviceId); - if (!pairedAfterRead) { - throw new Error(`expected paired device ${identity.deviceId}`); - } - expect(pairedAfterRead.lastSeenReason).toBe("connect"); - expect(typeof pairedAfterRead.lastSeenAtMs).toBe("number"); - wsRemoteRead.close(); - - const ws2 = await openWs(port, { host: "gateway.example" }); - const nonce2 = await readConnectChallengeNonce(ws2); - const res = await connectReq(ws2, { - token: "secret", - scopes: ["operator.admin"], - client, - device: await buildSignedDeviceForIdentity({ - identityPath, - client, - scopes: ["operator.admin"], - nonce: nonce2, - }), - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - pairing = await listDevicePairing(); - const pendingAfterAdmin = pairing.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingAfterAdmin).toHaveLength(1); - expectArrayIncludes(pendingAfterAdmin[0]?.scopes, ["operator.admin"]); - if (!(await getPairedDevice(identity.deviceId))) { - throw new Error(`expected paired device ${identity.deviceId}`); - } - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("requires approval for loopback scope upgrades for control ui clients", async () => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identity, identityPath } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-device-token-scope-", - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - displayName: "loopback-control-ui-upgrade", - platform: CONTROL_UI_CLIENT.platform, - }); - - const ws2 = await openWs(port, { origin: originForPort(port) }); - const nonce2 = await readConnectChallengeNonce(ws2); - const upgraded = await connectReq(ws2, { - token: "secret", - scopes: ["operator.admin"], - client: { ...CONTROL_UI_CLIENT }, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: CONTROL_UI_CLIENT, - scopes: ["operator.admin"], - nonce: nonce2, - }), - }); - expect(upgraded.ok).toBe(false); - expect(upgraded.error?.message ?? "").toContain("pairing required"); - const pending = await listDevicePairing(); - const pendingUpgrade = pending.pending.filter((entry) => entry.deviceId === identity.deviceId); - expect(pendingUpgrade).toHaveLength(1); - expectArrayIncludes(pendingUpgrade[0]?.scopes, ["operator.admin"]); - const updated = await getPairedDevice(identity.deviceId); - expect(updated?.tokens?.operator?.scopes ?? []).not.toContain("operator.admin"); - - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("returns pairing-required for malformed persisted access lists", async () => { - const { identity, identityPath } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-device-malformed-access-", - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - displayName: "malformed-access-upgrade", - platform: TEST_OPERATOR_CLIENT.platform, - }); - await injectMalformedPairedAccessLists(identity.deviceId); - - const { server, port, prevToken } = await startControlUiServer("secret"); - let ws: WebSocket | undefined; - try { - ws = await openWs(port); - const nonce = await readConnectChallengeNonce(ws); - const result = await connectReq(ws, { - token: "secret", - scopes: ["operator.admin"], - client: { ...TEST_OPERATOR_CLIENT }, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: TEST_OPERATOR_CLIENT, - scopes: ["operator.admin"], - nonce, - }), - }); - - expect(result.ok).toBe(false); - expect(result.error?.message ?? "").toContain("pairing required"); - expect((result.error?.details as { reason?: string } | undefined)?.reason).toBe( - "scope-upgrade", - ); - } finally { - ws?.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("does not expose approved access when a paired device id reconnects with a different key", async () => { - const { identity, identityPath } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-device-key-mismatch-", - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - displayName: "remote-key-mismatch", - platform: TEST_OPERATOR_CLIENT.platform, - }); - await overwritePairedPublicKey(identity.deviceId, "mismatched-public-key"); - - const { server, port, prevToken } = await startControlUiServer("secret"); - const ws2 = await openTailscaleWs(port); - try { - const nonce2 = await readConnectChallengeNonce(ws2); - const mismatched = await connectReq(ws2, { - token: "secret", - scopes: ["operator.admin"], - client: { ...TEST_OPERATOR_CLIENT }, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: TEST_OPERATOR_CLIENT, - scopes: ["operator.admin"], - nonce: nonce2, - }), - }); - expect(mismatched.ok).toBe(false); - expect(mismatched.error?.message ?? "").toContain("pairing required"); - expect( - ( - mismatched.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedRoles?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.reason, - ).toBe("not-paired"); - expect( - ( - mismatched.error?.details as - | { - requestedRole?: string; - requestedScopes?: string[]; - } - | undefined - )?.requestedRole, - ).toBe("operator"); - expect( - ( - mismatched.error?.details as - | { - requestedRole?: string; - requestedScopes?: string[]; - } - | undefined - )?.requestedScopes, - ).toEqual(["operator.admin"]); - expect( - ( - mismatched.error?.details as - | { - approvedRoles?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.approvedRoles, - ).toBeUndefined(); - expect( - ( - mismatched.error?.details as - | { - approvedRoles?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.approvedScopes, - ).toBeUndefined(); - } finally { - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("voice-node setup code reconnects with node and Talk-only operator tokens", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-voice-node-", - ); - const client = { - id: "node-host", - version: "1.0.0", - platform: "esp32", - mode: "node" as const, - deviceFamily: "ESP32", - }; - - try { - const issued = await issueDeviceBootstrapToken({ - profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - if (!initial.ok) { - throw new Error(`voice-node bootstrap failed: ${JSON.stringify(initial.error)}`); - } - expect(initial.ok).toBe(true); - const auth = ( - initial.payload as - | { - auth?: { - role?: string; - scopes?: string[]; - deviceToken?: string; - deviceTokens?: Array<{ - role?: string; - scopes?: string[]; - deviceToken?: string; - }>; - }; - } - | undefined - )?.auth; - expect(auth?.role).toBe("node"); - expect(auth?.scopes).toEqual([]); - const nodeToken = auth?.deviceToken; - if (!nodeToken) { - throw new Error("expected issued voice-node device token"); - } - const operatorHandoff = auth?.deviceTokens?.find((entry) => entry.role === "operator"); - expect(operatorHandoff).toMatchObject({ - scopes: ["operator.read", "operator.talk"], - deviceToken: expect.any(String), - }); - const operatorToken = operatorHandoff?.deviceToken; - if (!operatorToken) { - throw new Error("expected handed-off voice-node operator token"); - } - expect((await listDevicePairing()).pending).toEqual([]); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["node", "operator"]); - expect(paired?.approvedScopes).toEqual(["operator.read", "operator.talk"]); - wsBootstrap.close(); - - const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const nodeReconnect = await connectReq(wsNode, { - skipDefaultAuth: true, - deviceToken: nodeToken, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(nodeReconnect.ok).toBe(true); - wsNode.close(); - - const wsOperator = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const operatorReconnect = await connectReq(wsOperator, { - skipDefaultAuth: true, - deviceToken: operatorToken, - role: "operator", - scopes: ["operator.read", "operator.talk"], - client, - deviceIdentityPath: identityPath, - }); - expect(operatorReconnect.ok).toBe(true); - expect((await rpcReq(wsOperator, "health")).ok).toBe(true); - const talkMode = await rpcReq(wsOperator, "talk.mode", { - enabled: true, - phase: "listening", - }); - expect(talkMode.ok).toBe(true); - expect(talkMode.payload).toMatchObject({ enabled: true, phase: "listening" }); - const adminMutation = await rpcReq(wsOperator, "set-heartbeats", { enabled: false }); - expect(adminMutation.ok).toBe(false); - expect(adminMutation.error?.message ?? "").toContain("missing scope"); - wsOperator.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("qr setup code returns node token plus full operator handoff", async () => { - const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = - await import("../infra/device-bootstrap.js"); - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = - await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-node-", - ); - const client = { - id: "openclaw-ios", - version: "2026.3.30", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }; - - try { - const issued = await issueDeviceBootstrapToken({ - profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(true); - const approvedPayload = initial.payload as - | { - type?: string; - auth?: { - deviceToken?: string; - recoveryScope?: string; - role?: string; - scopes?: string[]; - deviceTokens?: Array<{ - deviceToken?: string; - role?: string; - scopes?: string[]; - }>; - }; - } - | undefined; - expect(approvedPayload?.type).toBe("hello-ok"); - const issuedDeviceToken = approvedPayload?.auth?.deviceToken; - if (!issuedDeviceToken) { - throw new Error("expected issued device token"); - } - expect(approvedPayload?.auth?.role).toBe("node"); - expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); - const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - ); - const issuedOperatorToken = operatorHandoff?.deviceToken; - if (!issuedOperatorToken) { - throw new Error("expected handed-off operator device token"); - } - expect(operatorHandoff?.scopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).toContain("operator.admin"); - - const pendingAfterInitial = await listDevicePairing(); - const pendingForDevice = pendingAfterInitial.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingForDevice).toEqual([]); - wsBootstrap.close(); - - const afterBootstrap = await listDevicePairing(); - expect( - afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), - ).toEqual([]); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["node", "operator"]); - expect(paired?.approvedScopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); - expect(paired?.tokens?.node?.scopes).toEqual([]); - expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); - expect(paired?.tokens?.operator?.scopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - - const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const replay = await connectReq(wsReplay, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(replay.ok).toBe(false); - expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, - ); - wsReplay.close(); - - const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const reconnect = await connectReq(wsReconnect, { - skipDefaultAuth: true, - deviceToken: issuedDeviceToken, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(reconnect.ok).toBe(true); - wsReconnect.close(); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - role: "node", - scopes: [], - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedDeviceToken, - role: "node", - scopes: [], - }), - ).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedOperatorToken, - role: "operator", - scopes: [ - "operator.admin", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], - }), - ).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedOperatorToken, - role: "operator", - scopes: ["operator.admin"], - }), - ).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedOperatorToken, - role: "operator", - scopes: ["operator.pairing"], - }), - ).resolves.toEqual({ ok: true }); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test.each([ - { - name: "Android", - identityPrefix: "openclaw-bootstrap-android-node-", - client: { - id: "openclaw-android", - version: "2026.6.2", - platform: "Android 16", - mode: "node" as const, - deviceFamily: "Android", - }, - }, - { - name: "iPadOS", - identityPrefix: "openclaw-bootstrap-ipados-node-", - client: { - id: "openclaw-ios", - version: "2026.6.2", - platform: "iPadOS 26.3.1", - mode: "node" as const, - deviceFamily: "iPad", - }, - }, - ])( - "qr setup code auto-approves $name clients when mobile metadata matches", - async ({ client, identityPrefix }) => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { identity, initial } = await connectSetupCodeBootstrapNode({ - identityPrefix, - client, - }); - expect(initial.ok).toBe(true); - const approvedPayload = initial.payload as - | { - type?: string; - auth?: { - deviceToken?: string; - role?: string; - scopes?: string[]; - deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; - }; - } - | undefined; - expect(approvedPayload?.type).toBe("hello-ok"); - expect(approvedPayload?.auth?.deviceToken).toBeTruthy(); - expect(approvedPayload?.auth?.role).toBe("node"); - expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); - const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - ); - expect(operatorHandoff?.deviceToken).toBeTruthy(); - expect(operatorHandoff?.scopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).toContain("operator.admin"); - - const pendingAfterInitial = await listDevicePairing(); - expect( - pendingAfterInitial.pending.filter((entry) => entry.deviceId === identity.deviceId), - ).toEqual([]); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["node", "operator"]); - expect(paired?.approvedScopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - }, - ); - - test("limited qr setup keeps the previous bounded operator handoff", async () => { - const { identity, initial } = await connectSetupCodeBootstrapNode({ - identityPrefix: "openclaw-bootstrap-limited-node-", - client: { - id: "openclaw-ios", - version: "2026.7.13", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }, - limited: true, - }); - expect(initial.ok).toBe(true); - const payload = initial.payload as - | { - auth?: { - deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; - }; - } - | undefined; - const operatorHandoff = payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator"); - const operatorToken = operatorHandoff?.deviceToken; - if (!operatorToken) { - throw new Error("expected handed-off limited operator device token"); - } - expect(operatorHandoff?.scopes).toEqual([ - "operator.approvals", - "operator.questions", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - - const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js"); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.approvedScopes).not.toContain("operator.admin"); - expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin"); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: operatorToken, - role: "operator", - scopes: ["operator.admin"], - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: operatorToken, - role: "operator", - scopes: ["operator.pairing"], - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); - }); - - test("full qr setup upgrades an existing limited mobile pairing", async () => { - const identityPrefix = "openclaw-bootstrap-limited-upgrade-node-"; - const client = { - id: "openclaw-ios", - version: "2026.7.13", - platform: "iOS 26.3.1", - mode: "node" as const, - deviceFamily: "iPhone", - }; - const limited = await connectSetupCodeBootstrapNode({ - identityPrefix, - client, - limited: true, - }); - const upgraded = await connectSetupCodeBootstrapNode({ identityPrefix, client }); - expect(upgraded.identity.deviceId).toBe(limited.identity.deviceId); - expect(upgraded.initial.ok).toBe(true); - - const payload = upgraded.initial.payload as - | { - auth?: { - deviceTokens?: Array<{ role?: string; scopes?: string[] }>; - }; - } - | undefined; - expect( - payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes, - ).toContain("operator.admin"); - - const { getPairedDevice } = await import("../infra/device-pairing.js"); - const paired = await getPairedDevice(upgraded.identity.deviceId); - expect(paired?.approvedScopes).toContain("operator.admin"); - expect(paired?.tokens?.operator?.scopes).toContain("operator.admin"); - }); - - test.each([ - { - name: "mobile client id with mismatched platform metadata", - identityPrefix: "openclaw-bootstrap-mobile-spoof-", - client: { - id: "openclaw-android", - version: "2026.6.2", - platform: "iOS 26.3.1", - mode: "node" as const, - deviceFamily: "iPhone", - }, - }, - { - name: "valid non-mobile client id with mobile metadata", - identityPrefix: "openclaw-bootstrap-node-host-spoof-", - client: { - id: "node-host", - version: "2026.6.2", - platform: "Android 16", - mode: "node" as const, - deviceFamily: "Android", - }, - }, - ])( - "requires owner approval for setup-code bootstrap spoof: $name", - async ({ client, identityPrefix }) => { - const { listDevicePairing } = await import("../infra/device-pairing.js"); - const { identity, initial } = await connectSetupCodeBootstrapNode({ - identityPrefix, - client, - }); - expect(initial.ok).toBe(false); - expect(initial.error?.message ?? "").toContain("pairing required"); - expect( - initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined, - ).toMatchObject({ - code: ConnectErrorDetailCodes.PAIRING_REQUIRED, - pauseReconnect: false, - }); - - const pending = (await listDevicePairing()).pending.find( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toMatchObject({ - clientId: client.id, - clientMode: client.mode, - role: "node", - scopes: [], - }); - }, - ); - - test("qr bootstrap retry keeps full operator handoff after paired approval", async () => { - const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = - await import("../infra/device-bootstrap.js"); - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveBootstrapDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-node-retry-", - ); - const client = { - id: "openclaw-ios", - version: "2026.3.30", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }; - - try { - const issued = await issueDeviceBootstrapToken({ - profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); - const pending = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey, - role: "node", - roles: ["node", "operator"], - scopes: [ - "operator.admin", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], - clientId: client.id, - clientMode: client.mode, - displayName: client.id, - platform: client.platform, - deviceFamily: client.deviceFamily, - silent: true, - }); - await approveBootstrapDevicePairing( - pending.request.requestId, - FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, - ); - - const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const retry = await connectReq(wsRetry, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(retry.ok).toBe(true); - const payload = retry.payload as - | { - auth?: { - deviceToken?: string; - deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; - }; - } - | undefined; - expect(payload?.auth?.deviceToken).toBeTruthy(); - const operatorHandoff = payload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - ); - expect(operatorHandoff?.deviceToken).toBeTruthy(); - expect(operatorHandoff?.scopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).toContain("operator.admin"); - wsRetry.close(); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: identity.deviceId, - publicKey, - role: "node", - scopes: [], - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-node-reject-", - ); - const client = { - id: "openclaw-ios", - version: "2026.3.30", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }; - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - }, - }); - const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const initial = await connectReq(wsInitial, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(false); - expect( - initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined, - ).toMatchObject({ - code: ConnectErrorDetailCodes.PAIRING_REQUIRED, - pauseReconnect: false, - }); - wsInitial.close(); - - const pending = (await listDevicePairing()).pending.find( - (entry) => entry.deviceId === identity.deviceId, - ); - if (!pending) { - throw new Error("expected pending bootstrap pairing request"); - } - await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({ - requestId: pending.requestId, - deviceId: identity.deviceId, - }); - - const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const retry = await connectReq(wsRetry, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(retry.ok).toBe(false); - expect((retry.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, - ); - wsRetry.close(); - expect( - (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), - ).toEqual([]); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); - const reconcileModule = await import("./node-connect-reconcile.js"); - const reconcileSpy = vi - .spyOn(reconcileModule, "reconcileNodePairingOnConnect") - .mockRejectedValueOnce(new Error("boom")); - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, client } = await createOperatorIdentityFixture( - "openclaw-bootstrap-reconcile-fail-", - ); - const nodeClient = { - ...client, - id: "openclaw-android", - mode: "node", - }; - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - }, - }); - - const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const initial = await connectReq(wsInitial, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: nodeClient, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(false); - wsInitial.close(); - const pending = (await listDevicePairing()).pending.find( - (entry) => entry.clientId === nodeClient.id, - ); - if (!pending) { - throw new Error("expected pending bootstrap pairing request"); - } - await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] }); - - const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - await expect( - connectReq(wsFail, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: nodeClient, - deviceIdentityPath: identityPath, - timeoutMs: 500, - }), - ).rejects.toThrow(); - // The full agentic shard can saturate the event loop enough that the - // server-side close after a pre-hello failure arrives later than 1s. - await expect(waitForWsClose(wsFail, 5_000)).resolves.toBe(true); - - const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const retry = await connectReq(wsRetry, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: nodeClient, - deviceIdentityPath: identityPath, - }); - expect(retry.ok).toBe(true); - wsRetry.close(); - } finally { - reconcileSpy.mockRestore(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-role-upgrade-", - ); - const client = { - id: "openclaw-ios", - version: "2026.3.30", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }; - - try { - const seededRequest = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - role: "operator", - scopes: ["operator.read"], - clientId: client.id, - clientMode: client.mode, - platform: client.platform, - deviceFamily: client.deviceFamily, - }); - await approveDevicePairing(seededRequest.request.requestId, { - callerScopes: ["operator.read"], - }); - - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - }, - }); - const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const upgrade = await connectReq(wsUpgrade, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(upgrade.ok).toBe(false); - expect(upgrade.error?.message ?? "").toContain("pairing required"); - expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - expect( - (upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason, - ).toBe("role-upgrade"); - expect( - ( - upgrade.error?.details as - | { - requestedRole?: string; - approvedRoles?: string[]; - } - | undefined - )?.requestedRole, - ).toBe("node"); - expect( - ( - upgrade.error?.details as - | { - requestedRole?: string; - approvedRoles?: string[]; - } - | undefined - )?.approvedRoles, - ).toEqual(["operator"]); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toHaveLength(1); - expect(pending[0]?.role).toBe("node"); - expect(pending[0]?.roles).toEqual(["node"]); - const paired = await getPairedDevice(identity.deviceId); - expectArrayIncludes(paired?.roles, ["operator"]); - wsUpgrade.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity, client } = await createOperatorIdentityFixture( - "openclaw-bootstrap-operator-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["operator"], - scopes: ["operator.read"], - }, - }); - const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: ["operator.read"], - client, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(false); - expect(initial.error?.message ?? "").toContain("pairing required"); - expect((initial.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toHaveLength(1); - expect(pending[0]?.role).toBe("operator"); - expectArrayIncludes(pending[0]?.scopes, ["operator.read"]); - expect(await getPairedDevice(identity.deviceId)).toBeNull(); - wsBootstrap.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("silently approves host-authorized control ui owner bootstrap tokens", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = - await import("../infra/device-pairing.js"); - const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { resolveSharedGatewaySessionGeneration } = - await import("./server/ws-shared-generation.js"); - testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-control-ui-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, - }); - const wsBootstrap = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.50", - }); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(true); - const payload = initial.payload as - | { - type?: string; - auth?: { - deviceToken?: string; - recoveryScope?: string; - role?: string; - scopes?: string[]; - }; - } - | undefined; - expect(payload?.type).toBe("hello-ok"); - expect(payload?.auth?.role).toBe("operator"); - expect(payload?.auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); - const deviceToken = payload?.auth?.deviceToken; - const recoveryScope = payload?.auth?.recoveryScope; - if (!deviceToken) { - throw new Error("expected control ui owner device token"); - } - expect(recoveryScope).toMatch(/^[A-Za-z0-9_-]+$/u); - expect((await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false })).ok).toBe(true); - wsBootstrap.close(); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toEqual([]); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["operator"]); - expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); - const wsReload = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.50", - }); - const reload = await connectReq(wsReload, { - skipDefaultAuth: true, - deviceToken, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(reload.ok).toBe(true); - expect( - (reload.payload as { auth?: { recoveryScope?: string } } | undefined)?.auth?.recoveryScope, - ).toBe(recoveryScope); - wsReload.close(); - - const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration({ - mode: "token", - token: "secret", - allowTailscale: false, - }); - if (!sharedGatewaySessionGeneration) { - throw new Error("expected shared gateway session generation"); - } - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: deviceToken, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - requiredSharedGatewaySessionGeneration: sharedGatewaySessionGeneration, - }), - ).resolves.toEqual({ - ok: true, - issuer: { - kind: "shared-gateway-auth", - generation: sharedGatewaySessionGeneration, - }, - }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: deviceToken, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - requiredSharedGatewaySessionGeneration: "rotated-generation", - }), - ).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" }); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("keeps generic control ui bootstrap tokens on the bounded profile", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = - await import("../shared/device-bootstrap-profile.js"); - testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-control-ui-bounded-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["operator"], - scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - purpose: "control-ui", - }, - }); - const wsBootstrap = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.51", - }); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(true); - const auth = ( - initial.payload as - | { - auth?: { - scopes?: string[]; - }; - } - | undefined - )?.auth; - expect(auth?.scopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]); - expect(auth?.scopes).not.toContain("operator.admin"); - expect(auth?.scopes).not.toContain("operator.pairing"); - const adminMutation = await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false }); - expect(adminMutation.ok).toBe(false); - expect(adminMutation.error?.message ?? "").toContain("missing scope"); - wsBootstrap.close(); - - expect( - (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), - ).toEqual([]); - expect((await getPairedDevice(identity.deviceId))?.approvedScopes).toEqual([ - ...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - ]); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("silently upgrades the same control ui key with a host-authorized bootstrap", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { identity, identityPath } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-control-ui-owner-upgrade-", - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - displayName: "control-ui-owner-upgrade", - platform: CONTROL_UI_CLIENT.platform, - }); - const before = await getPairedDevice(identity.deviceId); - const previousToken = before?.tokens?.operator?.token; - if (!previousToken) { - throw new Error("expected limited operator token"); - } - - testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath: secondIdentityPath } = await createOperatorIdentityFixture( - "openclaw-control-ui-owner-upgrade-second-browser-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE, - }); - const wsUpgrade = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.52", - }); - const upgraded = await connectReq(wsUpgrade, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(upgraded.ok).toBe(true); - const auth = ( - upgraded.payload as - | { - auth?: { - deviceToken?: string; - scopes?: string[]; - }; - } - | undefined - )?.auth; - const upgradedToken = auth?.deviceToken; - if (!upgradedToken) { - throw new Error("expected upgraded operator token"); - } - expect(upgradedToken).not.toBe(previousToken); - expect(auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); - expect((await rpcReq(wsUpgrade, "set-heartbeats", { enabled: false })).ok).toBe(true); - wsUpgrade.close(); - - expect( - (await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId), - ).toEqual([]); - const paired = await getPairedDevice(identity.deviceId); - expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]); - expect(paired?.tokens?.operator?.token).toBe(upgradedToken); - - const wsReload = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.52", - }); - const reload = await connectReq(wsReload, { - skipDefaultAuth: true, - deviceToken: upgradedToken, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(reload.ok).toBe(true); - wsReload.close(); - - const wsSecondBrowser = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.53", - }); - const replay = await connectReq(wsSecondBrowser, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: secondIdentityPath, - }); - expect(replay.ok).toBe(false); - expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, - ); - wsSecondBrowser.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("requires pairing for control ui bootstrap token without control-ui purpose", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } = - await import("../shared/device-bootstrap-profile.js"); - testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-control-ui-missing-purpose-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["operator"], - scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - }, - }); - const wsBootstrap = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.51", - }); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: ["operator.read"], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(false); - expect(initial.error?.message ?? "").toContain("pairing required"); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toHaveLength(1); - expect(pending[0]?.role).toBe("operator"); - expect(await getPairedDevice(identity.deviceId)).toBeNull(); - wsBootstrap.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("requires pairing for control ui node bootstrap tokens", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] }; - const { server, port, prevToken } = await startControlUiServer("secret"); - - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-control-ui-node-profile-", - ); - - try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - purpose: "control-ui", - }, - }); - const wsBootstrap = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.52", - }); - const initial = await connectReq(wsBootstrap, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: CONTROL_UI_CLIENT, - deviceIdentityPath: identityPath, - }); - expect(initial.ok).toBe(false); - expect(initial.error?.message ?? "").toContain("pairing required"); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toHaveLength(1); - expect(pending[0]?.role).toBe("node"); - expect(await getPairedDevice(identity.deviceId)).toBeNull(); - wsBootstrap.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity, client } = - await createOperatorIdentityFixture("openclaw-device-scope-"); - const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { - const socket = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { host: "gateway.example" }, - }); - const challengePromise = onceMessage( - socket, - (o) => o.type === "event" && o.event === "connect.challenge", - ); - await new Promise((resolve) => { - socket.once("open", resolve); - }); - const challenge = await challengePromise; - const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - const result = await connectReq(socket, { - token: "secret", - role, - scopes, - client, - device: await buildSignedDeviceForIdentity({ - identityPath, - client, - role, - scopes, - nonce: String(nonce), - }), - }); - socket.close(); - return result; - }; - - const nodeConnect = await connectWithNonce("node", []); - expect(nodeConnect.ok).toBe(true); - - const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]); - expect(operatorConnect.ok).toBe(false); - expect(operatorConnect.error?.message ?? "").toContain("pairing required"); - - const pending = await listDevicePairing(); - const pendingForTestDevice = pending.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingForTestDevice).toHaveLength(1); - expectArrayIncludes(pendingForTestDevice[0]?.scopes, ["operator.read", "operator.write"]); - - const paired = await getPairedDevice(identity.deviceId); - expectArrayIncludes(paired?.roles, ["node", "operator"]); - expectArrayIncludes(paired?.approvedScopes, ["operator.read", "operator.write"]); - - const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]); - expect(approvedOperatorConnect.ok).toBe(true); - - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("allows operator.read connect when device is paired with operator.admin", async () => { - const { listDevicePairing } = await import("../infra/device-pairing.js"); - const { identityPath, identity } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-device-admin-superset-", - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - displayName: "operator-admin-superset", - platform: TEST_OPERATOR_CLIENT.platform, - scopes: ["operator.admin"], - }); - - const { server, port, prevToken } = await startControlUiServer("secret"); - - const ws2 = await openWs(port); - const nonce2 = await readConnectChallengeNonce(ws2); - const res = await connectReq(ws2, { - token: "secret", - scopes: ["operator.read"], - client: TEST_OPERATOR_CLIENT, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: TEST_OPERATOR_CLIENT, - scopes: ["operator.read"], - nonce: nonce2, - }), - }); - expect(res.ok).toBe(true); - ws2.close(); - - const list = await listDevicePairing(); - expect(list.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); - - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("allows operator shared auth with legacy paired metadata", async () => { - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-device-legacy-meta-", - ); - const deviceId = identity.deviceId; - const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); - const pending = await requestDevicePairing({ - deviceId, - publicKey, - role: "operator", - scopes: ["operator.read"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - displayName: "legacy-test", - platform: "test", - }); - await approveDevicePairing(pending.request.requestId, { - callerScopes: pending.request.scopes ?? ["operator.admin"], - }); - - await stripPairedMetadataRolesAndScopes(deviceId); - - const { server, port, prevToken } = await startControlUiServer("secret"); - let ws2: WebSocket | undefined; - try { - const wsReconnect = await openWs(port); - ws2 = wsReconnect; - const reconnectNonce = await readConnectChallengeNonce(wsReconnect); - const reconnect = await connectReq(wsReconnect, { - token: "secret", - scopes: ["operator.read"], - client: TEST_OPERATOR_CLIENT, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: TEST_OPERATOR_CLIENT, - scopes: ["operator.read"], - nonce: reconnectNonce, - }), - }); - expect(reconnect.ok).toBe(true); - - const repaired = await getPairedDevice(deviceId); - expect(repaired?.role).toBe("operator"); - expect(repaired?.approvedScopes ?? []).toContain("operator.read"); - expect(repaired?.tokens?.operator?.scopes ?? []).toContain("operator.read"); - const list = await listDevicePairing(); - expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - ws2?.close(); - } - }); - - test("requires approval for local scope upgrades even when paired metadata is legacy-shaped", async () => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { identity, identityPath } = await seedApprovedOperatorReadPairing({ - identityPrefix: "openclaw-device-legacy-", - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - displayName: "legacy-upgrade-test", - platform: "test", - }); - - await stripPairedMetadataRolesAndScopes(identity.deviceId); - - const { server, port, prevToken } = await startControlUiServer("secret"); - let ws2: WebSocket | undefined; - try { - const client = { ...TEST_OPERATOR_CLIENT }; - - const wsUpgrade = await openWs(port); - ws2 = wsUpgrade; - const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); - const upgraded = await connectReq(wsUpgrade, { - token: "secret", - scopes: ["operator.admin"], - client, - device: await buildSignedDeviceForIdentity({ - identityPath, - client, - scopes: ["operator.admin"], - nonce: upgradeNonce, - }), - }); - expect(upgraded.ok).toBe(false); - expect(upgraded.error?.message ?? "").toContain("pairing required"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.reason, - ).toBe("scope-upgrade"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.requestedRole, - ).toBe("operator"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.requestedScopes, - ).toEqual(["operator.admin"]); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.approvedScopes, - ).toEqual(["operator.read"]); - wsUpgrade.close(); - - const pendingUpgrade = (await listDevicePairing()).pending.find( - (entry) => entry.deviceId === identity.deviceId, - ); - if (!pendingUpgrade) { - throw new Error(`expected pending upgrade for device ${identity.deviceId}`); - } - expectArrayIncludes(pendingUpgrade.scopes, ["operator.admin"]); - const repaired = await getPairedDevice(identity.deviceId); - expect(repaired?.role).toBe("operator"); - expectArrayIncludes(repaired?.approvedScopes, ["operator.read"]); - } finally { - ws2?.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("rejects revoked device token", async () => { - const { revokeDeviceToken } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - const { identity, deviceToken, deviceIdentityPath } = - await ensurePairedDeviceTokenForCurrentIdentity(ws); - - await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" }); - - ws.close(); - - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { token: deviceToken, deviceIdentityPath }); - expect(res2.ok).toBe(false); - - ws2.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - }); - - test("allows gateway backend loopback shared-auth connections without device pairing", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - const sockets = [ws]; - try { - const backendCases: Array<{ - name: string; - headers?: Record; - socket?: WebSocket; - }> = [ - { name: "default host", socket: ws }, - { name: "remote-looking host", headers: { host: "gateway.example" } }, - { name: "private host", headers: { host: "172.17.0.2:18789" } }, - ]; - - for (const backendCase of backendCases) { - const socket = backendCase.socket ?? (await openWs(port, backendCase.headers)); - if (!backendCase.socket) { - sockets.push(socket); - } - const backendConnect = await connectReq(socket, { - token: "secret", - client: BACKEND_GATEWAY_CLIENT, - }); - expect(backendConnect.ok, backendCase.name).toBe(true); - } - } finally { - for (const socket of sockets) { - socket.close(); - } - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); - try { - const { identity, identityPath } = - await createOperatorIdentityFixture("openclaw-cli-docker-"); - const nonce = await readConnectChallengeNonce(wsDockerCli); - const dockerCli = await connectReq(wsDockerCli, { - token: "secret", - client: { - id: GATEWAY_CLIENT_NAMES.CLI, - version: "1.0.0", - platform: "linux", - mode: GATEWAY_CLIENT_MODES.CLI, - }, - device: await buildSignedDeviceForIdentity({ - identityPath, - client: { - id: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, - }, - scopes: ["operator.admin"], - nonce, - }), - }); - expect(dockerCli.ok).toBe(true); - const pending = await listDevicePairing(); - expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); - if (!(await getPairedDevice(identity.deviceId))) { - throw new Error(`expected paired device ${identity.deviceId}`); - } - } finally { - wsDockerCli.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("allows CLI clients on loopback even when the host header is not private-or-loopback", async () => { - const { server, port, prevToken } = await startControlUiServer("secret"); - const wsRemoteLike = await openWs(port, { host: "gateway.example" }); - try { - const remoteCli = await connectReq(wsRemoteLike, { - token: "secret", - client: { - id: GATEWAY_CLIENT_NAMES.CLI, - version: "1.0.0", - platform: "linux", - mode: GATEWAY_CLIENT_MODES.CLI, - }, - }); - expect(remoteCli.ok).toBe(true); - } finally { - wsRemoteLike.close(); - await server.close(); - restoreGatewayToken(prevToken); - } - }); -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server.auth.control-ui.test.ts b/src/gateway/server.auth.control-ui.test.ts index ce1f3f3e9cd6..f7a0dc7a7eac 100644 --- a/src/gateway/server.auth.control-ui.test.ts +++ b/src/gateway/server.auth.control-ui.test.ts @@ -2,7 +2,12 @@ * Gateway Control UI auth pairing tests. */ import { describe } from "vitest"; -import { registerControlUiAndPairingSuite } from "./server.auth.control-ui.suite.js"; +import { registerControlUiBootstrapLifecycleSuite } from "./server.auth.control-ui.bootstrap-lifecycle.suite.js"; +import { registerControlUiDeviceTokenSuite } from "./server.auth.control-ui.device-token.suite.js"; +import { registerControlUiMobileBootstrapSuite } from "./server.auth.control-ui.mobile-bootstrap.suite.js"; +import { registerControlUiOwnerBootstrapSuite } from "./server.auth.control-ui.owner-bootstrap.suite.js"; +import { registerControlUiPairingSuite } from "./server.auth.control-ui.pairing.suite.js"; +import { registerControlUiTrustedProxySuite } from "./server.auth.control-ui.trusted-proxy.suite.js"; import { installGatewayTestHooks } from "./server.auth.test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -15,5 +20,10 @@ await Promise.all([ ]); describe("gateway server auth/connect", () => { - registerControlUiAndPairingSuite(); + registerControlUiTrustedProxySuite(); + registerControlUiDeviceTokenSuite(); + registerControlUiPairingSuite(); + registerControlUiMobileBootstrapSuite(); + registerControlUiBootstrapLifecycleSuite(); + registerControlUiOwnerBootstrapSuite(); }); diff --git a/src/gateway/server.auth.control-ui.trusted-proxy.suite.ts b/src/gateway/server.auth.control-ui.trusted-proxy.suite.ts new file mode 100644 index 000000000000..440192d40cdf --- /dev/null +++ b/src/gateway/server.auth.control-ui.trusted-proxy.suite.ts @@ -0,0 +1,287 @@ +import { beforeAll, expect, test } from "vitest"; +import { + createOperatorIdentityFixture, + seedApprovedOperatorReadPairing, + withControlUiGatewayServer, +} from "./server.auth.control-ui.fixtures.test-support.js"; +import { + connectReq, + configureTrustedProxyControlUiAuth, + CONTROL_UI_CLIENT, + ConnectErrorDetailCodes, + createSignedDevice, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + openWs, + readConnectChallengeNonce, + rpcReq, + testState, + TRUSTED_PROXY_CONTROL_UI_HEADERS, +} from "./server.auth.test-helpers.js"; + +export function registerControlUiTrustedProxySuite(): void { + const trustedProxyControlUiCases: Array<{ + name: string; + role: "operator" | "node"; + withUnpairedNodeDevice: boolean; + expectedOk: boolean; + expectedErrorSubstring?: string; + expectedErrorCode?: string; + }> = [ + { + name: "rejects loopback trusted-proxy control ui operator without device identity", + role: "operator", + withUnpairedNodeDevice: false, + expectedOk: false, + expectedErrorSubstring: "control ui requires device identity", + expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + }, + { + name: "rejects trusted-proxy control ui node role without device identity", + role: "node", + withUnpairedNodeDevice: false, + expectedOk: false, + expectedErrorSubstring: "control ui requires device identity", + expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + }, + { + name: "rejects loopback trusted-proxy control ui node role before pairing", + role: "node", + withUnpairedNodeDevice: true, + expectedOk: false, + expectedErrorSubstring: "unauthorized", + }, + ]; + const trustedProxyControlUiResults = new Map>>(); + + const withTrustedProxyControlUiServer = async ( + run: (port: number) => Promise, + ): Promise => { + const { replaceConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = undefined; + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + allowedOrigins: ["https://localhost"], + }; + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { allowedOrigins: ["https://localhost"] }, + }, + }, + afterWrite: { mode: "auto" }, + }); + await withControlUiGatewayServer(async ({ port }) => await run(port)); + }; + + beforeAll(async () => { + await configureTrustedProxyControlUiAuth(); + await withControlUiGatewayServer(async ({ port }) => { + for (const tc of trustedProxyControlUiCases) { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const scopes = tc.withUnpairedNodeDevice ? [] : undefined; + let device: Awaited>["device"] | null = null; + if (tc.withUnpairedNodeDevice) { + const challengeNonce = await readConnectChallengeNonce(ws); + if (!challengeNonce) { + throw new Error(`expected connect challenge nonce for ${tc.name}`); + } + ({ device } = await createSignedDevice({ + token: null, + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + nonce: challengeNonce, + })); + } + trustedProxyControlUiResults.set( + tc.name, + await connectReq(ws, { + skipDefaultAuth: true, + role: tc.role, + scopes, + device, + client: { ...CONTROL_UI_CLIENT }, + }), + ); + } finally { + ws.close(); + } + } + }); + }); + + test.each(trustedProxyControlUiCases)("$name", (tc) => { + const res = trustedProxyControlUiResults.get(tc.name); + if (!res) { + throw new Error(`missing trusted-proxy result for ${tc.name}`); + } + expect(res.ok, tc.name).toBe(tc.expectedOk); + if (!tc.expectedOk) { + if (tc.expectedErrorSubstring) { + expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring); + } + if (tc.expectedErrorCode) { + expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe( + tc.expectedErrorCode, + ); + } + } + }); + + test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => { + await configureTrustedProxyControlUiAuth(); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { rejectDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { identity } = await createOperatorIdentityFixture("openclaw-control-ui-trusted-proxy-"); + const pendingRequest = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "operator", + scopes: ["operator.admin"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + }); + await withControlUiGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin"], + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("control ui requires device identity"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ); + } finally { + ws.close(); + await rejectDevicePairing(pendingRequest.request.requestId); + } + }); + }); + + test("requires pairing for trusted-proxy control ui device identity", async () => { + await withTrustedProxyControlUiServer(async (port) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const challengeNonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token: null, + role: "operator", + scopes: ["operator.admin", "operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + nonce: challengeNonce, + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin", "operator.read"], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + } finally { + ws.close(); + } + }); + }); + + test("clears trusted-proxy control ui scopes without device identity", async () => { + await withTrustedProxyControlUiServer(async (port) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin", "operator.read"], + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + auth?: { scopes?: string[]; deviceToken?: string }; + } + | undefined; + expect(payload?.auth?.scopes).toEqual([]); + expect(payload?.auth?.deviceToken).toBeUndefined(); + + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message ?? "").toContain("missing scope"); + } finally { + ws.close(); + } + }); + }); + + test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => { + await withTrustedProxyControlUiServer(async (port) => { + const seeded = await seedApprovedOperatorReadPairing({ + identityPrefix: "openclaw-control-ui-trusted-proxy-bounded-", + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + displayName: "Control UI", + platform: "web", + scopes: ["operator.admin", "operator.read"], + }); + const ws = await openWs(port, { + ...TRUSTED_PROXY_CONTROL_UI_HEADERS, + "x-openclaw-scopes": "operator.read", + }); + try { + const challengeNonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token: null, + role: "operator", + scopes: ["operator.admin", "operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + identityPath: seeded.identityPath, + nonce: challengeNonce, + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin", "operator.read"], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + auth?: { scopes?: string[]; deviceToken?: string }; + } + | undefined; + expect(payload?.auth?.scopes).toEqual(["operator.read"]); + expect(payload?.auth?.deviceToken).toBeUndefined(); + + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(false); + expect(admin.error?.message ?? "").toContain("missing scope"); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + } finally { + ws.close(); + } + }); + }); +} diff --git a/test/scripts/changed-path-facts.test.ts b/test/scripts/changed-path-facts.test.ts index de19ff1c64bf..80516989ebf7 100644 --- a/test/scripts/changed-path-facts.test.ts +++ b/test/scripts/changed-path-facts.test.ts @@ -41,7 +41,9 @@ describe("changed path facts", () => { isTestOnly: true, isNativeOnly: false, }); - expect(getChangedPathFacts("src/gateway/server.auth.control-ui.suite.ts")).toMatchObject({ + expect( + getChangedPathFacts("src/gateway/server.auth.control-ui.trusted-proxy.suite.ts"), + ).toMatchObject({ surface: "source", isChangedLaneTest: true, isTestOnly: true, diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index efbe456b9b20..b99e9e6a966d 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -78,9 +78,11 @@ describe("CI changed Node test plan", () => { expect(hasBuildArtifactAffectingChange(["src/agents/foo.test.ts", "test/helpers/x.ts"])).toBe( false, ); - expect(hasBuildArtifactAffectingChange(["src/gateway/server.auth.control-ui.suite.ts"])).toBe( - false, - ); + expect( + hasBuildArtifactAffectingChange([ + "src/gateway/server.auth.control-ui.trusted-proxy.suite.ts", + ]), + ).toBe(false); expect(hasBuildArtifactAffectingChange(["src/agents/foo.ts"])).toBe(true); // Build-input classification: only sources and the build pipeline can // change dist bytes; repo scripts, workflows, and qa scenarios cannot.