fix(voice-call): preserve per-call agent routing (#77763)

* fix(voice-call): preserve per-call agent routing

Co-authored-by: Tran Quang <randytran8800@gmail.com>

* chore: keep release notes in PR metadata

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
Tran Quang 2026-07-08 13:06:35 +07:00 committed by GitHub
parent 8cb9263692
commit 19f7b72a74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 953 additions and 209 deletions

View file

@ -805,6 +805,12 @@ Twilio-only config:
With `voiceCall.enabled: true` (the default) and Twilio transport, Voice Call places the DTMF sequence before opening the realtime media stream, then uses the saved intro text as the initial realtime greeting. If `voice-call` is not enabled, Google Meet can still validate and record the dial plan but cannot place the Twilio call.
Leave `voiceCall.gatewayUrl` unset to use the local trusted Gateway runtime, which preserves the
invoking agent for the full call. A configured Gateway URL remains an explicit WebSocket target and
cannot authenticate plugin provenance; non-default agent joins fail closed instead of silently
using another agent. Run Google Meet and Voice Call in the same Gateway process when per-agent
routing is required.
## Tool
Agents use the `google_meet` tool:

View file

@ -3,7 +3,7 @@ summary: "api.runtime -- the injected runtime helpers available to plugins"
title: "Plugin runtime helpers"
sidebarTitle: "Runtime helpers"
read_when:
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, subagent, nodes)
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, Gateway, subagent, nodes)
- You want to understand what api.runtime exposes
- You are accessing config, agent, or media helpers from plugin code
---
@ -214,6 +214,27 @@ two-party event loops that do not go through the shared inbound reply runner.
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`.
</Warning>
</Accordion>
<Accordion title="api.runtime.gateway">
Call another Gateway method in process while preserving the current plugin's trusted runtime
identity. This is intended for bundled or trusted official plugins that compose plugin-owned
Gateway capabilities without opening a loopback WebSocket connection.
```typescript
if (await api.runtime.gateway.isAvailable()) {
const result = await api.runtime.gateway.request<{ callId: string }>(
"voicecall.start",
{ to: "+15550001234", mode: "conversation" },
{ timeoutMs: 60_000 },
);
}
```
Requests use `operator.write` scope and do not grant admin scope. Calls from arbitrary external
plugins are rejected. Failed methods throw a `GatewayClientRequestError`, preserving structured
`details`, retry metadata, and the Gateway error code for recovery flows. Use `isAvailable()`
before choosing this path from tools that can also run in standalone agent processes.
</Accordion>
<Accordion title="api.runtime.subagent">
Launch and manage background subagent runs.

View file

@ -13,6 +13,7 @@ import {
import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js";
const voiceCallMocks = vi.hoisted(() => ({
createVoiceCallGateway: vi.fn(({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway),
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
callId: "call-1",
dtmfSent: true,
@ -46,6 +47,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
});
vi.mock("./src/voice-call-gateway.js", () => ({
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway,

View file

@ -59,6 +59,9 @@ type GoogleMeetManifestConfigSchema = JsonSchemaObject & {
};
const voiceCallMocks = vi.hoisted(() => ({
createVoiceCallGateway: vi.fn(
({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway,
),
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
callId: "call-1",
dtmfSent: true,
@ -99,6 +102,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
});
vi.mock("./src/voice-call-gateway.js", () => ({
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
getMeetVoiceCallGatewayCall: voiceCallMocks.getMeetVoiceCallGatewayCall,
@ -1451,6 +1455,104 @@ describe("google-meet plugin", () => {
expect(gatewayJoinParams.requesterSessionKey).toBe("agent:main:discord:channel:general");
});
it("keeps Twilio calls on the agent that invoked the tool", async () => {
const { tools, gatewayRequest } = setup(
{ defaultTransport: "twilio" },
{
gatewayAvailable: true,
toolContext: { agentId: "Support", sessionKey: "agent:support:main" },
},
);
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
};
const result = await tool.execute("id", {
action: "join",
url: "https://meet.google.com/abc-defg-hij",
dialInNumber: "+15551234567",
});
expect(gatewayRequest).toHaveBeenCalledWith(
"googlemeet.join",
expect.objectContaining({
agentId: "support",
requesterSessionKey: "agent:support:main",
}),
{ timeoutMs: 60_000 },
);
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "support",
sessionKey: `agent:support:google-meet:${result.details.session.id}`,
}),
);
});
it("fails closed for standalone non-default agent routing", async () => {
const { tools } = setup(
{},
{ toolContext: { agentId: "support", sessionKey: "agent:support:main" } },
);
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
};
const result = await tool.execute("id", {
action: "join",
url: "https://meet.google.com/abc-defg-hij",
});
expect(result.details.error).toContain("requires a Gateway-hosted agent run");
});
it("preserves structured recovery details from the running Gateway", async () => {
const { tools } = setup(
{},
{ toolContext: { agentId: "main", sessionKey: "agent:main:main" } },
);
googleMeetPluginTesting.setCallGatewayFromCliForTests(async () => {
throw Object.assign(new Error("browser login required"), {
details: {
manualActionRequired: true,
reason: "not-authenticated",
browser: { profile: "openclaw" },
},
});
});
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
};
const result = await tool.execute("id", {
action: "join",
url: "https://meet.google.com/abc-defg-hij",
});
expect(result.details).toEqual({
manualActionRequired: true,
reason: "not-authenticated",
browser: { profile: "openclaw" },
});
});
it("does not accept agent routing from an external gateway caller", async () => {
const { methods } = setup({ defaultTransport: "twilio" });
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
url: "https://meet.google.com/abc-defg-hij",
dialInNumber: "+15551234567",
agentId: "spoofed",
});
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
expect.objectContaining({
agentId: undefined,
sessionKey: expect.stringMatching(/^voice:google-meet:meet_/),
}),
);
});
it("explains that Twilio joins need dial-in details", async () => {
const { tools } = setup({ defaultTransport: "twilio" });
const tool = tools[0] as {
@ -1483,12 +1585,10 @@ describe("google-meet plugin", () => {
const [endParams] = mockCall(voiceCallMocks.endMeetVoiceCallGatewayCall) as [
Record<string, unknown>,
];
expect(requireRecord(endParams.config, "voice-call end config").defaultTransport).toBe(
"twilio",
);
expect(endParams.gateway).toBeDefined();
expect(endParams.callId).toBe("call-1");
expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
config: endParams.config,
gateway: endParams.gateway,
callId: "call-1",
});
});
@ -1543,13 +1643,11 @@ describe("google-meet plugin", () => {
const [speakParams] = voiceCallMocks.speakMeetViaVoiceCallGateway.mock.calls.at(
0,
) as unknown as [Record<string, unknown>];
expect(requireRecord(speakParams.config, "voice-call speak config").defaultTransport).toBe(
"twilio",
);
expect(speakParams.gateway).toBeDefined();
expect(speakParams.callId).toBe("call-1");
expect(speakParams.message).toBe("Say exactly: hello after joining.");
expect(voiceCallMocks.speakMeetViaVoiceCallGateway).toHaveBeenCalledWith({
config: speakParams.config,
gateway: speakParams.gateway,
callId: "call-1",
message: "Say exactly: hello after joining.",
});

View file

@ -12,6 +12,7 @@ import {
} from "openclaw/plugin-sdk/gateway-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
@ -472,8 +473,16 @@ async function callGoogleMeetGatewayFromTool(params: {
config: GoogleMeetConfig;
action: GoogleMeetGatewayToolAction;
raw: Record<string, unknown>;
runtime?: OpenClawPluginApi["runtime"];
}): Promise<unknown> {
try {
if (params.runtime) {
return await params.runtime.gateway.request(
googleMeetGatewayMethodForToolAction(params.action),
params.raw,
{ timeoutMs: resolveGoogleMeetGatewayOperationTimeoutMs(params.config) },
);
}
return await googleMeetToolDeps.callGatewayFromCli(
googleMeetGatewayMethodForToolAction(params.action),
{
@ -492,6 +501,18 @@ async function callGoogleMeetGatewayFromTool(params: {
}
}
function keepTrustedToolAgentId(
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): Record<string, unknown> {
const { agentId: rawAgentId, ...rest } = raw;
if (client?.internal?.pluginRuntimeOwnerId !== "google-meet") {
return rest;
}
const agentId = normalizeOptionalString(rawAgentId);
return agentId ? { ...rest, agentId } : rest;
}
async function createMeetFromParams(params: {
config: GoogleMeetConfig;
runtime: OpenClawPluginApi["runtime"];
@ -724,18 +745,20 @@ export default definePluginEntry({
api.registerGatewayMethod(
"googlemeet.join",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
const rt = await ensureRuntime();
const result = await rt.join({
url: resolveMeetingInput(config, params?.url),
transport: normalizeTransport(params?.transport),
mode: normalizeMode(params?.mode),
dialInNumber: normalizeOptionalString(params?.dialInNumber),
pin: normalizeOptionalString(params?.pin),
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
message: normalizeOptionalString(params?.message),
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
url: resolveMeetingInput(config, trustedParams.url),
transport: normalizeTransport(trustedParams.transport),
mode: normalizeMode(trustedParams.mode),
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
pin: normalizeOptionalString(trustedParams.pin),
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
message: normalizeOptionalString(trustedParams.message),
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
agentId: normalizeOptionalString(trustedParams.agentId),
});
respond(true, result);
} catch (err) {
@ -746,9 +769,9 @@ export default definePluginEntry({
api.registerGatewayMethod(
"googlemeet.create",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const raw = keepTrustedToolAgentId(asParamRecord(params), client);
respond(
true,
shouldJoinCreatedMeet(raw)
@ -976,18 +999,20 @@ export default definePluginEntry({
api.registerGatewayMethod(
"googlemeet.testSpeech",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
const rt = await ensureRuntime();
const result = await rt.testSpeech({
url: resolveMeetingInput(config, params?.url),
transport: normalizeTransport(params?.transport),
mode: normalizeMode(params?.mode),
dialInNumber: normalizeOptionalString(params?.dialInNumber),
pin: normalizeOptionalString(params?.pin),
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
message: normalizeOptionalString(params?.message),
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
url: resolveMeetingInput(config, trustedParams.url),
transport: normalizeTransport(trustedParams.transport),
mode: normalizeMode(trustedParams.mode),
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
pin: normalizeOptionalString(trustedParams.pin),
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
message: normalizeOptionalString(trustedParams.message),
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
agentId: normalizeOptionalString(trustedParams.agentId),
});
respond(true, result);
} catch (err) {
@ -1024,8 +1049,17 @@ export default definePluginEntry({
async execute(_toolCallId, params) {
const raw = asParamRecord(params);
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const rawWithRequester = requesterSessionKey ? { ...raw, requesterSessionKey } : raw;
const agentId = toolContext.agentId ? normalizeAgentId(toolContext.agentId) : undefined;
try {
const useTrustedRuntime = agentId ? await api.runtime.gateway.isAvailable() : false;
if (agentId && agentId !== "main" && !useTrustedRuntime) {
throw new Error("Per-agent Google Meet routing requires a Gateway-hosted agent run.");
}
const rawWithRequester = {
...raw,
...(requesterSessionKey ? { requesterSessionKey } : {}),
...(useTrustedRuntime ? { agentId } : {}),
};
assertGoogleMeetAgentToolActionSupported({ config, raw });
switch (raw.action) {
case "join": {
@ -1034,6 +1068,7 @@ export default definePluginEntry({
config,
action: "join",
raw: rawWithRequester,
runtime: useTrustedRuntime ? api.runtime : undefined,
}),
);
}
@ -1043,6 +1078,7 @@ export default definePluginEntry({
config,
action: "create",
raw: rawWithRequester,
runtime: useTrustedRuntime ? api.runtime : undefined,
}),
);
}
@ -1052,6 +1088,7 @@ export default definePluginEntry({
config,
action: "test_speech",
raw: rawWithRequester,
runtime: useTrustedRuntime ? api.runtime : undefined,
}),
);
}

View file

@ -148,6 +148,7 @@ export async function createAndJoinMeetFromParams(params: {
dtmfSequence: normalizeOptionalString(params.raw.dtmfSequence),
message: normalizeOptionalString(params.raw.message),
requesterSessionKey: normalizeOptionalString(params.raw.requesterSessionKey),
agentId: normalizeOptionalString(params.raw.agentId),
});
return {
...created,

View file

@ -33,11 +33,13 @@ import type {
GoogleMeetSession,
} from "./transports/types.js";
import {
createVoiceCallGateway,
endMeetVoiceCallGatewayCall,
getMeetVoiceCallGatewayCall,
isVoiceCallMissingError,
joinMeetViaVoiceCallGateway,
speakMeetViaVoiceCallGateway,
type VoiceCallGateway,
} from "./voice-call-gateway.js";
type ChromeAudioBridgeResult = NonNullable<
@ -224,6 +226,7 @@ export class GoogleMeetRuntime {
readonly #sessionStops = new Map<string, () => Promise<void>>();
readonly #sessionSpeakers = new Map<string, (instructions?: string) => void>();
readonly #sessionHealth = new Map<string, () => GoogleMeetChromeHealth>();
readonly #voiceCallGateway: VoiceCallGateway;
constructor(
private readonly params: {
@ -232,7 +235,9 @@ export class GoogleMeetRuntime {
runtime: PluginRuntime;
logger: RuntimeLogger;
},
) {}
) {
this.#voiceCallGateway = createVoiceCallGateway(params);
}
list(): GoogleMeetSession[] {
this.#refreshHealth();
@ -434,11 +439,19 @@ export class GoogleMeetRuntime {
try {
if (transport === "chrome" || transport === "chrome-node") {
// Freeze the invoking agent into the bridge config so every later consult
// stays on the same workspace even if plugin defaults change mid-call.
const chromeConfig = request.agentId
? {
...this.params.config,
realtime: { ...this.params.config.realtime, agentId: request.agentId },
}
: this.params.config;
const result =
transport === "chrome-node"
? await launchChromeMeetOnNode({
runtime: this.params.runtime,
config: this.params.config,
config: chromeConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: request.requesterSessionKey,
@ -448,7 +461,7 @@ export class GoogleMeetRuntime {
})
: await launchChromeMeet({
runtime: this.params.runtime,
config: this.params.config,
config: chromeConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: request.requesterSessionKey,
@ -494,13 +507,17 @@ export class GoogleMeetRuntime {
const voiceCallResult = this.params.config.voiceCall.enabled
? await joinMeetViaVoiceCallGateway({
config: this.params.config,
gateway: this.#voiceCallGateway,
dialInNumber,
dtmfSequence,
logger: this.params.logger,
...(request.requesterSessionKey
? { requesterSessionKey: request.requesterSessionKey }
: {}),
sessionKey: buildTwilioVoiceCallSessionKey(session.id),
agentId: request.agentId,
sessionKey: request.agentId
? `agent:${request.agentId}:google-meet:${session.id}`
: buildTwilioVoiceCallSessionKey(session.id),
message: isGoogleMeetTalkBackMode(mode)
? (request.message ??
this.params.config.voiceCall.introMessage ??
@ -520,7 +537,7 @@ export class GoogleMeetRuntime {
if (voiceCallResult?.callId) {
this.#sessionStops.set(session.id, async () => {
await endMeetVoiceCallGatewayCall({
config: this.params.config,
gateway: this.#voiceCallGateway,
callId: voiceCallResult.callId,
});
});
@ -581,7 +598,7 @@ export class GoogleMeetRuntime {
if (session.transport === "twilio" && session.twilio?.voiceCallId) {
try {
await speakMeetViaVoiceCallGateway({
config: this.params.config,
gateway: this.#voiceCallGateway,
callId: session.twilio.voiceCallId,
message:
instructions ||
@ -856,7 +873,7 @@ export class GoogleMeetRuntime {
}
try {
const status = await getMeetVoiceCallGatewayCall({
config: this.params.config,
gateway: this.#voiceCallGateway,
callId,
});
if (status.found === false) {

View file

@ -48,6 +48,7 @@ export function setupGoogleMeetPlugin(
config: Record<string, unknown> = {},
options: {
fullConfig?: Record<string, unknown>;
gatewayAvailable?: boolean;
nodesListResult?: GoogleMeetTestNodeListResult;
nodesInvokeResult?: unknown;
browserActResult?: Record<string, unknown>;
@ -137,6 +138,9 @@ export function setupGoogleMeetPlugin(
return { code: 0, stdout: "", stderr: "" };
},
);
const gatewayRequest = vi.fn((method: string, params?: Record<string, unknown>) =>
invokeGoogleMeetGatewayMethodForTest(methods, method, params, "google-meet"),
);
const api = createTestPluginApi({
id: "google-meet",
name: "Google Meet",
@ -146,6 +150,10 @@ export function setupGoogleMeetPlugin(
config: options.fullConfig ?? {},
pluginConfig: config,
runtime: {
gateway: {
isAvailable: vi.fn(async () => options.gatewayAvailable === true),
request: gatewayRequest,
},
system: {
runCommandWithTimeout,
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
@ -187,6 +195,7 @@ export function setupGoogleMeetPlugin(
nodesInvoke,
nodeHostCommands,
nodeInvokePolicies,
gatewayRequest,
};
}
@ -194,10 +203,12 @@ export async function invokeGoogleMeetGatewayMethodForTest(
methods: Map<string, unknown>,
method: string,
params?: unknown,
pluginRuntimeOwnerId?: string,
): Promise<unknown> {
const handler = methods.get(method) as
| ((opts: {
params: Record<string, unknown>;
client?: { internal?: { pluginRuntimeOwnerId?: string } };
respond: (
ok: boolean,
payload?: unknown,
@ -229,6 +240,7 @@ export async function invokeGoogleMeetGatewayMethodForTest(
params: (params && typeof params === "object" && !Array.isArray(params)
? params
: {}) as Record<string, unknown>,
...(pluginRuntimeOwnerId ? { client: { internal: { pluginRuntimeOwnerId } } } : {}),
respond,
}),
).catch(reject);

View file

@ -9,6 +9,8 @@ export type GoogleMeetJoinRequest = {
mode?: GoogleMeetModeInput;
message?: string;
requesterSessionKey?: string;
/** Agent selected by the calling tool context. */
agentId?: string;
timeoutMs?: number;
dialInNumber?: string;
pin?: string;

View file

@ -2,12 +2,14 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
createVoiceCallGateway,
endMeetVoiceCallGatewayCall,
getMeetVoiceCallGatewayCall,
joinMeetViaVoiceCallGateway,
} from "./voice-call-gateway.js";
const gatewayMocks = vi.hoisted(() => ({
runtimeRequest: vi.fn(),
request: vi.fn(),
stopAndWait: vi.fn(async () => {}),
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })),
@ -28,7 +30,9 @@ describe("Google Meet voice-call gateway", () => {
beforeEach(() => {
vi.useRealTimers();
gatewayMocks.request.mockReset();
gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
gatewayMocks.request.mockResolvedValue({ success: true });
gatewayMocks.runtimeRequest.mockReset();
gatewayMocks.runtimeRequest.mockResolvedValue({ callId: "call-1" });
gatewayMocks.stopAndWait.mockClear();
gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
});
@ -52,8 +56,16 @@ describe("Google Meet voice-call gateway", () => {
realtime: { introMessage: "Say exactly: I'm here and listening." },
});
gatewayMocks.request
.mockResolvedValueOnce({ callId: "call-1" })
.mockResolvedValueOnce({ success: true });
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
const join = joinMeetViaVoiceCallGateway({
config,
gateway,
dialInNumber: "+15551234567",
dtmfSequence: "123456#",
message: "Say exactly: I'm here and listening.",
@ -86,23 +98,30 @@ describe("Google Meet voice-call gateway", () => {
{ timeoutMs: 30_000 },
);
expect(gatewayMocks.request).toHaveBeenCalledTimes(2);
expect(gatewayMocks.runtimeRequest).not.toHaveBeenCalled();
});
it("skips the intro without failing when the realtime bridge is not ready", async () => {
gatewayMocks.request
.mockResolvedValueOnce({ callId: "call-1" })
.mockResolvedValueOnce({ success: false, error: "No active realtime bridge for call" });
gatewayMocks.request.mockResolvedValueOnce({ callId: "call-1" }).mockResolvedValueOnce({
success: false,
error: "No active realtime bridge for call",
});
const config = resolveGoogleMeetConfig({
voiceCall: {
gatewayUrl: "ws://127.0.0.1:18789",
gatewayUrl: "wss://voice.example.test",
dtmfDelayMs: 1,
postDtmfSpeechDelayMs: 1,
},
});
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
const result = await joinMeetViaVoiceCallGateway({
config,
gateway,
dialInNumber: "+15551234567",
dtmfSequence: "123456#",
logger,
@ -117,14 +136,64 @@ describe("Google Meet voice-call gateway", () => {
);
});
it("treats missing delegated calls as already ended", async () => {
gatewayMocks.request.mockRejectedValueOnce(new Error("Call not found"));
it("routes the call through the originating agent", async () => {
const config = resolveGoogleMeetConfig({});
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
await joinMeetViaVoiceCallGateway({
config,
gateway,
dialInNumber: "+15551234567",
agentId: "support",
sessionKey: "agent:support:google-meet:meet-1",
});
expect(gatewayMocks.runtimeRequest).toHaveBeenCalledWith(
"voicecall.start",
expect.objectContaining({
agentId: "support",
sessionKey: "agent:support:google-meet:meet-1",
}),
{ timeoutMs: 30_000 },
);
});
it("rejects per-agent routing through an external Voice Call gateway", async () => {
const config = resolveGoogleMeetConfig({
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
voiceCall: { gatewayUrl: "wss://voice.example.test" },
});
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
await expect(
endMeetVoiceCallGatewayCall({ config, callId: "call-1" }),
joinMeetViaVoiceCallGateway({
config,
gateway,
dialInNumber: "+15551234567",
agentId: "support",
}),
).rejects.toThrow("requires the local Gateway runtime");
expect(gatewayMocks.request).not.toHaveBeenCalled();
});
it("treats missing delegated calls as already ended", async () => {
gatewayMocks.request.mockRejectedValueOnce(new Error("Call not found"));
const config = resolveGoogleMeetConfig({
voiceCall: { gatewayUrl: "wss://voice.example.test" },
});
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
await expect(
endMeetVoiceCallGatewayCall({ gateway, callId: "call-1" }),
).resolves.toBeUndefined();
expect(gatewayMocks.request).toHaveBeenCalledWith(
@ -137,10 +206,15 @@ describe("Google Meet voice-call gateway", () => {
it("reads delegated call status from the gateway", async () => {
gatewayMocks.request.mockResolvedValueOnce({ found: false });
const config = resolveGoogleMeetConfig({
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
voiceCall: { gatewayUrl: "wss://voice.example.test" },
});
await expect(getMeetVoiceCallGatewayCall({ config, callId: "call-1" })).resolves.toEqual({
const gateway = createVoiceCallGateway({
config,
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
});
await expect(getMeetVoiceCallGatewayCall({ gateway, callId: "call-1" })).resolves.toEqual({
found: false,
});

View file

@ -4,12 +4,17 @@ import {
GatewayClient,
startGatewayClientWhenEventLoopReady,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "./config.js";
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
export type VoiceCallGateway = {
trustedPluginIdentity: boolean;
request: <T>(method: string, params: Record<string, unknown>) => Promise<T>;
};
type VoiceCallStartResult = {
callId?: string;
initiated?: boolean;
@ -77,6 +82,34 @@ async function createConnectedGatewayClient(
return client!;
}
export function createVoiceCallGateway(params: {
config: GoogleMeetConfig;
runtime: PluginRuntime;
}): VoiceCallGateway {
if (!params.config.voiceCall.gatewayUrl) {
return {
trustedPluginIdentity: true,
request: (method, requestParams) =>
params.runtime.gateway.request(method, requestParams, {
timeoutMs: params.config.voiceCall.requestTimeoutMs,
}),
};
}
return {
trustedPluginIdentity: false,
async request<T>(method: string, requestParams: Record<string, unknown>): Promise<T> {
const client = await createConnectedGatewayClient(params.config);
try {
return (await client.request(method, requestParams, {
timeoutMs: params.config.voiceCall.requestTimeoutMs,
})) as T;
} finally {
await client.stopAndWait({ timeoutMs: 1_000 });
}
},
};
}
export function isVoiceCallMissingError(error: unknown): boolean {
const message = formatErrorMessage(error).toLowerCase();
return message.includes("call not found") || message.includes("call is not active");
@ -84,159 +117,116 @@ export function isVoiceCallMissingError(error: unknown): boolean {
export async function joinMeetViaVoiceCallGateway(params: {
config: GoogleMeetConfig;
gateway: VoiceCallGateway;
dialInNumber: string;
dtmfSequence?: string;
logger?: RuntimeLogger;
message?: string;
requesterSessionKey?: string;
agentId?: string;
sessionKey?: string;
}): Promise<VoiceCallMeetJoinResult> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
params.logger?.info(
`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
const requiresTrustedAgentRouting = params.agentId && params.agentId !== "main";
if (requiresTrustedAgentRouting && !params.gateway.trustedPluginIdentity) {
throw new Error(
"Per-agent Voice Call routing requires the local Gateway runtime. Remove google-meet voiceCall.gatewayUrl or omit agent routing.",
);
const start = (await client.request(
"voicecall.start",
{
to: params.dialInNumber,
mode: "conversation",
...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallStartResult;
if (!start.callId) {
throw new Error(start.error || "voicecall.start did not return callId");
}
}
params.logger?.info(
`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
);
const start = await params.gateway.request<VoiceCallStartResult>("voicecall.start", {
to: params.dialInNumber,
mode: "conversation",
...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
...(params.agentId && params.gateway.trustedPluginIdentity ? { agentId: params.agentId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
});
if (!start.callId) {
throw new Error(start.error || "voicecall.start did not return callId");
}
params.logger?.info(`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`);
const dtmfSent = Boolean(params.dtmfSequence);
if (dtmfSent) {
params.logger?.info(
`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`,
`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
);
const dtmfSent = Boolean(params.dtmfSequence);
if (dtmfSent) {
}
let introSent = false;
if (params.message) {
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
if (delayMs > 0) {
params.logger?.info(
`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
);
await sleep(delayMs);
}
let spoken: VoiceCallSpeakResult;
try {
spoken = await params.gateway.request<VoiceCallSpeakResult>("voicecall.speak", {
callId: start.callId,
allowTwimlFallback: false,
message: params.message,
});
} catch (err) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
);
spoken = { success: false };
}
if (spoken.success === false) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${
spoken.error || "voicecall.speak failed"
}`,
);
} else {
introSent = true;
params.logger?.info(
`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
);
}
let introSent = false;
if (params.message) {
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
if (delayMs > 0) {
params.logger?.info(
`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
);
await sleep(delayMs);
}
let spoken: VoiceCallSpeakResult;
try {
spoken = (await client.request(
"voicecall.speak",
{
callId: start.callId,
allowTwimlFallback: false,
message: params.message,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallSpeakResult;
} catch (err) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
);
spoken = { success: false };
}
if (spoken.success === false) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${
spoken.error || "voicecall.speak failed"
}`,
);
} else {
introSent = true;
params.logger?.info(
`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
);
}
}
return {
callId: start.callId,
dtmfSent,
introSent,
};
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
return {
callId: start.callId,
dtmfSent,
introSent,
};
}
export async function endMeetVoiceCallGatewayCall(params: {
config: GoogleMeetConfig;
gateway: VoiceCallGateway;
callId: string;
}): Promise<void> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
try {
await client.request(
"voicecall.end",
{
callId: params.callId,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
);
} catch (err) {
if (!isVoiceCallMissingError(err)) {
throw err;
}
await params.gateway.request("voicecall.end", { callId: params.callId });
} catch (err) {
if (!isVoiceCallMissingError(err)) {
throw err;
}
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
}
export async function getMeetVoiceCallGatewayCall(params: {
config: GoogleMeetConfig;
gateway: VoiceCallGateway;
callId: string;
}): Promise<VoiceCallStatusResult> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
return (await client.request(
"voicecall.status",
{
callId: params.callId,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallStatusResult;
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
return await params.gateway.request<VoiceCallStatusResult>("voicecall.status", {
callId: params.callId,
});
}
export async function speakMeetViaVoiceCallGateway(params: {
config: GoogleMeetConfig;
gateway: VoiceCallGateway;
callId: string;
message: string;
}): Promise<void> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
const spoken = (await client.request(
"voicecall.speak",
{
callId: params.callId,
message: params.message,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallSpeakResult;
if (spoken.success === false) {
throw new Error(spoken.error || "voicecall.speak failed");
}
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
const spoken = await params.gateway.request<VoiceCallSpeakResult>("voicecall.speak", {
callId: params.callId,
message: params.message,
});
if (spoken.success === false) {
throw new Error(spoken.error || "voicecall.speak failed");
}
}

View file

@ -122,7 +122,10 @@ function createServiceContext(): Parameters<NonNullable<Registered["service"]>["
} as Parameters<NonNullable<Registered["service"]>["start"]>[0];
}
function setup(config: Record<string, unknown>): Registered {
function setup(
config: Record<string, unknown>,
toolContext: Record<string, unknown> = {},
): Registered {
const methods = new Map<string, unknown>();
const methodScopes = new Map<string, string | undefined>();
const tools: unknown[] = [];
@ -141,7 +144,12 @@ function setup(config: Record<string, unknown>): Registered {
methods.set(method, handler);
methodScopes.set(method, opts?.scope);
},
registerTool: (tool: unknown) => tools.push(tool),
registerTool: (tool: unknown) =>
tools.push(
typeof tool === "function"
? (tool as (context: Record<string, unknown>) => unknown)(toolContext)
: tool,
),
registerCli: () => {},
registerService: (registeredService) => {
service = registeredService;
@ -492,6 +500,47 @@ describe("voice-call plugin", () => {
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("accepts per-call agent routing only from plugin runtime", async () => {
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.start") as
| ((ctx: {
params: Record<string, unknown>;
client?: { internal?: { pluginRuntimeOwnerId?: string } };
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { to: "+15550001234", agentId: "support" },
client: { internal: { pluginRuntimeOwnerId: "google-meet" } },
respond,
});
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
"+15550001234",
undefined,
expect.objectContaining({ agentId: "support" }),
);
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("rejects external per-call agent routing", async () => {
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.start") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: { to: "+15550001234", agentId: "spoofed" }, respond });
expect(runtimeStub.manager["initiateCall"]).not.toHaveBeenCalled();
expect(firstRespondCall(respond)[2]?.code).toBe("INVALID_REQUEST");
});
it("returns redacted call status", async () => {
const call = createCallRecord({
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
@ -715,6 +764,25 @@ describe("voice-call plugin", () => {
expectWarningIncludes('Run "openclaw doctor --fix"');
});
it("freezes the invoking agent on tool-created calls", async () => {
const { tools } = setup({ provider: "mock" }, { agentId: "support" });
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<unknown>;
};
await tool.execute("id", {
action: "initiate_call",
to: "+15550001234",
message: "Hello",
});
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
"+15550001234",
undefined,
expect.objectContaining({ agentId: "support", message: "Hello" }),
);
});
it("tool get_status returns json payload", async () => {
const call = createCallRecord({
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },

View file

@ -2,6 +2,7 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
@ -409,12 +410,14 @@ export default definePluginEntry({
dtmfSequence?: string;
sessionKey?: string;
requesterSessionKey?: string;
agentId?: string;
}) => {
const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
message: params.message,
mode: params.mode,
dtmfSequence: params.dtmfSequence,
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
});
if (!result.success) {
respondError(params.respond, result.error || "initiate failed");
@ -671,13 +674,29 @@ export default definePluginEntry({
api.registerGatewayMethod(
"voicecall.start",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const to = normalizeOptionalString(params?.to) ?? "";
const message = normalizeOptionalString(params?.message) ?? "";
const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
const sessionKey = normalizeOptionalString(params?.sessionKey);
const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey);
const requestedAgentId = normalizeOptionalString(params?.agentId);
const normalizedAgentId = requestedAgentId
? normalizeAgentId(requestedAgentId)
: undefined;
const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
if (
requestedAgentId &&
(!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())
) {
respondError(
respond,
"agentId requires a trusted plugin caller and a valid agent id",
ErrorCodes.INVALID_REQUEST,
);
return;
}
if (!to) {
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
return;
@ -694,6 +713,7 @@ export default definePluginEntry({
dtmfSequence,
sessionKey,
...(requesterSessionKey ? { requesterSessionKey } : {}),
...(normalizedAgentId ? { agentId: normalizedAgentId } : {}),
});
} catch (err) {
sendError(respond, err);
@ -702,13 +722,14 @@ export default definePluginEntry({
VOICE_CALL_WRITE_METHOD_SCOPE,
);
api.registerTool({
api.registerTool((toolContext) => ({
name: "voice_call",
label: "Voice Call",
description: "Make phone calls and have voice conversations via the voice-call plugin.",
parameters: VoiceCallToolSchema,
async execute(_toolCallId, params) {
const rawParams = asParamRecord(params);
const agentId = normalizeOptionalString(toolContext.agentId);
try {
const rt = await ensureRuntime();
@ -730,6 +751,7 @@ export default definePluginEntry({
rawParams.mode === "notify" || rawParams.mode === "conversation"
? rawParams.mode
: undefined,
...(agentId ? { agentId } : {}),
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
@ -816,6 +838,7 @@ export default definePluginEntry({
{
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
message: normalizeOptionalString(rawParams.message),
...(agentId ? { agentId } : {}),
...(normalizeOptionalString(rawParams.requesterSessionKey)
? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) }
: {}),
@ -831,7 +854,7 @@ export default definePluginEntry({
});
}
},
});
}));
api.registerCli(
({ program }) =>

View file

@ -664,6 +664,7 @@ describe("processEvent (functional)", () => {
inboundGreeting: "Hello from global.",
numbers: {
"+15550002222": {
agentId: "cards",
inboundGreeting: "Silver Fox Cards, how can I help?",
},
},
@ -685,6 +686,7 @@ describe("processEvent (functional)", () => {
const call = requireFirstActiveCall(ctx);
expect(call.metadata?.initialMessage).toBe("Silver Fox Cards, how can I help?");
expect(call.metadata?.numberRouteKey).toBe("+15550002222");
expect(call.agentId).toBe("cards");
});
it("deduplicates by dedupeKey even when event IDs differ", () => {

View file

@ -1,6 +1,7 @@
// Voice Call plugin module implements events behavior.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
import { resolveVoiceCallEffectiveConfig, resolveVoiceCallSessionKey } from "../config.js";
import type { CallRecord, NormalizedEvent } from "../types.js";
@ -100,6 +101,7 @@ function createWebhookCall(params: {
callId,
phone: params.direction === "outbound" ? params.to : params.from,
}),
agentId: normalizeAgentId(effectiveConfig.agentId),
startedAt: Date.now(),
transcript: [],
processedEventIds: [],

View file

@ -207,6 +207,38 @@ describe("voice-call outbound helpers", () => {
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
`agent:main:voice:call:${result.callId}`,
);
expect(ctx.activeCalls.get(result.callId)?.agentId).toBe("main");
});
it("uses the per-call agent for explicit session normalization", async () => {
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: {
name: "twilio",
initiateCall: vi.fn(async () => ({ providerCallId: "provider-1" })),
},
config: {
agentId: "main",
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
},
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(
ctx as never,
"+14155550123",
"agent:support:google-meet:meet-1",
{ agentId: "Support" },
);
expect(ctx.activeCalls.get(result.callId)).toMatchObject({
agentId: "support",
sessionKey: "agent:support:google-meet:meet-1",
});
});
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {

View file

@ -1,6 +1,7 @@
// Voice Call plugin module implements outbound behavior.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
@ -144,6 +145,7 @@ export async function initiateCall(
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
const dtmfSequence = opts.dtmfSequence;
const requesterSessionKey = opts.requesterSessionKey?.trim();
const agentId = normalizeAgentId(opts.agentId ?? ctx.config.agentId);
if (dtmfSequence) {
const validationError = validateDtmfDigits(dtmfSequence);
if (validationError) {
@ -188,12 +190,13 @@ export async function initiateCall(
from,
to,
sessionKey: resolveVoiceCallSessionKey({
config: ctx.config,
config: { ...ctx.config, agentId },
callId,
phone: to,
explicitSessionKey: sessionKey,
coreSession: ctx.coreSession,
}),
agentId,
startedAt: Date.now(),
transcript: [],
processedEventIds: [],

View file

@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { CoreAgentDeps } from "./core-bridge.js";
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
@ -64,7 +65,7 @@ describe("buildRealtimeVoiceInstructions", () => {
await writeFile(path.join(workspaceDir, "IDENTITY.md"), "Name: Claw Voice\nVibe: snappy\n");
await writeFile(path.join(workspaceDir, "SECRET.md"), "do not include\n");
const coreConfig = { agents: { list: [{ id: "voice" }] } } as CoreConfig;
const coreConfig = { agents: { list: [{ id: "voice" }] } } as OpenClawConfig;
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
@ -111,7 +112,7 @@ describe("buildRealtimeVoiceInstructions", () => {
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
config,
coreConfig: { agents: { list: [{ id: agentId }] } } as CoreConfig,
coreConfig: { agents: { list: [{ id: agentId }] } } as OpenClawConfig,
agentRuntime: createAgentRuntime("/unused"),
});

View file

@ -5,7 +5,7 @@ import { root } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import type { CoreAgentDeps } from "./core-bridge.js";
// Builds compact agent context injected into realtime voice sessions.
@ -59,7 +59,7 @@ async function readWorkspaceVoiceContextFiles(params: {
export async function buildRealtimeVoiceInstructions(params: {
baseInstructions: string;
config: VoiceCallConfig;
coreConfig: CoreConfig;
coreConfig: OpenClawConfig;
agentRuntime: CoreAgentDeps;
}): Promise<string> {
const { config } = params;
@ -83,10 +83,9 @@ export async function buildRealtimeVoiceInstructions(params: {
];
if (contextConfig.includeIdentity) {
const identity = params.agentRuntime.resolveAgentIdentity(
params.coreConfig as OpenClawConfig,
agentId,
) as VoiceIdentityLike | undefined;
const identity = params.agentRuntime.resolveAgentIdentity(params.coreConfig, agentId) as
| VoiceIdentityLike
| undefined;
const identityLines = [
normalizeString(identity?.name) ? `- Name: ${normalizeString(identity?.name)}` : undefined,
normalizeString(identity?.emoji) ? `- Emoji: ${normalizeString(identity?.emoji)}` : undefined,
@ -102,10 +101,7 @@ export async function buildRealtimeVoiceInstructions(params: {
}
if (contextConfig.includeWorkspaceFiles) {
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(
params.coreConfig as OpenClawConfig,
agentId,
);
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.coreConfig, agentId);
// Workspace reads stay under the agent root; missing or unreadable context files are omitted.
const fileSections = await readWorkspaceVoiceContextFiles({
workspaceDir,

View file

@ -0,0 +1,11 @@
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type { VoiceCallConfig } from "./config.js";
import type { CallRecord } from "./types.js";
/** Keep one agent owner for the full call, including legacy stored records. */
export function resolveCallAgentId(
call: Pick<CallRecord, "agentId">,
config: Pick<VoiceCallConfig, "agentId">,
): string {
return normalizeAgentId(call.agentId ?? config.agentId);
}

View file

@ -768,6 +768,30 @@ describe("generateVoiceResponse", () => {
expect(args.sessionFile).toBeUndefined();
});
it("prefers the agent frozen on the call", async () => {
const { runtime, runEmbeddedAgent, resolveStorePath } = createAgentRuntime([
{ text: '{"spoken":"Support agent."}' },
]);
await generateVoiceResponse({
voiceConfig: VoiceCallConfigSchema.parse({ agentId: "voice", responseTimeoutMs: 5000 }),
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
agentId: "support",
sessionKey: "agent:support:google-meet:meet-1",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "support" });
expect(requireEmbeddedAgentArgs(runEmbeddedAgent)).toMatchObject({
agentId: "support",
sessionKey: "agent:support:google-meet:meet-1",
});
});
it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => {
const { runtime, runEmbeddedAgent } = createAgentRuntime([
{ text: '{"spoken":"No tools needed."}' },

View file

@ -12,6 +12,7 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
import { resolveVoiceResponseModel } from "./response-model.js";
export type VoiceResponseParams = {
@ -27,6 +28,8 @@ export type VoiceResponseParams = {
sessionKey?: string;
/** Caller's phone number */
from: string;
/** Agent frozen on the call record. */
agentId?: string;
/** Conversation transcript */
transcript: Array<{ speaker: "user" | "bot"; text: string }>;
/** Latest user message */
@ -249,15 +252,15 @@ export async function generateVoiceResponse(
};
}
const cfg = coreConfig;
const agentId = resolveCallAgentId({ agentId: params.agentId }, voiceConfig);
const resolvedSessionKey = resolveVoiceCallSessionKey({
config: voiceConfig,
config: { ...voiceConfig, agentId },
callId,
phone: from,
explicitSessionKey: sessionKey,
coreSession: coreConfig.session,
});
const agentId = voiceConfig.agentId ?? "main";
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
// Resolve paths

View file

@ -323,6 +323,57 @@ describe("createVoiceCallRuntime lifecycle", () => {
expect(mocks.webhookCtorArgs[0]?.[4]).toBe(fullConfig);
});
it("builds realtime instructions for the agent frozen on each call", async () => {
const config = createBaseConfig();
config.realtime.enabled = true;
config.realtime.agentContext = {
enabled: true,
maxChars: 6000,
includeIdentity: true,
includeWorkspaceFiles: false,
files: ["SOUL.md"],
};
const fullConfig = {
agents: { list: [{ id: "main" }, { id: "support" }] },
} as OpenClawConfig;
const resolveAgentIdentity = vi.fn((_cfg: OpenClawConfig, agentId: string) => ({
name: agentId === "support" ? "Support Voice" : "Main Voice",
}));
await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
fullConfig,
agentRuntime: {
resolveAgentIdentity,
} as never,
});
const resolveInstructions = mocks.realtimeHandlerCtorArgs[0]?.[7];
if (typeof resolveInstructions !== "function") {
throw new Error("expected per-call realtime instruction resolver");
}
const supportInstructions = resolveInstructions({
callId: "call-support",
agentId: "support",
direction: "outbound",
from: "+15550001111",
to: "+15550002222",
});
expect(supportInstructions).toContain("- Agent id: support");
expect(supportInstructions).toContain("- Name: Support Voice");
expect(supportInstructions).not.toContain("Main Voice");
const unknownInstructions = resolveInstructions({
callId: "call-unknown",
agentId: "unknown",
direction: "outbound",
from: "+15550001111",
to: "+15550002222",
});
expect(unknownInstructions).not.toContain("OpenClaw agent voice context:");
});
it.each(["twilio", "telnyx", "plivo"] as const)(
"fails closed when %s falls back to a local-only webhook",
async (provider) => {
@ -432,6 +483,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
};
mocks.managerGetCall.mockReturnValue({
callId: "call-1",
agentId: "support",
direction: "outbound",
from: "+15550001234",
to: "+15550009999",
@ -470,7 +522,8 @@ describe("createVoiceCallRuntime lifecycle", () => {
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
"embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
expect(consultParams.agentId).toBe("support");
expect(consultParams.sessionKey).toBe("agent:support:voice:15550009999");
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
expect(consultParams.messageProvider).toBe("voice");
expect(consultParams.lane).toBe("voice");

View file

@ -11,6 +11,7 @@ import {
type RealtimeVoiceAgentConsultTranscriptEntry,
type ResolvedRealtimeVoiceProvider,
} from "openclaw/plugin-sdk/realtime-voice";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type { VoiceCallConfig } from "./config.js";
import {
resolveVoiceCallEffectiveConfig,
@ -26,11 +27,13 @@ import type { VoiceCallProvider } from "./providers/base.js";
import type { TwilioProvider } from "./providers/twilio.js";
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
import { resolveVoiceResponseModel } from "./response-model.js";
import { setVoiceCallStateRuntime, type VoiceCallStateRuntime } from "./runtime-state.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import type { CallRecord } from "./types.js";
import {
isProviderUnreachableWebhookUrl,
providerRequiresPublicWebhook,
@ -234,6 +237,58 @@ async function resolveRealtimeProvider(params: {
});
}
function listRealtimeAgentIds(config: VoiceCallConfig, coreConfig: OpenClawConfig): string[] {
const agentIds = new Set<string>([normalizeAgentId(config.agentId)]);
for (const agent of coreConfig.agents?.list ?? []) {
agentIds.add(normalizeAgentId(agent.id));
}
for (const route of Object.values(config.numbers)) {
if (route.agentId) {
agentIds.add(normalizeAgentId(route.agentId));
}
}
return [...agentIds];
}
async function createRealtimeInstructionsResolver(params: {
config: VoiceCallConfig;
coreConfig: OpenClawConfig;
agentRuntime: CoreAgentDeps;
}): Promise<(call: CallRecord) => string> {
const genericConfig: VoiceCallConfig = {
...params.config,
realtime: {
...params.config.realtime,
agentContext: { ...params.config.realtime.agentContext, enabled: false },
},
};
const genericInstructions = await buildRealtimeVoiceInstructions({
baseInstructions: params.config.realtime.instructions,
config: genericConfig,
coreConfig: params.coreConfig,
agentRuntime: params.agentRuntime,
});
const entries = await Promise.all(
listRealtimeAgentIds(params.config, params.coreConfig).map(async (agentId) => {
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: params.config.realtime.instructions,
config: { ...params.config, agentId },
coreConfig: params.coreConfig,
agentRuntime: params.agentRuntime,
});
return [agentId, instructions] as const;
}),
);
const instructionsByAgentId = new Map(entries);
return (call) => {
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const effectiveConfig = resolveVoiceCallEffectiveConfig(params.config, numberRouteKey).config;
return (
instructionsByAgentId.get(resolveCallAgentId(call, effectiveConfig)) ?? genericInstructions
);
};
}
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
@ -299,15 +354,13 @@ export async function createVoiceCallRuntime(params: {
);
if (realtimeProvider) {
const { RealtimeCallHandler } = await loadRealtimeHandler();
const realtimeInstructions = await buildRealtimeVoiceInstructions({
baseInstructions: config.realtime.instructions,
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
config,
coreConfig,
coreConfig: cfg,
agentRuntime,
});
const realtimeConfig = {
...config.realtime,
instructions: realtimeInstructions,
tools: resolveRealtimeVoiceAgentConsultTools(
config.realtime.toolPolicy,
config.realtime.tools,
@ -321,6 +374,7 @@ export async function createVoiceCallRuntime(params: {
realtimeProvider.providerConfig,
config.serve.path,
cfg,
resolveRealtimeInstructions,
);
if (config.realtime.toolPolicy !== "none") {
realtimeHandler.registerToolHandler(
@ -332,10 +386,10 @@ export async function createVoiceCallRuntime(params: {
}
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
const agentId = effectiveConfig.agentId ?? "main";
const agentId = resolveCallAgentId(call, effectiveConfig);
const sessionKey = resolveVoiceCallConsultSessionKey({
...call,
config: effectiveConfig,
config: { ...effectiveConfig, agentId },
coreSession: cfg.session,
});
const requesterSessionKey =

View file

@ -162,6 +162,8 @@ export const CallRecordSchema = z.object({
from: z.string(),
to: z.string(),
sessionKey: z.string().optional(),
/** Agent selected when the call was created. Optional for legacy records. */
agentId: z.string().optional(),
startedAt: z.number(),
answeredAt: z.number().optional(),
endedAt: z.number().optional(),
@ -315,4 +317,6 @@ export type OutboundCallOptions = {
dtmfSequence?: string;
/** Session that initiated the call, used for agent context/delegated message routing */
requesterSessionKey?: string;
/** Agent selected for this call instead of the plugin default. */
agentId?: string;
};

View file

@ -1659,8 +1659,9 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
});
describe("VoiceCallWebhookServer classic response routing", () => {
it("keeps outbound calls on the top-level agent when the dialed number has an inbound route", async () => {
it("keeps outbound calls on their frozen agent when the dialed number has an inbound route", async () => {
const call = createCall(Date.now());
call.agentId = "support";
call.direction = "outbound";
call.to = "+15550001111";
call.sessionKey = "agent:top:voice:15550001111";
@ -1696,8 +1697,9 @@ describe("VoiceCallWebhookServer classic response routing", () => {
const params = requireFirstMockCall(
mocks.generateVoiceResponse.mock.calls,
"classic voice response",
)[0] as { voiceConfig?: VoiceCallConfig } | undefined;
)[0] as { agentId?: string; voiceConfig?: VoiceCallConfig } | undefined;
expect(params?.voiceConfig?.agentId).toBe("top");
expect(params?.agentId).toBe("support");
expect(speak).toHaveBeenCalledWith(call.callId, "Hello back", {
listenAfterPlayback: true,
});

View file

@ -40,6 +40,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { isProviderStatusTerminal } from "./providers/shared/call-status.js";
import type { TwilioProvider } from "./providers/twilio.js";
import { normalizeProxyIp } from "./proxy-ip.js";
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js";
import type { WebhookResponsePayload } from "./webhook.types.js";
import type { RealtimeCallHandler } from "./webhook/realtime-handler.js";
@ -1013,6 +1014,7 @@ export class VoiceCallWebhookServer {
callId,
sessionKey: call.sessionKey,
from: call.from,
agentId: resolveCallAgentId(call, effectiveConfig),
transcript: call.transcript,
userMessage,
onEarlyText: async (text) => {

View file

@ -75,6 +75,7 @@ function makeHandler(
provider?: Partial<VoiceCallProvider>;
providerConfig?: Record<string, unknown>;
realtimeProvider?: RealtimeVoiceProviderPlugin;
resolveInstructions?: (call: CallRecord) => string;
},
) {
const config: VoiceCallRealtimeConfig = {
@ -124,6 +125,8 @@ function makeHandler(
deps?.realtimeProvider ?? makeRealtimeProvider(() => makeBridge()),
deps?.providerConfig ?? { apiKey: "test-key" },
"/voice/webhook",
undefined,
deps?.resolveInstructions,
);
}
@ -406,9 +409,11 @@ describe("RealtimeCallHandler path routing", () => {
it("joins Telnyx realtime streams to the token-bound call", async () => {
const processEvent = vi.fn();
const resolveInstructions = vi.fn((call: CallRecord) => `instructions:${call.agentId}`);
const getCall = vi.fn(
(): CallRecord => ({
callId: "call-1",
agentId: "support",
providerCallId: "v3:call-1",
provider: "telnyx",
direction: "inbound",
@ -421,7 +426,7 @@ describe("RealtimeCallHandler path routing", () => {
metadata: { initialMessage: "hello" },
}),
);
const createBridge = vi.fn(() => makeBridge());
const createBridge = vi.fn((_request: RealtimeBridgeRequest) => makeBridge());
const handler = makeHandler(undefined, {
manager: {
processEvent,
@ -431,6 +436,7 @@ describe("RealtimeCallHandler path routing", () => {
name: "telnyx",
},
realtimeProvider: makeRealtimeProvider(createBridge),
resolveInstructions,
});
handler.setPublicUrl("https://public.example/voice/webhook");
const session = handler.issueStreamSession({
@ -463,6 +469,13 @@ describe("RealtimeCallHandler path routing", () => {
expect((processEvent.mock.calls[0]?.[0] as NormalizedEvent | undefined)?.callId).toBe(
"call-1",
);
expect(resolveInstructions).toHaveBeenCalledWith(
expect.objectContaining({
callId: "call-1",
agentId: "support",
}),
);
expect(createBridge.mock.calls[0]?.[0].instructions).toBe("instructions:support");
} finally {
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
ws.close();

View file

@ -258,6 +258,7 @@ export type StreamSession = {
type CallRegistration = {
callId: string;
instructions: string;
initialGreetingInstructions?: string;
};
@ -345,6 +346,7 @@ export class RealtimeCallHandler {
private readonly providerConfig: RealtimeVoiceProviderConfig,
private readonly servePath: string,
private readonly coreConfig?: OpenClawConfig,
private readonly resolveInstructions?: (call: CallRecord) => string,
) {}
setPublicUrl(url: string): void {
@ -574,7 +576,7 @@ export class RealtimeCallHandler {
return null;
}
const { callId, initialGreetingInstructions } = registration;
const { callId, instructions, initialGreetingInstructions } = registration;
const callRecord = this.manager.getCallByProviderCallId(callSid);
const talk: TalkSessionController = createTalkSessionController(
{
@ -721,7 +723,7 @@ export class RealtimeCallHandler {
cfg: this.coreConfig,
providerConfig: this.providerConfig,
interruptResponseOnInputAudio,
instructions: this.config.instructions,
instructions,
tools: this.config.tools,
initialGreetingInstructions,
triggerGreetingOnReady: Boolean(initialGreetingInstructions),
@ -1269,12 +1271,11 @@ export class RealtimeCallHandler {
...baseFields,
});
const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
return {
callId: callRecord.callId,
initialGreetingInstructions: buildGreetingInstructions(
this.config.instructions,
initialGreeting,
),
instructions,
initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting),
};
}

View file

@ -921,6 +921,98 @@ describe("loadGatewayPlugins", () => {
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
});
test("dispatches gateway methods with the trusted plugin identity", async () => {
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
loadGatewayStartupPluginsForTest();
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-request"));
const runtime = runtimeModule.createPluginRuntime();
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
{ pluginId: "google-meet", pluginOrigin: "bundled" },
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
);
expect(getLastDispatchedParams()).toEqual({ to: "+15550001234" });
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
});
test("reports whether trusted in-process Gateway dispatch is available", async () => {
const runtime = runtimeModule.createPluginRuntime();
expect(await runtime.gateway.isAvailable()).toBe(false);
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-available"));
expect(await runtime.gateway.isAvailable()).toBe(true);
});
test("does not inherit admin scope for trusted plugin gateway requests", async () => {
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
loadGatewayStartupPluginsForTest();
const scope = {
context: createTestContext("plugin-gateway-request-admin-caller"),
client: {
connect: {
scopes: ["operator.admin"],
},
} as GatewayRequestOptions["client"],
isWebchatConnect: () => false,
} satisfies PluginRuntimeGatewayRequestScope;
const runtime = runtimeModule.createPluginRuntime();
await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () =>
gatewayRequestScopeModule.withPluginRuntimePluginScope(
{ pluginId: "google-meet", pluginOrigin: "bundled" },
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
),
);
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
});
test("preserves structured errors from trusted plugin gateway requests", async () => {
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
loadGatewayStartupPluginsForTest();
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-error"));
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: "browser login required",
details: { manualActionRequired: true, reason: "not-authenticated" },
});
});
const runtime = runtimeModule.createPluginRuntime();
const request = gatewayRequestScopeModule.withPluginRuntimePluginScope(
{ pluginId: "google-meet", pluginOrigin: "bundled" },
() => runtime.gateway.request("googlemeet.join", { url: "https://meet.google.com/abc" }),
);
await expect(request).rejects.toMatchObject({
name: "GatewayClientRequestError",
gatewayCode: "INVALID_REQUEST",
details: { manualActionRequired: true, reason: "not-authenticated" },
});
});
test("rejects gateway dispatch from arbitrary plugins", async () => {
loadOpenClawPlugins.mockReturnValue(
addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }),
);
loadGatewayStartupPluginsForTest();
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-rejected"));
const runtime = runtimeModule.createPluginRuntime();
await expect(
gatewayRequestScopeModule.withPluginRuntimePluginScope(
{ pluginId: "third-party", pluginOrigin: "global" },
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
),
).rejects.toThrow("bundled or trusted official plugins");
expect(handleGatewayRequest).not.toHaveBeenCalled();
});
test("does not let arbitrary plugin nodes runtime mint admin scope for browser proxy", async () => {
loadOpenClawPlugins.mockReturnValue(
addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }),

View file

@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
@ -22,7 +23,7 @@ import type { PluginRegistryParams } from "../plugins/registry-types.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { PluginRuntime, RuntimeGatewayRequestOptions } from "../plugins/runtime/types.js";
import type { PluginLogger, PluginOrigin } from "../plugins/types.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
@ -357,7 +358,13 @@ function unwrapGatewayMethodDispatchResponse(
response: GatewayMethodDispatchResponse,
): unknown {
if (!response.ok) {
throw new Error(response.error?.message ?? `Gateway method "${method}" failed.`);
throw new GatewayClientRequestError({
code: response.error?.code,
message: response.error?.message ?? `Gateway method "${method}" failed.`,
details: response.error?.details,
retryable: response.error?.retryable,
retryAfterMs: response.error?.retryAfterMs,
});
}
return response.payload;
}
@ -564,6 +571,23 @@ export async function dispatchGatewayMethodInProcess<T>(
return await dispatchGatewayMethod<T>(method, params, options);
}
export async function dispatchTrustedPluginGatewayMethod<T>(
method: string,
params: Record<string, unknown> = {},
options?: RuntimeGatewayRequestOptions,
): Promise<T> {
const scope = getPluginRuntimeGatewayRequestScope();
const pluginId = scope?.pluginId?.trim();
if (!canTrustedOfficialPluginRequestScopes(scope ?? {})) {
throw new Error("Gateway requests are only available to bundled or trusted official plugins.");
}
return await dispatchGatewayMethod<T>(method, params, {
forceSyntheticClient: true,
pluginRuntimeOwnerId: pluginId,
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
});
}
const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000;
export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {

View file

@ -367,6 +367,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
};
const base: PluginRuntime = {
version: "1.0.0-test",
gateway: {
isAvailable: vi.fn(async () => false),
request: vi.fn(),
},
config: {
current: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["current"],
mutateConfigFile: vi.fn(async () => ({

View file

@ -387,6 +387,7 @@ const fullWorkspacePluginLoaderCacheState = new PluginLoaderCacheState<CachedPlu
);
const LAZY_RUNTIME_REFLECTION_KEYS = [
"version",
"gateway",
"config",
"agent",
"subagent",

View file

@ -169,4 +169,34 @@ describe("plugin registry runtime config scope", () => {
pluginSource: "/plugins/google-meet/index.js",
});
});
it("runs gateway requests with the owning plugin scope", async () => {
let requestScope = getPluginRuntimeGatewayRequestScope();
const runtime = createPluginRuntime();
runtime.gateway = {
isAvailable: async () => true,
request: async <T>() => {
requestScope = getPluginRuntimeGatewayRequestScope();
return { ok: true } as T;
},
};
const pluginRegistry = createTestRegistry(runtime);
const record = createPluginRecord({
id: "google-meet",
name: "Google Meet",
source: "/plugins/google-meet/index.js",
origin: "bundled",
enabled: true,
configSchema: false,
});
const api = pluginRegistry.createApi(record, { config: {} as OpenClawConfig });
await api.runtime.gateway.request("voicecall.start", { to: "+15550001234" });
expect(requestScope).toMatchObject({
pluginId: "google-meet",
pluginOrigin: "bundled",
pluginSource: "/plugins/google-meet/index.js",
});
});
});

View file

@ -2757,6 +2757,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
withPluginRuntimePluginIdScope(pluginId, () => llm.complete(params)),
} satisfies PluginRuntime["llm"];
}
if (prop === "gateway") {
const gateway = getRuntimeProperty();
return {
isAvailable: () => runWithPluginScope(() => gateway.isAvailable()),
request: (method, params, options) =>
runWithPluginScope(() => gateway.request(method, params, options)),
} satisfies PluginRuntime["gateway"];
}
if (prop === "nodes") {
const nodes = getRuntimeProperty();
return {

View file

@ -49,6 +49,22 @@ const loadMediaUnderstandingRuntime = createLazyRuntimeModule(
const loadModelAuthRuntime = createLazyRuntimeModule(
() => import("./runtime-model-auth.runtime.js"),
);
const loadGatewayPluginRuntime = createLazyRuntimeModule(
() => import("../../gateway/server-plugins.js"),
);
function createRuntimeGateway(): PluginRuntime["gateway"] {
return {
isAvailable: async () => {
const runtime = await loadGatewayPluginRuntime();
return runtime.hasInProcessGatewayContext();
},
request: async (method, params, options) => {
const runtime = await loadGatewayPluginRuntime();
return runtime.dispatchTrustedPluginGatewayMethod(method, params, options);
},
};
}
function createRuntimeTts(): PluginRuntime["tts"] {
const bindTtsRuntime = createLazyRuntimeMethodBinder(loadTtsRuntime);
@ -253,6 +269,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
// Sourced from the shared OpenClaw version resolver (#52899) so plugins
// always see the same version the CLI reports, avoiding API-version drift.
version: VERSION,
gateway: createRuntimeGateway(),
config: createRuntimeConfig(),
agent: createRuntimeAgent(),
subagent: createLateBindingSubagent(

View file

@ -86,8 +86,22 @@ export type RuntimeNodeInvokeParams = {
scopes?: OperatorScope[];
};
export type RuntimeGatewayRequestOptions = {
timeoutMs?: number;
};
/** Trusted in-process runtime surface injected into native plugins. */
export type PluginRuntime = PluginRuntimeCore & {
gateway: {
/** Whether this process owns an active Gateway request context. */
isAvailable: () => Promise<boolean>;
/** Dispatch a Gateway method as the current trusted plugin. */
request: <T = unknown>(
method: string,
params?: Record<string, unknown>,
options?: RuntimeGatewayRequestOptions,
) => Promise<T>;
};
subagent: {
run: (params: SubagentRunParams) => Promise<SubagentRunResult>;
waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;