Fix chat-channel node exec approval replays

Fixes #77656.

Summary:
- Carry chat turn-source metadata through approved async host=node replays.
- Bind trusted backend replay to node, command, session, agent, and chat target metadata instead of transient WebSocket connection ids.
- Cover Telegram and WeCom-style reconnect replay plus denial cases with gateway, websocket, and agent tests.
- Carry the current-main CLI help assertion fix needed to clear exact-head CI after the rebase.

Verification:
- pnpm test src/gateway/node-invoke-system-run-approval.test.ts src/gateway/server.node-invoke-approval-bypass.test.ts src/agents/bash-tools.exec-host-node.test.ts -- --reporter=verbose
- pnpm test src/cli/channel-auth.test.ts src/cli/plugins-cli.policy.test.ts src/cli/command-registration-policy.test.ts -- --reporter=verbose
- pnpm check:changed
- GitHub CI passed on d1392a873c
- ClawSweeper re-review completed on the approval replay head
This commit is contained in:
Val Alexander
2026-05-09 22:10:21 -05:00
committed by GitHub
parent 392ce6d8d8
commit 8ead938c7c
7 changed files with 576 additions and 1 deletions

View File

@@ -223,6 +223,7 @@ Docs: https://docs.openclaw.ai
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
- Telegram: persist reply-chain message cache records as a compact append log instead of rewriting the full cache on every inbound message, reducing large-group turn latency.
- Telegram/CLI-backend: mirror outbound replies to the session transcript so CLI-backend agent responses create `.jsonl` session files, preventing `sessionId=unknown` on subsequent runs. Fixes #75991.
- Gateway/nodes: allow approved chat-channel macOS node exec replays to cross transient agent WebSocket reconnects only when node, agent session, and channel target metadata still match, restoring Telegram/WeCom host=node approvals without opening a general backend replay bypass. Fixes #77656. Thanks @BunsDev.
- QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee.
- Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee.
- Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys.

View File

