Skip to content

Commit fd00c58

Browse files
committed
fix(mcp): prove owner-gated permission replies
1 parent 50ed5b3 commit fd00c58

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// MCP channels Docker client drives the QA-owned channel bridge smoke.
22
import { randomUUID } from "node:crypto";
3+
import { setTimeout as delay } from "node:timers/promises";
34
import {
45
assert,
56
ClaudeChannelNotificationSchema,
@@ -29,6 +30,8 @@ function summarizeSessionRows(rows: Array<Record<string, unknown>> | undefined)
2930
}));
3031
}
3132

33+
const NON_OWNER_PERMISSION_QUIET_WINDOW_MS = 1_000;
34+
3235
function formatUnknownError(error: unknown): string {
3336
if (error instanceof Error) {
3437
return error.message;
@@ -96,6 +99,7 @@ async function main() {
9699
assert(gatewayToken, "missing GW_TOKEN");
97100

98101
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
102+
let nonOwnerGateway: GatewayRpcClient | undefined;
99103
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
100104
const mcpTempState = createMcpClientTempState({ gatewayToken });
101105

@@ -349,6 +353,36 @@ async function main() {
349353
},
350354
});
351355

356+
nonOwnerGateway = await connectGateway({
357+
url: gatewayUrl,
358+
token: gatewayToken,
359+
scopes: ["operator.read", "operator.write"],
360+
});
361+
await nonOwnerGateway.request("chat.send", {
362+
sessionKey: "agent:main:main",
363+
message: "yes abcde",
364+
idempotencyKey: randomUUID(),
365+
});
366+
await waitFor(
367+
"non-owner reply forwarded as an ordinary Claude channel message",
368+
() =>
369+
mcpHandle.rawMessages
370+
.map((entry) => ClaudeChannelNotificationSchema.safeParse(entry))
371+
.find(
372+
(entry) =>
373+
entry.success &&
374+
entry.data.params.meta.session_key === "agent:main:main" &&
375+
entry.data.params.content === "yes abcde",
376+
)?.data.params,
377+
60_000,
378+
);
379+
await delay(NON_OWNER_PERMISSION_QUIET_WINDOW_MS);
380+
const nonOwnerPermission = mcpHandle.rawMessages
381+
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
382+
.find((entry) => entry.success && entry.data.params.request_id === "abcde");
383+
assert(!nonOwnerPermission, "non-owner reply must not resolve the Claude permission");
384+
385+
const ownerNotificationStart = mcpHandle.rawMessages.length;
352386
await gateway.request("chat.send", {
353387
sessionKey: "agent:main:main",
354388
message: "yes abcde",
@@ -360,6 +394,7 @@ async function main() {
360394
"Claude permission notification",
361395
() =>
362396
mcpHandle.rawMessages
397+
.slice(ownerNotificationStart)
363398
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
364399
.find((entry) => entry.success && entry.data.params.request_id === "abcde")?.data
365400
.params,
@@ -389,6 +424,9 @@ async function main() {
389424
{
390425
ok: true,
391426
sessionKey: "agent:main:main",
427+
nonOwnerReplyForwarded: true,
428+
nonOwnerPermissionBlocked: true,
429+
ownerPermissionAllowed: permission.behavior === "allow",
392430
rawNotifications: mcpHandle.rawMessages.filter(
393431
(entry) =>
394432
ClaudeChannelNotificationSchema.safeParse(entry).success ||
@@ -401,6 +439,9 @@ async function main() {
401439
);
402440
} finally {
403441
const closeTasks: Array<Promise<unknown>> = [gateway.close()];
442+
if (nonOwnerGateway) {
443+
closeTasks.push(nonOwnerGateway.close());
444+
}
404445
if (mcpHandle) {
405446
closeTasks.push(mcpHandle.client.close(), mcpHandle.transport.close());
406447
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export async function waitFor<T>(
114114
export async function connectGateway(params: {
115115
url: string;
116116
token: string;
117+
scopes?: readonly string[];
117118
}): Promise<GatewayRpcClient> {
118119
const startedAt = Date.now();
119120
let attempt = 0;
@@ -138,8 +139,14 @@ export async function connectGateway(params: {
138139
async function connectGatewayOnce(params: {
139140
url: string;
140141
token: string;
142+
scopes?: readonly string[];
141143
}): Promise<GatewayRpcClient> {
142-
const requestedScopes = ["operator.read", "operator.write", "operator.pairing", "operator.admin"];
144+
const requestedScopes = params.scopes ?? [
145+
"operator.read",
146+
"operator.write",
147+
"operator.pairing",
148+
"operator.admin",
149+
];
143150
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
144151
const gatewayClient = createGatewayWsClient({
145152
handshakeTimeoutMs: GATEWAY_WS_OPEN_TIMEOUT_MS,

0 commit comments

Comments
 (0)