diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 07194620ff6..22c0e472508 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -1049,14 +1049,14 @@ describe("gateway server auth/connect", () => { } }); - test("requires pairing for scope upgrades", async () => { + test("skips pairing for operator scope upgrades when shared token auth is valid", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { buildDeviceAuthPayload } = await import("./device-auth.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); - const { getPairedDevice } = await import("../infra/device-pairing.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-")); const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); @@ -1093,12 +1093,10 @@ describe("gateway server auth/connect", () => { client, device: buildDevice(["operator.read"], initialNonce), }); - if (!initial.ok) { - await approvePendingPairingIfNeeded(); - } - - let paired = await getPairedDevice(identity.deviceId); - expect(paired?.scopes).toContain("operator.read"); + expect(initial.ok).toBe(true); + let pairing = await listDevicePairing(); + expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); ws.close(); @@ -1110,30 +1108,16 @@ describe("gateway server auth/connect", () => { client, device: buildDevice(["operator.admin"], nonce2), }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - - await approvePendingPairingIfNeeded(); + expect(res.ok).toBe(true); + pairing = await listDevicePairing(); + expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); ws2.close(); - - const ws3 = await openWs(port); - const nonce3 = await readConnectChallengeNonce(ws3); - const approved = await connectReq(ws3, { - token: "secret", - scopes: ["operator.admin"], - client, - device: buildDevice(["operator.admin"], nonce3), - }); - expect(approved.ok).toBe(true); - paired = await getPairedDevice(identity.deviceId); - expect(paired?.scopes).toContain("operator.admin"); - - ws3.close(); await server.close(); restoreGatewayToken(prevToken); }); - test("single approval captures pending node and operator roles for the same device", async () => { + test("still requires node pairing while operator shared auth succeeds for the same device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1200,26 +1184,23 @@ describe("gateway server auth/connect", () => { expect(nodeConnect.error?.message ?? "").toContain("pairing required"); const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]); - expect(operatorConnect.ok).toBe(false); - expect(operatorConnect.error?.message ?? "").toContain("pairing required"); + expect(operatorConnect.ok).toBe(true); const pending = await listDevicePairing(); const pendingForTestDevice = pending.pending.filter( (entry) => entry.deviceId === identity.deviceId, ); expect(pendingForTestDevice).toHaveLength(1); - expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"])); - expect(pendingForTestDevice[0]?.scopes).toEqual( - expect.arrayContaining(["operator.read", "operator.write"]), - ); + expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node"])); + expect(pendingForTestDevice[0]?.roles ?? []).not.toContain("operator"); if (!pendingForTestDevice[0]) { throw new Error("expected pending pairing request"); } await approveDevicePairing(pendingForTestDevice[0].requestId); const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); - expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"])); + expect(paired?.roles).toEqual(expect.arrayContaining(["node"])); + expect(paired?.roles ?? []).not.toContain("operator"); const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]); expect(approvedOperatorConnect.ok).toBe(true); @@ -1301,7 +1282,7 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); - test("allows legacy paired devices missing role/scope metadata", async () => { + test("allows operator shared auth with legacy paired metadata", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1310,10 +1291,34 @@ describe("gateway server auth/connect", () => { await import("../infra/device-identity.js"); const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); const { writeJsonAtomic } = await import("../infra/json-files.js"); - const { getPairedDevice } = await import("../infra/device-pairing.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-")); const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); 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); + + const { pairedPath } = resolvePairingPaths(undefined, "devices"); + const paired = (await readJsonFile>>(pairedPath)) ?? {}; + const legacy = paired[deviceId]; + if (!legacy) { + throw new Error(`Expected paired metadata for deviceId=${deviceId}`); + } + delete legacy.roles; + delete legacy.scopes; + await writeJsonAtomic(pairedPath, paired); + const buildDevice = (nonce: string) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ @@ -1337,32 +1342,6 @@ describe("gateway server auth/connect", () => { const { server, ws, port, prevToken } = await startServerWithClient("secret"); let ws2: WebSocket | undefined; try { - const initialNonce = await readConnectChallengeNonce(ws); - const initial = await connectReq(ws, { - token: "secret", - scopes: ["operator.read"], - client: TEST_OPERATOR_CLIENT, - device: buildDevice(initialNonce), - }); - if (!initial.ok) { - await approvePendingPairingIfNeeded(); - } - - const initialPaired = await getPairedDevice(deviceId); - expect(initialPaired?.roles).toContain("operator"); - expect(initialPaired?.scopes).toContain("operator.read"); - - const { pairedPath } = resolvePairingPaths(undefined, "devices"); - const paired = - (await readJsonFile>>(pairedPath)) ?? {}; - const legacy = paired[deviceId]; - if (!legacy) { - throw new Error(`Expected paired metadata for deviceId=${deviceId}`); - } - - delete legacy.roles; - delete legacy.scopes; - await writeJsonAtomic(pairedPath, paired); ws.close(); const wsReconnect = await openWs(port); @@ -1377,8 +1356,10 @@ describe("gateway server auth/connect", () => { expect(reconnect.ok).toBe(true); const repaired = await getPairedDevice(deviceId); - expect(repaired?.roles).toContain("operator"); - expect(repaired?.scopes).toContain("operator.read"); + expect(repaired?.roles).toBeUndefined(); + expect(repaired?.scopes).toBeUndefined(); + const list = await listDevicePairing(); + expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]); } finally { await server.close(); restoreGatewayToken(prevToken); @@ -1387,7 +1368,7 @@ describe("gateway server auth/connect", () => { } }); - test("rejects scope escalation from legacy paired metadata", async () => { + test("allows shared-auth scope escalation even when paired metadata is legacy-shaped", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1396,15 +1377,39 @@ describe("gateway server auth/connect", () => { const { buildDeviceAuthPayload } = await import("./device-auth.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing } = + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } = await import("../utils/message-channel.js"); + const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-")); + const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const seeded = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: devicePublicKey, + role: "operator", + scopes: ["operator.read"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + displayName: "legacy-upgrade-test", + platform: "test", + }); + await approveDevicePairing(seeded.request.requestId); + + const { pairedPath } = resolvePairingPaths(undefined, "devices"); + const paired = (await readJsonFile>>(pairedPath)) ?? {}; + const legacy = paired[identity.deviceId]; + expect(legacy).toBeTruthy(); + if (!legacy) { + throw new Error(`Expected paired metadata for deviceId=${identity.deviceId}`); + } + delete legacy.roles; + delete legacy.scopes; + await writeJsonAtomic(pairedPath, paired); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); let ws2: WebSocket | undefined; try { - const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); const client = { id: GATEWAY_CLIENT_NAMES.TEST, version: "1.0.0", @@ -1432,35 +1437,8 @@ describe("gateway server auth/connect", () => { }; }; - const initialNonce = await readConnectChallengeNonce(ws); - const initial = await connectReq(ws, { - token: "secret", - scopes: ["operator.read"], - client, - device: buildDevice(["operator.read"], initialNonce), - }); - if (!initial.ok) { - const list = await listDevicePairing(); - const pending = list.pending.at(0); - expect(pending?.requestId).toBeDefined(); - if (pending?.requestId) { - await approveDevicePairing(pending.requestId); - } - } ws.close(); - const { pairedPath } = resolvePairingPaths(undefined, "devices"); - const paired = - (await readJsonFile>>(pairedPath)) ?? {}; - const legacy = paired[identity.deviceId]; - expect(legacy).toBeTruthy(); - if (!legacy) { - throw new Error(`Expected paired metadata for deviceId=${identity.deviceId}`); - } - delete legacy.roles; - delete legacy.scopes; - await writeJsonAtomic(pairedPath, paired); - const wsUpgrade = await openWs(port); ws2 = wsUpgrade; const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); @@ -1470,15 +1448,13 @@ describe("gateway server auth/connect", () => { client, device: buildDevice(["operator.admin"], upgradeNonce), }); - expect(upgraded.ok).toBe(false); - expect(upgraded.error?.message ?? "").toContain("pairing required"); + expect(upgraded.ok).toBe(true); wsUpgrade.close(); const pendingUpgrade = (await listDevicePairing()).pending.find( (entry) => entry.deviceId === identity.deviceId, ); - expect(pendingUpgrade?.requestId).toBeDefined(); - expect(pendingUpgrade?.scopes).toContain("operator.admin"); + expect(pendingUpgrade).toBeUndefined(); const repaired = await getPairedDevice(identity.deviceId); expect(repaired?.role).toBe("operator"); expect(repaired?.roles).toBeUndefined(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 6b9ed0ad979..e59d852fc7d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -542,7 +542,13 @@ export function attachGatewayWsMessageHandler(params: { return; } - const skipPairing = shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk); + // Shared token/password auth is already gateway-level trust for operator clients. + // In that case, don't force device pairing on first connect. + const skipPairingForOperatorSharedAuth = + role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; + const skipPairing = + shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) || + skipPairingForOperatorSharedAuth; if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) {