@@ -153,6 +153,10 @@ export function buildNodeSystemRunInvoke(params: {
cwd: string | undefined;
agentId: string | undefined;
sessionKey: string | undefined;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
approved?: boolean;
approvalDecision?: "allow-once" | "allow-always" | null;
runId?: string;
@@ -174,6 +178,14 @@ export function buildNodeSystemRunInvoke(params: {
timeoutMs,
agentId: params.agentId,
sessionKey: params.sessionKey,
...(params.turnSourceChannel != null ? { turnSourceChannel: params.turnSourceChannel } : {}),
...(params.turnSourceTo != null ? { turnSourceTo: params.turnSourceTo } : {}),
...(params.turnSourceAccountId != null
? { turnSourceAccountId: params.turnSourceAccountId }
: {}),
...(params.turnSourceThreadId != null
? { turnSourceThreadId: params.turnSourceThreadId }
: {}),
approved: params.approved,
approvalDecision: params.approvalDecision ?? undefined,
runId: params.runId ?? undefined,

View File

@@ -271,6 +271,10 @@ describe("executeNodeHostCommand", () => {
warnings: [],
agentId: "requested-agent",
sessionKey: "requested-session",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
});
expect(result.details?.status).toBe("approval-pending");
@@ -295,6 +299,10 @@ describe("executeNodeHostCommand", () => {
approvalDecision: "allow-once",
systemRunPlan: preparedPlan,
timeoutMs: 30_000,
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
}),
}),
{ scopes: ["operator.write", "operator.approvals"] },

View File

@@ -218,6 +218,10 @@ export async function executeNodeHostCommand(
cwd: prepared.cwd,
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
approved: approvedByAsk,
approvalDecision:
approvalDecision === "allow-always" && inlineEvalHit !== null

View File

@@ -102,6 +102,38 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expect(result.details?.code).toBe(code);
}
function makeChatRecord(overrides: Partial<ExecApprovalRecord["request"]> = {}) {
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
record.requestedByConnId = "chat-agent-conn";
record.requestedByDeviceId = null;
record.requestedByClientId = "gateway-client";
record.requestedByDeviceTokenAuth = false;
record.request = {
...record.request,
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
systemRunPlan: {
argv: ["echo", "SAFE"],
cwd: null,
commandText: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
},
systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"],
cwd: null,
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
}).binding,
...overrides,
};
return record;
}
test("rejects cmd.exe /c trailing-arg mismatch against rawCommand", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
@@ -437,6 +469,29 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expectRejectedForwardingResult(result, "APPROVAL_NODE_MISMATCH", "not valid for this node");
});
test("rejects approval ids replayed from a different device token binding", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: {
...client,
connect: {
...client.connect,
device: { id: "dev-2" },
},
},
execApprovalManager: manager(makeRecord("echo SAFE")),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_DEVICE_MISMATCH", "not valid for this device");
});
test("accepts trusted backend replay for no-device approval after the request connection changes", () => {
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
record.requestedByConnId = "control-ui-conn";
@@ -522,6 +577,239 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("accepts trusted backend chat replay when stable requester metadata matches", () => {
const record = makeChatRecord();
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(record),
nowMs: now,
});
expectAllowOnceForwardingResult(result);
if (!result.ok) {
throw new Error("unreachable");
}
const forwarded = result.params as Record<string, unknown>;
expect(forwarded).not.toHaveProperty("turnSourceChannel");
expect(forwarded).not.toHaveProperty("turnSourceTo");
expect(forwarded).not.toHaveProperty("turnSourceAccountId");
expect(forwarded).not.toHaveProperty("turnSourceThreadId");
});
test("accepts trusted backend chat replay from a non-bridgeable agent client when stable requester metadata matches", () => {
const record = makeChatRecord();
record.requestedByClientId = "chat-agent";
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(record),
nowMs: now,
});
expectAllowOnceForwardingResult(result);
});
test("accepts trusted backend WeCom replay when the approved chat agent connection changes", () => {
const sessionKey = "agent:main:wecom:conversation:corp-42";
const record = makeChatRecord({
sessionKey,
turnSourceChannel: "wecom",
turnSourceTo: "wecom:corp-42:conversation-7",
turnSourceAccountId: "corp-42",
turnSourceThreadId: "conversation-7",
systemRunPlan: {
argv: ["echo", "SAFE"],
cwd: null,
commandText: "echo SAFE",
agentId: "main",
sessionKey,
},
systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"],
cwd: null,
agentId: "main",
sessionKey,
}).binding,
});
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey,
turnSourceChannel: "wecom",
turnSourceTo: "wecom:corp-42:conversation-7",
turnSourceAccountId: "corp-42",
turnSourceThreadId: "conversation-7",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(record),
nowMs: now,
});
expectAllowOnceForwardingResult(result);
});
test("rejects trusted backend chat replay when session binding changes", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:99999",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(makeChatRecord()),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("rejects trusted backend chat replay when session binding casing changes", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:MAIN:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(makeChatRecord()),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("rejects trusted backend chat replay when agent binding casing changes", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "Main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(makeChatRecord()),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("rejects trusted backend chat replay when channel target changes", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:67890",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(makeChatRecord()),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("rejects trusted backend chat replay without matching approval scope", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: {
...trustedBackendClient,
connect: {
...trustedBackendClient.connect,
scopes: ["operator.write"],
},
},
execApprovalManager: manager(makeChatRecord()),
nowMs: now,
});
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
});
test("rejects no-device approval replay when the original request used device-token auth", () => {
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
record.requestedByConnId = "control-ui-conn";

View File

@@ -23,6 +23,10 @@ type SystemRunParamsLike = {
needsScreenRecording?: unknown;
agentId?: unknown;
sessionKey?: unknown;
turnSourceChannel?: unknown;
turnSourceTo?: unknown;
turnSourceAccountId?: unknown;
turnSourceThreadId?: unknown;
approved?: unknown;
approvalDecision?: unknown;
runId?: unknown;
@@ -75,15 +79,108 @@ function canBridgeNoDeviceApprovalFromBackend(params: {
client: ApprovalClient | null;
}): boolean {
const requestedByClientId = normalizeNullableString(params.snapshot.requestedByClientId);
const request = params.snapshot.request;
return (
params.snapshot.requestedByDeviceId == null &&
params.snapshot.requestedByDeviceTokenAuth !== true &&
!hasChatApprovalReplayBinding(request) &&
requestedByClientId !== null &&
BACKEND_BRIDGEABLE_NO_DEVICE_REQUEST_CLIENT_IDS.has(requestedByClientId) &&
isTrustedBackendApprovalClient(params.client)
);
}
function hasChatApprovalReplayBinding(request: ExecApprovalRecord["request"]): boolean {
return (
normalizeComparableString(request.turnSourceChannel, { lowercase: true }) !== null ||
normalizeComparableString(request.turnSourceTo) !== null ||
normalizeComparableString(request.turnSourceAccountId) !== null ||
normalizeComparableString(request.turnSourceThreadId) !== null
);
}
function normalizeComparableString(
value: unknown,
opts: { lowercase?: boolean } = {},
): string | null {
const normalized =
typeof value === "number" && Number.isFinite(value)
? String(value)
: normalizeNullableString(value);
if (!normalized) {
return null;
}
return opts.lowercase ? normalized.toLowerCase() : normalized;
}
function matchesRequiredString(params: {
expected: unknown;
actual: unknown;
lowercase?: boolean;
}): boolean {
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
if (!expected) {
return false;
}
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
}
function matchesOptionalString(params: {
expected: unknown;
actual: unknown;
lowercase?: boolean;
}): boolean {
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
if (!expected) {
return true;
}
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
}
function canBridgeNoDeviceChatApprovalFromBackend(params: {
snapshot: ExecApprovalRecord;
rawParams: SystemRunParamsLike;
client: ApprovalClient | null;
}): boolean {
if (
params.snapshot.requestedByDeviceId != null ||
params.snapshot.requestedByDeviceTokenAuth === true ||
!isTrustedBackendApprovalClient(params.client)
) {
return false;
}
const request = params.snapshot.request;
const plan = request.systemRunPlan ?? null;
return (
matchesRequiredString({
expected: request.turnSourceChannel,
actual: params.rawParams.turnSourceChannel,
lowercase: true,
}) &&
matchesRequiredString({
expected: request.turnSourceTo,
actual: params.rawParams.turnSourceTo,
}) &&
matchesRequiredString({
expected: plan?.sessionKey ?? request.sessionKey,
actual: params.rawParams.sessionKey,
}) &&
matchesOptionalString({
expected: plan?.agentId ?? request.agentId,
actual: params.rawParams.agentId,
}) &&
matchesOptionalString({
expected: request.turnSourceAccountId,
actual: params.rawParams.turnSourceAccountId,
}) &&
matchesOptionalString({
expected: request.turnSourceThreadId,
actual: params.rawParams.turnSourceThreadId,
})
);
}
function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unknown> {
// Defensive allowlist: only forward fields that the node-host `system.run` handler understands.
// This prevents future internal control fields from being smuggled through the gateway.
@@ -224,7 +321,8 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
} else if (
snapshot.requestedByConnId &&
snapshot.requestedByConnId !== (opts.client?.connId ?? null) &&
!canBridgeNoDeviceApprovalFromBackend({ snapshot, client: opts.client })
!canBridgeNoDeviceApprovalFromBackend({ snapshot, client: opts.client }) &&
!canBridgeNoDeviceChatApprovalFromBackend({ snapshot, rawParams: p, client: opts.client })
) {
return systemRunApprovalGuardError({
code: "APPROVAL_CLIENT_MISMATCH",

View File

@@ -122,6 +122,54 @@ async function requestAllowOnceApproval(
return approvalId;
}
type ChatApprovalContext = {
agentId: string;
sessionKey: string;
turnSourceChannel: string;
turnSourceTo: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
};
async function requestChatAllowOnceApproval(params: {
ws: WebSocket;
command: string;
nodeId: string;
context: ChatApprovalContext;
}): Promise<string> {
const approvalId = crypto.randomUUID();
const commandArgv = params.command.split(/\s+/).filter((part) => part.length > 0);
const requestP = rpcReq(params.ws, "exec.approval.request", {
id: approvalId,
command: params.command,
commandArgv,
systemRunPlan: {
argv: commandArgv,
cwd: null,
commandText: params.command,
agentId: params.context.agentId,
sessionKey: params.context.sessionKey,
},
nodeId: params.nodeId,
cwd: null,
host: "node",
agentId: params.context.agentId,
sessionKey: params.context.sessionKey,
turnSourceChannel: params.context.turnSourceChannel,
turnSourceTo: params.context.turnSourceTo,
turnSourceAccountId: params.context.turnSourceAccountId,
turnSourceThreadId: params.context.turnSourceThreadId,
timeoutMs: 30_000,
});
await rpcReq(params.ws, "exec.approval.resolve", {
id: approvalId,
decision: "allow-once",
});
const requested = await requestP;
expect(requested.ok).toBe(true);
return approvalId;
}
describe("node.invoke approval bypass", () => {
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let port: number;
@@ -195,6 +243,27 @@ describe("node.invoke approval bypass", () => {
return await connectOperatorWithRetry(scopes);
};
const connectTrustedBackend = async (scopes: string[]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, {
token: "secret",
scopes,
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
displayName: "agent",
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
timeoutMs: CONNECT_REQ_TIMEOUT_MS,
});
expect(res.ok).toBe(true);
return ws;
};
const connectOperatorWithNewDevice = async (scopes: string[]) => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
@@ -461,6 +530,101 @@ describe("node.invoke approval bypass", () => {
}
});
test("bridges no-device chat approvals across backend reconnects only for the same turn source", async () => {
let invokeCount = 0;
let lastInvokeParams: Record<string, unknown> | null = null;
const node = await connectLinuxNode((payload) => {
invokeCount += 1;
const obj = payload as { paramsJSON?: unknown };
const raw = typeof obj?.paramsJSON === "string" ? obj.paramsJSON : "";
lastInvokeParams = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
});
const wsRequest = await connectTrustedBackend(["operator.write", "operator.approvals"]);
const wsReplay = await connectTrustedBackend(["operator.write", "operator.approvals"]);
try {
const nodeId = await getConnectedNodeId(wsRequest);
const context: ChatApprovalContext = {
agentId: "main",
sessionKey: "agent:main:telegram:direct:12345",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:12345",
turnSourceAccountId: "work",
turnSourceThreadId: "42",
};
const approvalId = await requestChatAllowOnceApproval({
ws: wsRequest,
command: "echo chat",
nodeId,
context,
});
const invoke = await rpcReq(wsReplay, "node.invoke", {
nodeId,
command: "system.run",
params: {
command: ["echo", "chat"],
rawCommand: "echo chat",
agentId: context.agentId,
sessionKey: context.sessionKey,
turnSourceChannel: context.turnSourceChannel,
turnSourceTo: context.turnSourceTo,
turnSourceAccountId: context.turnSourceAccountId,
turnSourceThreadId: context.turnSourceThreadId,
runId: approvalId,
approved: true,
approvalDecision: "allow-once",
},
idempotencyKey: crypto.randomUUID(),
});
expect(invoke.ok).toBe(true);
for (let i = 0; i < 100; i += 1) {
if (lastInvokeParams) {
break;
}
await sleep(50);
}
const forwardedParams = requireRecord(lastInvokeParams, "forwarded invoke params");
expect(forwardedParams["approved"]).toBe(true);
expect(forwardedParams["approvalDecision"]).toBe("allow-once");
expect(forwardedParams["turnSourceTo"]).toBeUndefined();
const mismatchApprovalId = await requestChatAllowOnceApproval({
ws: wsRequest,
command: "echo chat",
nodeId,
context,
});
const invokeCountBeforeMismatch = invokeCount;
const mismatch = await rpcReq(wsReplay, "node.invoke", {
nodeId,
command: "system.run",
params: {
command: ["echo", "chat"],
rawCommand: "echo chat",
agentId: context.agentId,
sessionKey: context.sessionKey,
turnSourceChannel: context.turnSourceChannel,
turnSourceTo: "telegram:67890",
turnSourceAccountId: context.turnSourceAccountId,
turnSourceThreadId: context.turnSourceThreadId,
runId: mismatchApprovalId,
approved: true,
approvalDecision: "allow-once",
},
idempotencyKey: crypto.randomUUID(),
});
expect(mismatch.ok).toBe(false);
expect(mismatch.error?.message ?? "").toContain("not valid for this client");
await expectNoForwardedInvoke(() => invokeCount > invokeCountBeforeMismatch);
} finally {
wsRequest.close();
wsReplay.close();
node.stop();
}
});
test("blocks cross-node replay on same device", async () => {
const invokeCounts = new Map<string, number>();
const onInvoke = (payload: unknown) => {