From 8ead938c7ca16f5c4a90dd5b7c3bd34bb3b99bd4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 9 May 2026 22:10:21 -0500 Subject: [PATCH] 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 d1392a873cecd3437c46a163d12722eb3ce0f2a3 - ClawSweeper re-review completed on the approval replay head --- CHANGELOG.md | 1 + .../bash-tools.exec-host-node-phases.ts | 12 + src/agents/bash-tools.exec-host-node.test.ts | 8 + src/agents/bash-tools.exec-host-node.ts | 4 + .../node-invoke-system-run-approval.test.ts | 288 ++++++++++++++++++ .../node-invoke-system-run-approval.ts | 100 +++++- ...server.node-invoke-approval-bypass.test.ts | 164 ++++++++++ 7 files changed, 576 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4159dae38f..4dc6f15fc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index 1e38b355a01..753b06a47c2 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -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, diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 2d676736885..1a48ab6e359 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -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"] }, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index dbf5f5f225c..847423ceb65 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -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 diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index bef7564a46e..087d2c327cc 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -102,6 +102,38 @@ describe("sanitizeSystemRunParamsForForwarding", () => { expect(result.details?.code).toBe(code); } + function makeChatRecord(overrides: Partial = {}) { + 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; + 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"; diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 41bb259e10c..b51022fc968 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -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): Record { // 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", diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 589d4bbd1c1..c5347570182 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -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 { + 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>["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((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 | 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) : 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(); const onInvoke = (payload: unknown) => {