From 0b75ea3cefcd1e4532f9b5c08d455433debdcba7 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:50:39 -0700 Subject: [PATCH] fix(talk): preserve caller tool authority in consults (#125392) --- .../chat-send-agent-dispatch.ts | 3 ++ .../server-methods/chat-send-handler.ts | 11 ++++++- .../chat.directive-tags.test.ts | 28 ++++++++++++++-- src/gateway/server-methods/talk-client.ts | 2 ++ src/gateway/server-methods/talk-session.ts | 2 ++ src/gateway/server-methods/talk.test.ts | 33 ++++++++++++++++++- src/gateway/talk-agent-consult.ts | 17 ++++++++-- ...ient-gateway-control.agent-consult.test.ts | 25 ++++++++++++-- src/gateway/talk-client-gateway-control.ts | 24 ++++++++++++++ .../talk-realtime-relay-session-create.ts | 1 + src/gateway/talk-realtime-relay-state.ts | 2 ++ 11 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/gateway/server-methods/chat-send-agent-dispatch.ts b/src/gateway/server-methods/chat-send-agent-dispatch.ts index f9639455436c..cf6990e33596 100644 --- a/src/gateway/server-methods/chat-send-agent-dispatch.ts +++ b/src/gateway/server-methods/chat-send-agent-dispatch.ts @@ -60,6 +60,7 @@ type StartChatDispatchParams = { attachments: PreparedChatSendAttachments; client: GatewayRequestHandlerOptions["client"]; context: GatewayRequestHandlerOptions["context"]; + toolsAllow?: string[]; cronCreatorAuthority: ReturnType; externalAuthorityAdmission: ChatSendExternalAuthorityAdmission | undefined; injection: { @@ -89,6 +90,7 @@ export function startChatDispatch(params: StartChatDispatchParams): void { attachments, client, context, + toolsAllow, cronCreatorAuthority, externalAuthorityAdmission, injection, @@ -248,6 +250,7 @@ export function startChatDispatch(params: StartChatDispatchParams): void { dispatchInboundMessageWithProjectedDispatcher({ ctx, cfg, + toolsAllow, dispatcherOptions: replyDispatch.dispatcherOptions, onSessionMetadataChanges: (changes) => changes.forEach((change) => emitSessionsChanged(context, change)), diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 64ba78f7d0bd..7c73ee7a167b 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -31,7 +31,7 @@ async function handleChatSendWithOptions( { params, respond, context, client }: GatewayRequestHandlerOptions, onAdmissionOwned?: () => Promise, externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission, - options?: { trustedSystemInput?: boolean }, + options?: { trustedSystemInput?: boolean; toolsAllow?: string[] }, ): Promise { const setup = await prepareAndAdmitChatSend( { params, respond, context, client }, @@ -273,6 +273,7 @@ async function handleChatSendWithOptions( attachments: preparedAttachments.value, client, context, + toolsAllow: options?.toolsAllow, cronCreatorAuthority, externalAuthorityAdmission, injection: { @@ -311,6 +312,14 @@ export async function handleChatSend( await handleChatSendWithOptions(options, onAdmissionOwned, externalAuthorityAdmission); } +/** Dispatches an internally delegated turn within its caller-owned tool boundary. */ +export async function handleChatSendWithRuntimeTools( + options: GatewayRequestHandlerOptions, + toolsAllow: string[], +): Promise { + await handleChatSendWithOptions(options, undefined, undefined, { toolsAllow }); +} + /** Dispatches Gateway-authored system input without widening the public chat-send contract. */ export async function handleTrustedInternalChatSend( options: GatewayRequestHandlerOptions, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index f854cda58da4..1e73d9eaf206 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -61,7 +61,7 @@ import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shar import { consumeCronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import { createChatRunState } from "../server-chat-state.js"; import { STALE_WORKER_BUILD_REASON } from "../worker-environments/admission.js"; -import { handleChatSend } from "./chat-send-handler.js"; +import { handleChatSend, handleChatSendWithRuntimeTools } from "./chat-send-handler.js"; import type { GatewayRequestContext, RespondFn } from "./types.js"; type ProjectedDispatchParams = Parameters< @@ -1161,6 +1161,7 @@ async function runNonStreamingChatSend(params: { waitForCompletion?: boolean; waitForDedupe?: boolean; waitFor?: NonStreamingChatSendWaitFor; + runtimeToolsAllow?: string[]; }): Promise | undefined> { const sendParams: { sessionKey: string; @@ -1179,7 +1180,7 @@ async function runNonStreamingChatSend(params: { params.directExternal === false ? handleChatSend : expectDefined(chatHandlers["chat.send"], 'chatHandlers["chat.send"] test invariant'); - await handler({ + const handlerOptions = { params: { ...sendParams, ...params.requestParams, @@ -1189,7 +1190,12 @@ async function runNonStreamingChatSend(params: { client: (params.client ?? null) as never, isWebchatConnect: () => false, context: params.context, - }); + }; + if (params.runtimeToolsAllow) { + await handleChatSendWithRuntimeTools(handlerOptions, params.runtimeToolsAllow); + } else { + await handler(handlerOptions); + } const waitFor = params.waitFor ?? @@ -1360,6 +1366,22 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.cronAuthorityProbe = undefined; }); + it("carries an internal runtime tool cap into agent dispatch", async () => { + await createGatewayUserTurnSqliteFixture("openclaw-chat-send-runtime-tools-"); + const { send } = createChatRequestFixture(); + + await send({ + idempotencyKey: "idem-runtime-tools", + directExternal: false, + runtimeToolsAllow: ["read"], + waitFor: "dedupe", + }); + + expect(dispatchInboundMessageMock).toHaveBeenLastCalledWith( + expect.objectContaining({ toolsAllow: ["read"] }), + ); + }); + it.each([ ["stale", "previous-leaf"], ["empty", null], diff --git a/src/gateway/server-methods/talk-client.ts b/src/gateway/server-methods/talk-client.ts index ea65d41b9125..b684f14c6578 100644 --- a/src/gateway/server-methods/talk-client.ts +++ b/src/gateway/server-methods/talk-client.ts @@ -58,6 +58,7 @@ import { closeTalkClientGatewayControlSession, createTalkClientAgentConsultRunner, createTalkClientGatewayControlOwner, + resolveTalkAgentConsultAuthority, } from "../talk-client-gateway-control.js"; import { ensureTalkRealtimeRelayVoiceSession, @@ -299,6 +300,7 @@ export const talkClientHandlers: GatewayRequestHandlers = { agentId, sessionKey, ...(ownerConnId ? { ownerConnId } : {}), + authority: resolveTalkAgentConsultAuthority(client?.connect?.scopes), getVoiceSessionId: () => activeVoiceSessionId, initialItems, }); diff --git a/src/gateway/server-methods/talk-session.ts b/src/gateway/server-methods/talk-session.ts index 5f81da3e61cb..a753128fdd6e 100644 --- a/src/gateway/server-methods/talk-session.ts +++ b/src/gateway/server-methods/talk-session.ts @@ -23,6 +23,7 @@ import { resolveConfiguredRealtimeVoiceProvider } from "../../talk/provider-reso import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; +import { resolveTalkAgentConsultAuthority } from "../talk-client-gateway-control.js"; import { createTalkHandoff, getTalkHandoff, revokeTalkHandoff } from "../talk-handoff.js"; import { cancelTalkRealtimeRelayTurn, @@ -297,6 +298,7 @@ export const talkSessionHandlers: GatewayRequestHandlers = { context, connId, cfg: runtimeConfig, + consultAuthority: resolveTalkAgentConsultAuthority(client?.connect?.scopes), provider: resolution.provider, providerConfig: relayLaunch.providerConfig, instructions: buildRealtimeInstructions(realtimeContext.instructions), diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 0a104ff9f2e4..aaa119c67cad 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -185,6 +185,7 @@ vi.mock("../../talk/client-voice-session.js", async (importOriginal) => { vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.chatSend, + handleChatSendWithRuntimeTools: mocks.chatSend, })); vi.mock("../sessions-resolve.js", () => ({ @@ -1653,6 +1654,7 @@ describe("talk.session unified handlers", () => { language: "de", }, respond: createRespond, + client: { connId: "conn-1", connect: { scopes: ["operator.talk"] } }, context: { getRuntimeConfig: () => ({ @@ -1687,7 +1689,15 @@ describe("talk.session unified handlers", () => { string, unknown >; - expectRecordFields(relayCreateInput, { connId: "conn-1", provider, language: "de" }); + expectRecordFields(relayCreateInput, { + connId: "conn-1", + provider, + language: "de", + consultAuthority: { + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], + }, + }); expectRecordFields(relayCreateInput.providerConfig, { apiKey: "openai-key", model: "gpt-realtime", @@ -2495,6 +2505,7 @@ describe("talk.client.toolCall handler", () => { args: { question: "What is in this repo?", responseStyle: "one sentence" }, }, respond, + client: { connId: "conn-1", connect: { scopes: ["operator.talk"] } }, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, }, @@ -2508,6 +2519,14 @@ describe("talk.client.toolCall handler", () => { expectRecordFields(chatInput.params, { sessionKey: "main" }); expect(chatInput.params?.message).toContain("What is in this repo?"); expect(chatInput.params?.idempotencyKey).toMatch(/^talk-call-1-/); + expect(mockCallArg(mocks.chatSend, 0, 1)).toEqual([ + "read", + "web_search", + "web_fetch", + "x_search", + "memory_search", + "memory_get", + ]); const response = expectRespondOk(respond, { runId: "run-voice-1" }) as Record; expect(response.idempotencyKey).toMatch(/^talk-call-1-/); }); @@ -2554,6 +2573,7 @@ describe("talk.client.toolCall handler", () => { args: { question: "Are the basement lights off?" }, }, respond, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, context: { getRuntimeConfig: () => ({ @@ -2570,6 +2590,7 @@ describe("talk.client.toolCall handler", () => { thinking: "low", fastMode: true, }); + expect(mockCallArg(mocks.chatSend, 0, 1)).toBeUndefined(); expectRespondOk(respond, { runId: "run-voice-1" }); }); @@ -2835,6 +2856,7 @@ describe("talk.client.create handler", () => { reasoningEffort: "low", }, respond, + client: { connId: "conn-1", connect: { scopes: ["operator.talk"] } }, context: { getRuntimeConfig: () => ({ @@ -2903,6 +2925,8 @@ describe("talk.client.create handler", () => { ], surface: "a browser Talk session", abortSignal: consultSignal, + senderIsOwner: false, + toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"], }), ); expect(createInput).not.toHaveProperty("provider"); @@ -2959,6 +2983,7 @@ describe("talk.client.create handler", () => { await callTalkHandler("talk.client.create", { params: { sessionKey: "main", model: "gpt-live-1" }, respond, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, context: { getRuntimeConfig: () => ({ @@ -2983,6 +3008,12 @@ describe("talk.client.create handler", () => { model: "gpt-live-1", runAgentConsult: expect.any(Function), }); + await ( + createInput.runAgentConsult as (params: { prompt: string }) => Promise<{ text: string }> + )({ prompt: "Check the repository" }); + const consultInput = mockCallArg(mocks.consultRealtimeVoiceAgent) as Record; + expect(consultInput.senderIsOwner).toBe(false); + expect(consultInput).not.toHaveProperty("toolsAllow"); expect(createInput).not.toHaveProperty("tools"); expectRespondOk(respond, { provider: "openai", transport: "webrtc" }); }); diff --git a/src/gateway/talk-agent-consult.ts b/src/gateway/talk-agent-consult.ts index d41e5a2a1f89..fb6f5590a363 100644 --- a/src/gateway/talk-agent-consult.ts +++ b/src/gateway/talk-agent-consult.ts @@ -10,12 +10,16 @@ import { import { normalizeTalkSection } from "../config/talk.js"; import { buildRealtimeVoiceAgentConsultChatMessage } from "../talk/agent-consult-tool.js"; import { abortChatRunById } from "./chat-abort.js"; -import { handleChatSend } from "./server-methods/chat-send-handler.js"; +import { + handleChatSend, + handleChatSendWithRuntimeTools, +} from "./server-methods/chat-send-handler.js"; import type { GatewayClient, GatewayRequestContext, GatewayRequestHandlerOptions, } from "./server-methods/shared-types.js"; +import { resolveTalkAgentConsultAuthority } from "./talk-client-gateway-control.js"; import { registerTalkRealtimeRelayAgentRun } from "./talk-realtime-relay.js"; import { formatForLog } from "./ws-log.js"; @@ -78,12 +82,13 @@ export async function startTalkRealtimeAgentConsult(params: { } const idempotencyKey = `talk-${params.callId}-${randomUUID()}`; const normalizedTalk = normalizeTalkSection(params.context.getRuntimeConfig().talk); + const authority = resolveTalkAgentConsultAuthority(params.client?.connect?.scopes); let acknowledgedRunId: string | undefined; const chatResponse = await new Promise< { ok: true; result: unknown } | { ok: false; error: ErrorShape } | undefined >((resolve) => { let acknowledged = false; - const chatSendResult = handleChatSend({ + const chatSendOptions = { req: { type: "req", id: `${params.requestId}:talk-tool-call`, @@ -146,7 +151,13 @@ export async function startTalkRealtimeAgentConsult(params: { }, ); }, - } as GatewayRequestHandlerOptions); + } as GatewayRequestHandlerOptions; + // talk.client.toolCall enters below the normal chat.send scope gate, so its + // delegated run must carry the Talk caller's already-resolved tool boundary. + const chatSendResult = + authority.toolsAllow !== undefined + ? handleChatSendWithRuntimeTools(chatSendOptions, authority.toolsAllow) + : handleChatSend(chatSendOptions); void Promise.resolve(chatSendResult).then( () => { if (!acknowledged) { diff --git a/src/gateway/talk-client-gateway-control.agent-consult.test.ts b/src/gateway/talk-client-gateway-control.agent-consult.test.ts index 4f04dcb6070b..d6337ce411ba 100644 --- a/src/gateway/talk-client-gateway-control.agent-consult.test.ts +++ b/src/gateway/talk-client-gateway-control.agent-consult.test.ts @@ -36,7 +36,10 @@ vi.mock("../talk/agent-consult-runtime.js", () => ({ consultRealtimeVoiceAgent: mocks.consultRealtimeVoiceAgent, })); -import { createTalkClientAgentConsultRunner } from "./talk-client-gateway-control.js"; +import { + createTalkClientAgentConsultRunner, + type TalkAgentConsultAuthority, +} from "./talk-client-gateway-control.js"; const config = {} as OpenClawConfig; const coreParams = { @@ -54,12 +57,16 @@ const coreParams = { workspaceDir: "/tmp/workspace", } as Parameters[0]; -function createRunner(registerRun = vi.fn()) { +function createRunner( + registerRun = vi.fn(), + authority: TalkAgentConsultAuthority = { senderIsOwner: false, toolsAllow: ["read"] }, +) { return createTalkClientAgentConsultRunner({ config, context: { chatAbortControllers: new Map(), logGateway: { warn: vi.fn() } } as never, agentId: "researcher", sessionKey: "agent:researcher:talk", + authority, getVoiceSessionId: () => "voice-session", initialItems: [], registerRun, @@ -107,9 +114,23 @@ describe("Talk client agent consult admission", () => { preparedRunAdmission: expect.objectContaining({ close: mocks.close }), }), ); + expect(mocks.consultRealtimeVoiceAgent).toHaveBeenCalledWith( + expect.objectContaining({ senderIsOwner: false, toolsAllow: ["read"] }), + ); expect(mocks.close).toHaveBeenCalledOnce(); }); + it("preserves full agent authority for administrator consults", async () => { + await expect( + createRunner(vi.fn(), { senderIsOwner: true }).runPrompt({ prompt: "check" }), + ).resolves.toEqual({ text: "done" }); + + expect(mocks.consultRealtimeVoiceAgent).toHaveBeenCalledWith( + expect.objectContaining({ senderIsOwner: true }), + ); + expect(mocks.consultRealtimeVoiceAgent.mock.calls[0]?.[0]).not.toHaveProperty("toolsAllow"); + }); + it("closes the Talk admission when core execution fails", async () => { mocks.runEmbeddedAgentCore.mockRejectedValueOnce(new Error("core failed")); diff --git a/src/gateway/talk-client-gateway-control.ts b/src/gateway/talk-client-gateway-control.ts index 6520c6d5a867..601c9ac76479 100644 --- a/src/gateway/talk-client-gateway-control.ts +++ b/src/gateway/talk-client-gateway-control.ts @@ -8,6 +8,7 @@ import { consultRealtimeVoiceAgent } from "../talk/agent-consult-runtime.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, parseRealtimeVoiceAgentConsultArgs, + resolveRealtimeVoiceAgentConsultToolsAllow, } from "../talk/agent-consult-tool.js"; import { buildRealtimeVoiceAgentCancelProviderResult, @@ -34,6 +35,7 @@ import { } from "../talk/realtime-session-harness.js"; import type { TalkEvent } from "../talk/talk-events.js"; import { registerChatAbortController } from "./chat-abort.js"; +import { ADMIN_SCOPE, WRITE_SCOPE } from "./operator-scopes.js"; import type { GatewayRequestContext } from "./server-methods/shared-types.js"; import { formatError } from "./server-utils.js"; import { registerTalkConnectionCleanup } from "./talk-session-registry.js"; @@ -54,6 +56,25 @@ const owners = new Map(); const REALTIME_VOICE_CONTEXT_MAX_UTF8_BYTES = 8_000; const REALTIME_CONTROL_MAX_PENDING = 8; + +export type TalkAgentConsultAuthority = { + senderIsOwner: boolean; + toolsAllow?: string[]; +}; + +export function resolveTalkAgentConsultAuthority( + scopes: readonly string[] | undefined, +): TalkAgentConsultAuthority { + const senderIsOwner = scopes?.includes(ADMIN_SCOPE) === true; + if (senderIsOwner || scopes?.includes(WRITE_SCOPE) === true) { + return { senderIsOwner }; + } + return { + senderIsOwner: false, + toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow("safe-read-only"), + }; +} + const loadTalkAgentExecution = createLazyRuntimeModule(async () => { const [embeddedAgent, admission] = await Promise.all([ import("../agents/embedded-agent.js"), @@ -213,12 +234,14 @@ export function createTalkClientAgentConsultRunner(params: { agentId: string; sessionKey: string; ownerConnId?: string; + authority?: TalkAgentConsultAuthority; getVoiceSessionId: () => string | undefined; initialItems: Array<{ role: "user" | "assistant"; text: string }>; runIdPrefix?: string; surface?: string; registerRun?: (params: { runId: string }) => void; }) { + const authority = params.authority ?? resolveTalkAgentConsultAuthority(undefined); let agentRuntime: ReturnType["agent"] | undefined; const runArgs = async (args: unknown, signal?: AbortSignal) => { const parsedArgs = parseRealtimeVoiceAgentConsultArgs(args); @@ -255,6 +278,7 @@ export function createTalkClientAgentConsultRunner(params: { questionSourceLabel: "user", thinkLevel: talkConfig?.consultThinkingLevel, fastMode: talkConfig?.consultFastMode, + ...authority, abortSignal: signal, onRunStarted: ({ runId, sessionId, timeoutMs }) => { if (params.registerRun) { diff --git a/src/gateway/talk-realtime-relay-session-create.ts b/src/gateway/talk-realtime-relay-session-create.ts index 6e31be18a35e..9f6089a97d47 100644 --- a/src/gateway/talk-realtime-relay-session-create.ts +++ b/src/gateway/talk-realtime-relay-session-create.ts @@ -135,6 +135,7 @@ export function createTalkRealtimeRelaySession( ), sessionKey: relaySessionKey, ownerConnId: params.connId, + authority: params.consultAuthority, getVoiceSessionId: () => relaySessionId, initialItems: [], runIdPrefix: "talk-realtime-relay-consult", diff --git a/src/gateway/talk-realtime-relay-state.ts b/src/gateway/talk-realtime-relay-state.ts index af274ebd900d..ae16e1439cab 100644 --- a/src/gateway/talk-realtime-relay-state.ts +++ b/src/gateway/talk-realtime-relay-state.ts @@ -14,6 +14,7 @@ import type { RealtimeVoiceSessionHarness } from "../talk/realtime-session-harne import type { RealtimeVoiceBridgeSession } from "../talk/session-runtime.js"; import type { TalkEvent } from "../talk/talk-session-controller.js"; import type { GatewayRequestContext } from "./server-methods/shared-types.js"; +import type { TalkAgentConsultAuthority } from "./talk-client-gateway-control.js"; import type { RelayToolCallLedger } from "./talk-realtime-relay-tool-call-ledger.js"; export const RELAY_SESSION_TTL_MS = 30 * 60 * 1000; @@ -122,6 +123,7 @@ export type CreateTalkRealtimeRelaySessionParams = { context: GatewayRequestContext; connId: string; cfg?: OpenClawConfig; + consultAuthority?: TalkAgentConsultAuthority; provider: RealtimeVoiceProviderPlugin; providerConfig: RealtimeVoiceProviderConfig; instructions: string;