Files
openclaw/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts
Peter Steinberger fe261b0f59 chore(tooling): typecheck root test/** with a dedicated tsgo lane (#104475)
* chore(types): add declaration files for scripts/lib and scripts/e2e modules

* chore(types): add declaration files for top-level script modules (a-m)

* chore(types): add declaration files for top-level script modules (n-z)

* test: use a non-secret-shaped gateway token fixture

* test: type ci workflow guard helpers for the root test lane

* chore(tooling): typecheck root test/** with a dedicated tsgo lane

- test/tsconfig/tsconfig.test.root.json: root-test program (strict unused checks,
  fixtures excluded; two Docker E2E clients that import built dist/** stay out,
  same rationale as the scripts/e2e exclusion in tsconfig.scripts.json)
- tsgo:test:root wired into tsgo:test, check:test-types, scripts/check.mjs, and
  the ci.yml test-types shard, mirroring the tsgo:scripts lane (#104348)
- changed-lane routing: test/**/*.ts (excluding fixtures) and the lane tsconfig
  now trigger 'typecheck test root' in check:changed; previously test/ paths ran
  lint only, so harness type errors surfaced first in CI (#104287 envDir case)
- burn down all 1071 latent type errors in the program: precise param/local
  types across test/scripts, test/vitest, test/e2e, and transitive scripts/e2e
  program members; 205 sibling .d.mts declaration files for imported .mjs
  modules (committed separately); zero any, zero ts-expect-error
- resolve the pre-existing testing star-export ambiguity in
  scripts/e2e/parallels/common.ts with an explicit re-export

Closes #104388

* chore(types): correct declaration fidelity per structured review

- re-derive 51 .d.mts files from implementation data flow instead of
  initializers: fix a wrong never return (runTestProjectsDelegation returns
  the child), add encoding-sensitive exec/spawn overloads (plain-gh), restore
  the full release profile union, make parsed paths string | null, add missing
  parseArgs fields via help/non-help unions, add a missing sibling declaration
  (budget-number-args), drop 15 unused lint directives
- precise install-record/tuple typing removes the type-aware oxlint
  regressions the first declarations caused in scripts/e2e implementations
- route .mts declaration edits under test/ to the testRoot lane and reference
  the test-root project from tsconfig.projects.json so tsgo:all covers it
  (closes both review findings against the lane wiring)

* chore(scripts): keep telegram runner dist typing structural for the boundary guard

* chore(types): declare runtime pack and gateway readiness exports added on main

* test: pin the importTargetPlan form of the plugin-contract plan import

The guard expectation still referenced the raw await import( form that
7ae5996bb3 (#103975) replaced with the importTargetPlan fallback helper;
the assertion fails on current main.
2026-07-11 06:15:41 -07:00

480 lines
16 KiB
TypeScript

// MCP channels Docker client drives the QA-owned channel bridge smoke.
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import {
assert,
assertGatewayScopes,
ClaudeChannelNotificationSchema,
ClaudePermissionNotificationSchema,
connectGateway,
connectMcpClient,
extractTextFromGatewayPayload,
type ClaudeChannelNotification,
type GatewayRpcClient,
maybeApprovePendingBridgePairing,
waitFor,
} from "./mcp-channels.fixture.ts";
import {
connectMcpClientWithPairingReconnect,
createMcpClientTempState,
} from "./mcp-client-temp-state.fixture.ts";
function summarizeSessionRows(rows: Array<Record<string, unknown>> | undefined) {
return (rows ?? []).map((entry) => ({
key: entry.key,
channel: entry.channel,
deliveryContext: entry.deliveryContext,
lastChannel: entry.lastChannel,
lastTo: entry.lastTo,
lastAccountId: entry.lastAccountId,
lastThreadId: entry.lastThreadId,
}));
}
function findEventByText(events: Array<Record<string, unknown>> | undefined, text: string) {
return (events ?? []).find((entry) => entry.text === text);
}
const NON_OWNER_PERMISSION_QUIET_WINDOW_MS = 1_000;
function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (error === undefined || error === null) {
return "";
}
if (typeof error === "string") {
return error;
}
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
return `${error}`;
}
if (typeof error === "symbol") {
return error.description ?? "symbol";
}
try {
return JSON.stringify(error) ?? "";
} catch {
return Object.prototype.toString.call(error);
}
}
async function waitForGatewaySeededConversation(gateway: GatewayRpcClient) {
let lastList: { sessions?: Array<Record<string, unknown>> } | undefined;
let lastError: unknown;
try {
return await waitFor(
"seeded conversation in gateway sessions.list",
async () => {
try {
lastList = await gateway.request<{ sessions?: Array<Record<string, unknown>> }>(
"sessions.list",
{ limit: 50, includeDerivedTitles: false, includeLastMessage: false },
);
lastError = undefined;
} catch (error) {
lastError = error;
return undefined;
}
return lastList.sessions?.find((entry) => entry.key === "agent:main:main");
},
180_000,
);
} catch (error) {
throw new Error(
`gateway sessions.list did not include seeded conversation: ${JSON.stringify(
{
count: lastList?.sessions?.length ?? 0,
sessions: summarizeSessionRows(lastList?.sessions),
lastError: formatUnknownError(lastError),
},
null,
2,
)}`,
{ cause: error },
);
}
}
async function main() {
const gatewayUrl = process.env.GW_URL?.trim();
const gatewayToken = process.env.GW_TOKEN?.trim();
assert(gatewayUrl, "missing GW_URL");
assert(gatewayToken, "missing GW_TOKEN");
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
assertGatewayScopes(gateway, {
include: ["operator.admin", "operator.pairing", "operator.write"],
label: "owner gateway",
});
let nonOwnerGateway: GatewayRpcClient | undefined;
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
const mcpTempState = createMcpClientTempState({ gatewayToken });
try {
const gatewayConversation = await waitForGatewaySeededConversation(gateway);
assert(
(gatewayConversation.deliveryContext as { channel?: unknown } | undefined)?.channel ===
"imessage",
"expected seeded gateway deliveryContext channel",
);
assert(
(gatewayConversation.deliveryContext as { to?: unknown } | undefined)?.to === "+15551234567",
"expected seeded gateway deliveryContext target",
);
mcpHandle = await connectMcpClientWithPairingReconnect({
tempState: mcpTempState,
connect: (tempState) =>
connectMcpClient({
gatewayUrl,
gatewayToken,
tempState,
}),
maybeApprovePairing: () => maybeApprovePendingBridgePairing(gateway),
});
const connectedMcp = mcpHandle;
const mcp = connectedMcp.client;
const callTool = <T>(params: Parameters<typeof mcp.callTool>[0]) =>
mcp.callTool(params, undefined, { timeout: 240_000 }) as Promise<T>;
let lastMcpConversationList: unknown;
const conversation = await waitFor(
"seeded conversation in conversations_list",
async () => {
const listed = await callTool<{
structuredContent?: { conversations?: Array<Record<string, unknown>> };
}>({
name: "conversations_list",
arguments: {
includeDerivedTitles: false,
includeLastMessage: false,
},
});
lastMcpConversationList = listed;
return listed.structuredContent?.conversations?.find(
(entry) => entry.sessionKey === "agent:main:main",
);
},
240_000,
).catch((error: unknown) => {
throw new Error(
`timeout waiting for seeded MCP conversation: ${JSON.stringify(
lastMcpConversationList,
null,
2,
)}`,
{ cause: error },
);
});
assert(conversation.channel === "imessage", "expected seeded channel");
assert(conversation.to === "+15551234567", "expected seeded target");
const fetched = await callTool<{
structuredContent?: { conversation?: Record<string, unknown> };
isError?: boolean;
}>({
name: "conversation_get",
arguments: { session_key: "agent:main:main" },
});
assert(!fetched.isError, "conversation_get should succeed");
assert(
fetched.structuredContent?.conversation?.sessionKey === "agent:main:main",
"conversation_get returned wrong session",
);
let lastHistory: unknown;
const messages = await waitFor(
"seeded transcript messages",
async () => {
const history = await callTool<{
structuredContent?: { messages?: Array<Record<string, unknown>> };
}>({
name: "messages_read",
arguments: { session_key: "agent:main:main", limit: 10 },
});
lastHistory = history;
const currentMessages = history.structuredContent?.messages ?? [];
return currentMessages.length >= 2 ? currentMessages : undefined;
},
240_000,
).catch((error: unknown) => {
throw new Error(
`timeout waiting for seeded transcript messages: ${JSON.stringify(lastHistory, null, 2)}`,
{ cause: error },
);
});
await waitFor(
"seeded attachment message",
() =>
messages.find((entry) => {
const raw = entry["__openclaw"];
return (
raw && typeof raw === "object" && (raw as { id?: unknown }).id === "msg-attachment"
);
}),
240_000,
);
const attachments = await callTool<{
structuredContent?: { attachments?: Array<Record<string, unknown>> };
isError?: boolean;
}>({
name: "attachments_fetch",
arguments: { session_key: "agent:main:main", message_id: "msg-attachment" },
});
assert(!attachments.isError, "attachments_fetch should succeed");
assert(
(attachments.structuredContent?.attachments?.length ?? 0) === 1,
"expected one seeded attachment",
);
const waited = (await Promise.all([
callTool<{
structuredContent?: { event?: Record<string, unknown> };
}>({
name: "events_wait",
arguments: {
session_key: "agent:main:main",
after_cursor: 0,
timeout_ms: 10_000,
},
}),
gateway.request("chat.inject", {
sessionKey: "agent:main:main",
message: "assistant live event",
}),
]).then(([result]) => result)) as {
structuredContent?: { event?: Record<string, unknown> };
};
const assistantEvent = waited.structuredContent?.event;
assert(assistantEvent, "expected events_wait result");
assert(assistantEvent.type === "message", "expected message event");
assert(assistantEvent.role === "assistant", "expected assistant event role");
assert(assistantEvent.text === "assistant live event", "expected assistant event text");
const assistantCursor = typeof assistantEvent.cursor === "number" ? assistantEvent.cursor : 0;
const polled = await callTool<{
structuredContent?: { events?: Array<Record<string, unknown>> };
}>({
name: "events_poll",
arguments: { session_key: "agent:main:main", after_cursor: 0, limit: 10 },
});
assert(
(polled.structuredContent?.events ?? []).some(
(entry) => entry.text === "assistant live event",
),
"expected assistant event in events_poll",
);
const channelMessage = `hello from docker ${randomUUID()}`;
await gateway.request("chat.send", {
sessionKey: "agent:main:main",
message: channelMessage,
idempotencyKey: randomUUID(),
});
const rawGatewayUserMessage = await waitFor(
"raw gateway user session.message",
() =>
gateway.events.find(
(entry) =>
entry.event === "session.message" &&
entry.payload.sessionKey === "agent:main:main" &&
extractTextFromGatewayPayload(entry.payload) === channelMessage,
),
10_000,
).catch(() => undefined);
let userEvent = await waitFor(
"MCP user session.message event",
async () => {
const polledValue = await callTool<{
structuredContent?: { events?: Array<Record<string, unknown>> };
}>({
name: "events_poll",
arguments: { session_key: "agent:main:main", after_cursor: assistantCursor, limit: 50 },
});
return findEventByText(polledValue.structuredContent?.events, channelMessage);
},
60_000,
).catch(() => undefined);
let finalPolledEvents: Array<Record<string, unknown>> | undefined;
if (userEvent?.text !== channelMessage) {
const polledLocal = await callTool<{
structuredContent?: { events?: Array<Record<string, unknown>> };
}>({
name: "events_poll",
arguments: { session_key: "agent:main:main", after_cursor: assistantCursor, limit: 50 },
});
finalPolledEvents = polledLocal.structuredContent?.events ?? [];
const finalUserEvent = findEventByText(finalPolledEvents, channelMessage);
if (finalUserEvent?.text === channelMessage) {
userEvent = finalUserEvent;
}
}
if (userEvent?.text !== channelMessage) {
throw new Error(
`expected user event after chat.send: ${JSON.stringify(
{
userEvent: userEvent ?? null,
rawGatewayUserMessage: rawGatewayUserMessage ?? null,
mcpEventsAfterAssistant: finalPolledEvents ?? [],
recentGatewayEvents: gateway.events.slice(-10).map((entry) => ({
event: entry.event,
sessionKey: entry.payload.sessionKey,
text: extractTextFromGatewayPayload(entry.payload),
})),
},
null,
2,
)}`,
);
}
let helpNotification: ClaudeChannelNotification;
try {
helpNotification = await waitFor("Claude channel notification", () =>
connectedMcp!.rawMessages
.map((entry) => ClaudeChannelNotificationSchema.safeParse(entry))
.flatMap((entry) => (entry.success ? [entry.data.params] : []))
.find(
(params) =>
params.meta.session_key === "agent:main:main" && params.content === channelMessage,
),
);
} catch (error) {
throw new Error(
`timeout waiting for Claude channel notification: ${JSON.stringify(
{
rawMessages: connectedMcp.rawMessages.slice(-10),
},
null,
2,
)}`,
{ cause: error },
);
}
assert(helpNotification.content === channelMessage, "expected Claude channel content");
await mcp.notification({
method: "notifications/claude/channel/permission_request",
params: {
request_id: "abcde",
tool_name: "Bash",
description: "run npm test",
input_preview: '{"cmd":"npm test"}',
},
});
nonOwnerGateway = await connectGateway({
url: gatewayUrl,
token: gatewayToken,
scopes: ["operator.read", "operator.write"],
client: {
id: "test",
displayName: "docker-mcp-channels-non-owner",
version: "1.0.0",
platform: process.platform,
mode: "test",
},
bindFreshDevice: true,
});
assertGatewayScopes(nonOwnerGateway, {
include: ["operator.read", "operator.write"],
exclude: ["operator.admin", "operator.pairing"],
label: "non-owner gateway",
});
await nonOwnerGateway.request("chat.send", {
sessionKey: "agent:main:main",
message: "yes abcde",
idempotencyKey: randomUUID(),
});
await waitFor(
"non-owner reply forwarded as an ordinary Claude channel message",
() =>
connectedMcp!.rawMessages
.map((entry) => ClaudeChannelNotificationSchema.safeParse(entry))
.flatMap((entry) => (entry.success ? [entry.data.params] : []))
.find(
(params) =>
params.meta.session_key === "agent:main:main" && params.content === "yes abcde",
),
60_000,
);
await delay(NON_OWNER_PERMISSION_QUIET_WINDOW_MS);
const nonOwnerPermission = connectedMcp.rawMessages
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
.find((entry) => entry.success && entry.data.params.request_id === "abcde");
assert(!nonOwnerPermission, "non-owner reply must not resolve the Claude permission");
const ownerNotificationStart = connectedMcp.rawMessages.length;
await gateway.request("chat.send", {
sessionKey: "agent:main:main",
message: "yes abcde",
idempotencyKey: randomUUID(),
});
let permission: { request_id: string; behavior: "allow" | "deny" };
try {
permission = await waitFor(
"Claude permission notification",
() =>
connectedMcp!.rawMessages
.slice(ownerNotificationStart)
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
.flatMap((entry) => (entry.success ? [entry.data.params] : []))
.find((params) => params.request_id === "abcde"),
60_000,
);
} catch (error) {
throw new Error(
`timeout waiting for Claude permission notification: ${JSON.stringify(
{
rawMessages: connectedMcp.rawMessages.slice(-10),
recentGatewayEvents: gateway.events.slice(-10).map((entry) => ({
event: entry.event,
sessionKey: entry.payload.sessionKey,
text: extractTextFromGatewayPayload(entry.payload),
})),
},
null,
2,
)}`,
{ cause: error },
);
}
assert(permission.behavior === "allow", "expected allow permission reply");
process.stdout.write(
JSON.stringify(
{
ok: true,
sessionKey: "agent:main:main",
nonOwnerReplyForwarded: true,
nonOwnerPermissionBlocked: true,
ownerPermissionAllowed: permission.behavior === "allow",
rawNotifications: connectedMcp.rawMessages.filter(
(entry) =>
ClaudeChannelNotificationSchema.safeParse(entry).success ||
ClaudePermissionNotificationSchema.safeParse(entry).success,
).length,
},
null,
2,
) + "\n",
);
} finally {
const closeTasks: Array<Promise<unknown>> = [gateway.close()];
if (nonOwnerGateway) {
closeTasks.push(nonOwnerGateway.close());
}
if (mcpHandle) {
closeTasks.push(mcpHandle.client.close(), mcpHandle.transport.close());
}
await Promise.allSettled(closeTasks);
mcpHandle?.cleanup();
mcpTempState.cleanup();
}
}
await main();