mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:01:22 +00:00
Gateway: align pairing scope checks for read access
This commit is contained in:
53
src/gateway/probe.test.ts
Normal file
53
src/gateway/probe.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const gatewayClientState = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
class MockGatewayClient {
|
||||
private readonly opts: Record<string, unknown>;
|
||||
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
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<unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>((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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user