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" ;
45import process from "node:process" ;
56import { setTimeout as delay } from "node:timers/promises" ;
67import { Client } from "@modelcontextprotocol/sdk/client/index.js" ;
@@ -40,11 +41,26 @@ export const ClaudePermissionNotificationSchema = z.object({
4041export type ClaudeChannelNotification = z . infer < typeof ClaudeChannelNotificationSchema > [ "params" ] ;
4142
4243export 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+
4864export type McpClientHandle = {
4965 client : Client ;
5066 cleanup ( ) : void ;
@@ -60,6 +76,14 @@ const MCP_CHANNEL_LIMITS = readMcpChannelLimits();
6076const MCP_CONNECT_TIMEOUT_MS = MCP_CHANNEL_LIMITS . connectTimeoutMs ;
6177const GATEWAY_EVENT_RETAIN_LIMIT = MCP_CHANNEL_LIMITS . gatewayEventRetainLimit ;
6278const 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
6488export 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+
226398function isRetryableGatewayConnectError ( error : Error ) : boolean {
227399 const message = error . message . toLowerCase ( ) ;
228400 return (
0 commit comments