fix(talk): preserve caller tool authority in consults (#125392)

This commit is contained in:
Josh Avant
2026-08-17 14:50:39 -07:00
committed by GitHub
parent 7b8d5f1637
commit 0b75ea3cef
11 changed files with 138 additions and 10 deletions
@@ -60,6 +60,7 @@ type StartChatDispatchParams = {
attachments: PreparedChatSendAttachments;
client: GatewayRequestHandlerOptions["client"];
context: GatewayRequestHandlerOptions["context"];
toolsAllow?: string[];
cronCreatorAuthority: ReturnType<ChatSendExternalAuthorityAdmission["resolve"]>;
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)),
@@ -31,7 +31,7 @@ async function handleChatSendWithOptions(
{ params, respond, context, client }: GatewayRequestHandlerOptions,
onAdmissionOwned?: () => Promise<boolean>,
externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission,
options?: { trustedSystemInput?: boolean },
options?: { trustedSystemInput?: boolean; toolsAllow?: string[] },
): Promise<void> {
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<void> {
await handleChatSendWithOptions(options, undefined, undefined, { toolsAllow });
}
/** Dispatches Gateway-authored system input without widening the public chat-send contract. */
export async function handleTrustedInternalChatSend(
options: GatewayRequestHandlerOptions,
@@ -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<Record<string, any> | 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],
@@ -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,
});
@@ -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),
+32 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
expect(consultInput.senderIsOwner).toBe(false);
expect(consultInput).not.toHaveProperty("toolsAllow");
expect(createInput).not.toHaveProperty("tools");
expectRespondOk(respond, { provider: "openai", transport: "webrtc" });
});
+14 -3
View File
@@ -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) {
@@ -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<PluginRuntime["agent"]["runEmbeddedAgent"]>[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"));
@@ -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<string, GatewayControlOwner>();
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<typeof createPluginRuntime>["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) {
@@ -135,6 +135,7 @@ export function createTalkRealtimeRelaySession(
),
sessionKey: relaySessionKey,
ownerConnId: params.connId,
authority: params.consultAuthority,
getVoiceSessionId: () => relaySessionId,
initialItems: [],
runIdPrefix: "talk-realtime-relay-consult",
+2
View File
@@ -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;