Skip to content

Commit 07a7655

Browse files
committed
test(mcp): bind channel non-owner gateway client
1 parent 5c4b639 commit 07a7655

2 files changed

Lines changed: 198 additions & 8 deletions

File tree

test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
33
import { setTimeout as delay } from "node:timers/promises";
44
import {
55
assert,
6+
assertGatewayScopes,
67
ClaudeChannelNotificationSchema,
78
ClaudePermissionNotificationSchema,
89
connectGateway,
@@ -103,6 +104,10 @@ async function main() {
103104
assert(gatewayToken, "missing GW_TOKEN");
104105

105106
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
107+
assertGatewayScopes(gateway, {
108+
include: ["operator.admin", "operator.pairing", "operator.write"],
109+
label: "owner gateway",
110+
});
106111
let nonOwnerGateway: GatewayRpcClient | undefined;
107112
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
108113
const mcpTempState = createMcpClientTempState({ gatewayToken });
@@ -367,6 +372,19 @@ async function main() {
367372
url: gatewayUrl,
368373
token: gatewayToken,
369374
scopes: ["operator.read", "operator.write"],
375+
client: {
376+
id: "test",
377+
displayName: "docker-mcp-channels-non-owner",
378+
version: "1.0.0",
379+
platform: process.platform,
380+
mode: "test",
381+
},
382+
bindFreshDevice: true,
383+
});
384+
assertGatewayScopes(nonOwnerGateway, {
385+
include: ["operator.read", "operator.write"],
386+
exclude: ["operator.admin", "operator.pairing"],
387+
label: "non-owner gateway",
370388
});
371389
await nonOwnerGateway.request("chat.send", {
372390
sessionKey: "agent:main:main",

test/e2e/qa-lab/runtime/mcp-channels.fixture.ts

Lines changed: 180 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Shared MCP-channel QA/Docker E2E fixture helpers.
22
// The mounted test harness imports packaged dist modules so bridge assertions run
33
// against the OpenClaw npm tarball installed in the functional image.
4+
import crypto from "node:crypto";
45
import process from "node:process";
56
import { setTimeout as delay } from "node:timers/promises";
67
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -40,11 +41,26 @@ export const ClaudePermissionNotificationSchema = z.object({
4041
export type ClaudeChannelNotification = z.infer<typeof ClaudeChannelNotificationSchema>["params"];
4142

4243
export type GatewayRpcClient = {
44+
auth: GatewayConnectAuth;
4345
request<T>(method: string, params?: unknown, opts?: { timeoutMs?: number }): Promise<T>;
4446
events: Array<{ event: string; payload: Record<string, unknown> }>;
4547
close(): Promise<void>;
4648
};
4749

50+
export type GatewayConnectAuth = {
51+
role: string;
52+
scopes: string[];
53+
deviceToken?: string;
54+
};
55+
56+
type GatewayClientInfo = {
57+
id: string;
58+
displayName: string;
59+
version: string;
60+
platform: string;
61+
mode: string;
62+
};
63+
4864
export type McpClientHandle = {
4965
client: Client;
5066
cleanup(): void;
@@ -60,6 +76,14 @@ const MCP_CHANNEL_LIMITS = readMcpChannelLimits();
6076
const MCP_CONNECT_TIMEOUT_MS = MCP_CHANNEL_LIMITS.connectTimeoutMs;
6177
const GATEWAY_EVENT_RETAIN_LIMIT = MCP_CHANNEL_LIMITS.gatewayEventRetainLimit;
6278
const MCP_RAW_MESSAGE_RETAIN_LIMIT = MCP_CHANNEL_LIMITS.rawMessageRetainLimit;
79+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
80+
const DEFAULT_GATEWAY_CLIENT: GatewayClientInfo = {
81+
id: "openclaw-tui",
82+
displayName: "docker-mcp-channels",
83+
version: "1.0.0",
84+
platform: process.platform,
85+
mode: "ui",
86+
};
6387

6488
export function assert(condition: unknown, message: string): asserts condition {
6589
if (!condition) {
@@ -115,6 +139,8 @@ export async function connectGateway(params: {
115139
url: string;
116140
token: string;
117141
scopes?: readonly string[];
142+
client?: GatewayClientInfo;
143+
bindFreshDevice?: boolean;
118144
}): Promise<GatewayRpcClient> {
119145
const startedAt = Date.now();
120146
let attempt = 0;
@@ -140,13 +166,16 @@ async function connectGatewayOnce(params: {
140166
url: string;
141167
token: string;
142168
scopes?: readonly string[];
169+
client?: GatewayClientInfo;
170+
bindFreshDevice?: boolean;
143171
}): Promise<GatewayRpcClient> {
144172
const requestedScopes = params.scopes ?? [
145173
"operator.read",
146174
"operator.write",
147175
"operator.pairing",
148176
"operator.admin",
149177
];
178+
const client = params.client ?? DEFAULT_GATEWAY_CLIENT;
150179
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
151180
const gatewayClient = createGatewayWsClient({
152181
handshakeTimeoutMs: GATEWAY_WS_OPEN_TIMEOUT_MS,
@@ -186,29 +215,35 @@ async function connectGatewayOnce(params: {
186215
});
187216
};
188217

189-
await sendGatewayRequest(
218+
const device = params.bindFreshDevice
219+
? await createSignedOperatorDevice({
220+
events,
221+
token: params.token,
222+
scopes: requestedScopes,
223+
client,
224+
})
225+
: undefined;
226+
227+
const connectPayload = await sendGatewayRequest(
190228
"connect",
191229
{
192230
minProtocol: PROTOCOL_VERSION,
193231
maxProtocol: PROTOCOL_VERSION,
194-
client: {
195-
id: "openclaw-tui",
196-
displayName: "docker-mcp-channels",
197-
version: "1.0.0",
198-
platform: process.platform,
199-
mode: "ui",
200-
},
232+
client,
201233
role: "operator",
202234
scopes: requestedScopes,
203235
caps: [],
204236
auth: { token: params.token },
237+
...(device ? { device } : {}),
205238
},
206239
GATEWAY_RPC_TIMEOUT_MS,
207240
);
241+
const auth = readGatewayConnectAuth(connectPayload);
208242

209243
await sendGatewayRequest("sessions.subscribe", {}, GATEWAY_RPC_TIMEOUT_MS);
210244

211245
return {
246+
auth,
212247
request(method, requestParams, opts) {
213248
return sendGatewayRequest(
214249
method,
@@ -223,6 +258,143 @@ async function connectGatewayOnce(params: {
223258
};
224259
}
225260

261+
function readGatewayConnectAuth(payload: unknown): GatewayConnectAuth {
262+
const auth = payload && typeof payload === "object" ? (payload as { auth?: unknown }).auth : null;
263+
if (!auth || typeof auth !== "object") {
264+
throw new Error(`gateway hello-ok missing auth metadata: ${JSON.stringify(payload)}`);
265+
}
266+
const record = auth as { role?: unknown; scopes?: unknown; deviceToken?: unknown };
267+
if (typeof record.role !== "string" || !Array.isArray(record.scopes)) {
268+
throw new Error(`gateway hello-ok auth metadata has invalid shape: ${JSON.stringify(auth)}`);
269+
}
270+
return {
271+
role: record.role,
272+
scopes: record.scopes.filter((scope): scope is string => typeof scope === "string"),
273+
...(typeof record.deviceToken === "string" ? { deviceToken: record.deviceToken } : {}),
274+
};
275+
}
276+
277+
export function assertGatewayScopes(
278+
gateway: GatewayRpcClient,
279+
expected: { include?: readonly string[]; exclude?: readonly string[]; label: string },
280+
) {
281+
const scopes = new Set(gateway.auth.scopes);
282+
const missing = (expected.include ?? []).filter((scope) => !scopes.has(scope));
283+
const forbidden = (expected.exclude ?? []).filter((scope) => scopes.has(scope));
284+
assert(
285+
missing.length === 0 && forbidden.length === 0,
286+
`${expected.label} granted unexpected gateway scopes: ${JSON.stringify({
287+
granted: gateway.auth.scopes,
288+
missing,
289+
forbidden,
290+
})}`,
291+
);
292+
}
293+
294+
function base64UrlEncode(buf: Buffer): string {
295+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
296+
}
297+
298+
function normalizeDeviceMetadataForAuth(value?: string | null): string {
299+
const trimmed = value?.trim();
300+
if (!trimmed) {
301+
return "";
302+
}
303+
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
304+
}
305+
306+
function derivePublicKeyRaw(publicKey: crypto.KeyObject): Buffer {
307+
const spki = publicKey.export({ type: "spki", format: "der" }) as Buffer;
308+
if (
309+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
310+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
311+
) {
312+
return spki.subarray(ED25519_SPKI_PREFIX.length);
313+
}
314+
return spki;
315+
}
316+
317+
function createEphemeralDeviceIdentity() {
318+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
319+
const publicKeyRaw = derivePublicKeyRaw(publicKey);
320+
return {
321+
deviceId: crypto.createHash("sha256").update(publicKeyRaw).digest("hex"),
322+
privateKey,
323+
publicKey: base64UrlEncode(publicKeyRaw),
324+
};
325+
}
326+
327+
// The Docker functional image mounts test files beside the packaged app, not
328+
// source packages. Keep the client-side v3 signing payload local so the bridge
329+
// runtime under test still comes from the package tarball.
330+
function buildDeviceAuthPayloadV3(params: {
331+
deviceId: string;
332+
clientId: string;
333+
clientMode: string;
334+
role: string;
335+
scopes: string[];
336+
signedAtMs: number;
337+
token?: string | null;
338+
nonce: string;
339+
platform?: string | null;
340+
deviceFamily?: string | null;
341+
}): string {
342+
return [
343+
"v3",
344+
params.deviceId,
345+
params.clientId,
346+
params.clientMode,
347+
params.role,
348+
params.scopes.join(","),
349+
String(params.signedAtMs),
350+
params.token ?? "",
351+
params.nonce,
352+
normalizeDeviceMetadataForAuth(params.platform),
353+
normalizeDeviceMetadataForAuth(params.deviceFamily),
354+
].join("|");
355+
}
356+
357+
function signDevicePayload(privateKey: crypto.KeyObject, payload: string): string {
358+
return base64UrlEncode(crypto.sign(null, Buffer.from(payload, "utf8"), privateKey));
359+
}
360+
361+
async function createSignedOperatorDevice(params: {
362+
events: Array<{ event: string; payload: Record<string, unknown> }>;
363+
token: string;
364+
scopes: readonly string[];
365+
client: GatewayClientInfo;
366+
}) {
367+
const nonce = await waitFor(
368+
"gateway connect challenge nonce",
369+
() => {
370+
const challenge = params.events.find((entry) => entry.event === "connect.challenge");
371+
const value = challenge?.payload.nonce;
372+
return typeof value === "string" && value.length > 0 ? value : undefined;
373+
},
374+
GATEWAY_WS_OPEN_TIMEOUT_MS,
375+
);
376+
const identity = createEphemeralDeviceIdentity();
377+
const signedAtMs = Date.now();
378+
const payload = buildDeviceAuthPayloadV3({
379+
deviceId: identity.deviceId,
380+
clientId: params.client.id,
381+
clientMode: params.client.mode,
382+
role: "operator",
383+
scopes: [...params.scopes],
384+
signedAtMs,
385+
token: params.token,
386+
nonce,
387+
platform: params.client.platform,
388+
});
389+
return {
390+
id: identity.deviceId,
391+
publicKey: identity.publicKey,
392+
signature: signDevicePayload(identity.privateKey, payload),
393+
signedAt: signedAtMs,
394+
nonce,
395+
};
396+
}
397+
226398
function isRetryableGatewayConnectError(error: Error): boolean {
227399
const message = error.message.toLowerCase();
228400
return (

0 commit comments

Comments
 (0)