diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts new file mode 100644 index 00000000000..b5927389c4d --- /dev/null +++ b/src/gateway/probe.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +const gatewayClientState = vi.hoisted(() => ({ + options: null as Record | null, +})); + +class MockGatewayClient { + private readonly opts: Record; + + constructor(opts: Record) { + this.opts = opts; + gatewayClientState.options = opts; + } + + start(): void { + void Promise.resolve() + .then(async () => { + const onHelloOk = this.opts.onHelloOk; + if (typeof onHelloOk === "function") { + await onHelloOk(); + } + }) + .catch(() => {}); + } + + stop(): void {} + + async request(method: string): Promise { + if (method === "system-presence") { + return []; + } + return {}; + } +} + +vi.mock("./client.js", () => ({ + GatewayClient: MockGatewayClient, +})); + +const { probeGateway } = await import("./probe.js"); + +describe("probeGateway", () => { + it("connects with operator.read scope", async () => { + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + }); + + expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 3dbba982dd7..0521e84d9c8 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -3,6 +3,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; +import { READ_SCOPE } from "./method-scopes.js"; export type GatewayProbeAuth = { token?: string; @@ -54,6 +55,7 @@ export async function probeGateway(opts: { url: opts.url, token: opts.auth?.token, password: opts.auth?.password, + scopes: [READ_SCOPE], clientName: GATEWAY_CLIENT_NAMES.CLI, clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 16415d0b71e..93854341d61 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -948,6 +948,71 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("allows operator.read connect when device is paired with operator.admin", async () => { + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const { 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")); + const client = { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }; + const buildDevice = (scopes: string[]) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: client.id, + clientMode: client.mode, + role: "operator", + scopes, + signedAtMs, + token: "secret", + }); + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + }; + + const initial = await connectReq(ws, { + token: "secret", + scopes: ["operator.admin"], + client, + device: buildDevice(["operator.admin"]), + }); + if (!initial.ok) { + await approvePendingPairingIfNeeded(); + } + + ws.close(); + + const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws2.once("open", resolve)); + const res = await connectReq(ws2, { + token: "secret", + scopes: ["operator.read"], + client, + device: buildDevice(["operator.read"]), + }); + expect(res.ok).toBe(true); + ws2.close(); + + const list = await listDevicePairing(); + expect(list.pending).toEqual([]); + + await server.close(); + restoreGatewayToken(prevToken); + }); + test("allows legacy paired devices missing role/scope metadata", async () => { const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); const { writeJsonAtomic } = await import("../infra/json-files.js"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 43cdd94e164..e265039ff6a 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -21,6 +21,7 @@ import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; +import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; import { @@ -743,9 +744,12 @@ export function attachGatewayWsMessageHandler(params: { return; } } else { - const allowedScopes = new Set(pairedScopes); - const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); - if (missingScope) { + const scopesAllowed = roleScopesAllow({ + role, + requestedScopes: scopes, + allowedScopes: pairedScopes, + }); + if (!scopesAllowed) { logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); const ok = await requirePairing("scope-upgrade"); if (!ok) { diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index cb9bd0d2dd5..9620da2f76d 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -114,6 +114,31 @@ describe("device pairing tokens", () => { expect(mismatch.reason).toBe("token-mismatch"); }); + test("accepts operator.read requests with an operator.admin token scope", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedOperatorDevice(baseDir, ["operator.admin"]); + const paired = await getPairedDevice("device-1", baseDir); + const token = requireToken(paired?.tokens?.operator?.token); + + const readOk = await verifyDeviceToken({ + deviceId: "device-1", + token, + role: "operator", + scopes: ["operator.read"], + baseDir, + }); + expect(readOk.ok).toBe(true); + + const writeMismatch = await verifyDeviceToken({ + deviceId: "device-1", + token, + role: "operator", + scopes: ["operator.write"], + baseDir, + }); + expect(writeMismatch).toEqual({ ok: false, reason: "scope-mismatch" }); + }); + test("treats multibyte same-length token input as mismatch without throwing", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 122463f6e63..c776f9bf15d 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { createAsyncLock, pruneExpiredPending, @@ -152,17 +153,6 @@ function mergeScopes(...items: Array): string[] | undefine return [...scopes]; } -function scopesAllow(requested: string[], allowed: string[]): boolean { - if (requested.length === 0) { - return true; - } - if (allowed.length === 0) { - return false; - } - const allowedSet = new Set(allowed); - return requested.every((scope) => allowedSet.has(scope)); -} - function newToken() { return generatePairingToken(); } @@ -411,7 +401,7 @@ export async function verifyDeviceToken(params: { return { ok: false, reason: "token-mismatch" }; } const requestedScopes = normalizeDeviceAuthScopes(params.scopes); - if (!scopesAllow(requestedScopes, entry.scopes)) { + if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) { return { ok: false, reason: "scope-mismatch" }; } entry.lastUsedAtMs = Date.now(); @@ -442,7 +432,7 @@ export async function ensureDeviceToken(params: { } const { device, role, tokens, existing } = context; if (existing && !existing.revokedAtMs) { - if (scopesAllow(requestedScopes, existing.scopes)) { + if (roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })) { return existing; } } diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts new file mode 100644 index 00000000000..ae8645d6bea --- /dev/null +++ b/src/shared/operator-scope-compat.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { roleScopesAllow } from "./operator-scope-compat.js"; + +describe("roleScopesAllow", () => { + it("treats operator.read as satisfied by read/write/admin scopes", () => { + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.read"], + allowedScopes: ["operator.read"], + }), + ).toBe(true); + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.read"], + allowedScopes: ["operator.write"], + }), + ).toBe(true); + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.read"], + allowedScopes: ["operator.admin"], + }), + ).toBe(true); + }); + + it("keeps non-read operator scopes explicit", () => { + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.write"], + allowedScopes: ["operator.admin"], + }), + ).toBe(false); + }); + + it("uses strict matching for non-operator roles", () => { + expect( + roleScopesAllow({ + role: "node", + requestedScopes: ["system.run"], + allowedScopes: ["operator.admin", "system.run"], + }), + ).toBe(true); + expect( + roleScopesAllow({ + role: "node", + requestedScopes: ["system.run"], + allowedScopes: ["operator.admin"], + }), + ).toBe(false); + }); +}); diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts new file mode 100644 index 00000000000..be82117f0a6 --- /dev/null +++ b/src/shared/operator-scope-compat.ts @@ -0,0 +1,46 @@ +const OPERATOR_ROLE = "operator"; +const OPERATOR_ADMIN_SCOPE = "operator.admin"; +const OPERATOR_READ_SCOPE = "operator.read"; +const OPERATOR_WRITE_SCOPE = "operator.write"; + +function normalizeScopeList(scopes: readonly string[]): string[] { + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) { + out.add(trimmed); + } + } + return [...out]; +} + +function operatorScopeSatisfied(requestedScope: string, granted: Set): boolean { + if (requestedScope === OPERATOR_READ_SCOPE) { + return ( + granted.has(OPERATOR_READ_SCOPE) || + granted.has(OPERATOR_WRITE_SCOPE) || + granted.has(OPERATOR_ADMIN_SCOPE) + ); + } + return granted.has(requestedScope); +} + +export function roleScopesAllow(params: { + role: string; + requestedScopes: readonly string[]; + allowedScopes: readonly string[]; +}): boolean { + const requested = normalizeScopeList(params.requestedScopes); + if (requested.length === 0) { + return true; + } + const allowed = normalizeScopeList(params.allowedScopes); + if (allowed.length === 0) { + return false; + } + const allowedSet = new Set(allowed); + if (params.role.trim() !== OPERATOR_ROLE) { + return requested.every((scope) => allowedSet.has(scope)); + } + return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet)); +}