fix(voicecall): redact read-scoped status payloads (#97870)

* fix(voicecall): redact read-scoped status payloads

* fix(voicecall): make status assertion lint-safe
This commit is contained in:
Agustin Rivera
2026-06-29 15:38:29 -07:00
committed by GitHub
parent 843ad14364
commit 825aafac57
2 changed files with 119 additions and 6 deletions

View File

@@ -178,6 +178,22 @@ function expectWarningIncludes(text: string): void {
expect(noopLogger.warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(text);
}
function expectRedactedVoiceCallStatus(value: unknown): void {
expect(value).toEqual({
callId: "call-1",
provider: "mock",
direction: "outbound",
state: "active",
startedAt: Date.UTC(2026, 4, 2, 9, 0, 0),
});
expect(value).not.toHaveProperty("from");
expect(value).not.toHaveProperty("to");
expect(value).not.toHaveProperty("sessionKey");
expect(value).not.toHaveProperty("transcript");
expect(value).not.toHaveProperty("processedEventIds");
expect(value).not.toHaveProperty("metadata");
}
async function registerVoiceCallCli(
program: Command,
pluginConfig: Record<string, unknown> = { provider: "mock" },
@@ -475,7 +491,21 @@ describe("voice-call plugin", () => {
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("returns call status", async () => {
it("returns redacted call status", async () => {
const call = createCallRecord({
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
processedEventIds: ["evt-1"],
sessionKey: "agent:main:voice:call-1",
transcript: [
{
timestamp: Date.UTC(2026, 4, 2, 9, 1, 0),
speaker: "user",
text: "private call transcript",
isFinal: true,
},
],
});
runtimeStub.manager.getCall = vi.fn(() => call);
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.status") as
| ((ctx: {
@@ -488,6 +518,41 @@ describe("voice-call plugin", () => {
const [ok, payload] = firstRespondCall(respond);
expect(ok).toBe(true);
expect(payload?.found).toBe(true);
expectRedactedVoiceCallStatus(payload?.call);
});
it("returns redacted active call status list", async () => {
const call = createCallRecord({
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
processedEventIds: ["evt-1"],
sessionKey: "agent:main:voice:call-1",
transcript: [
{
timestamp: Date.UTC(2026, 4, 2, 9, 1, 0),
speaker: "user",
text: "private call transcript",
isFinal: true,
},
],
});
runtimeStub.manager.getActiveCalls = vi.fn(() => [call]);
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.status") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: {}, respond });
const [ok, payload] = firstRespondCall(respond);
expect(ok).toBe(true);
expect(payload?.found).toBe(true);
const calls = (payload?.calls as unknown[] | undefined) ?? [];
expect(calls).toHaveLength(1);
expectRedactedVoiceCallStatus(calls[0]);
});
it("sends DTMF via voicecall.dtmf", async () => {
@@ -650,6 +715,20 @@ describe("voice-call plugin", () => {
});
it("tool get_status returns json payload", async () => {
const call = createCallRecord({
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
processedEventIds: ["evt-1"],
sessionKey: "agent:main:voice:call-1",
transcript: [
{
timestamp: Date.UTC(2026, 4, 2, 9, 1, 0),
speaker: "user",
text: "private call transcript",
isFinal: true,
},
],
});
runtimeStub.manager.getCall = vi.fn(() => call);
const { tools } = setup({ provider: "mock" });
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<unknown>;
@@ -657,8 +736,9 @@ describe("voice-call plugin", () => {
const result = (await tool.execute("id", {
action: "get_status",
callId: "call-1",
})) as { details: { found?: boolean } };
})) as { details: { found?: boolean; call?: unknown } };
expect(result.details.found).toBe(true);
expectRedactedVoiceCallStatus(result.details.call);
});
it("tool send_dtmf returns json payload", async () => {

View File

@@ -23,6 +23,7 @@ import {
} from "./src/config.js";
import type { CoreConfig } from "./src/core-bridge.js";
import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js";
import type { CallRecord } from "./src/types.js";
const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" as const };
const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const };
@@ -232,6 +233,33 @@ function isCliOnlyProcess(): boolean {
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
}
type VoiceCallStatus = Pick<
CallRecord,
| "callId"
| "providerCallId"
| "provider"
| "direction"
| "state"
| "startedAt"
| "answeredAt"
| "endedAt"
| "endReason"
>;
function toVoiceCallStatus(call: CallRecord): VoiceCallStatus {
return {
callId: call.callId,
...(call.providerCallId !== undefined ? { providerCallId: call.providerCallId } : {}),
provider: call.provider,
direction: call.direction,
state: call.state,
startedAt: call.startedAt,
...(call.answeredAt !== undefined ? { answeredAt: call.answeredAt } : {}),
...(call.endedAt !== undefined ? { endedAt: call.endedAt } : {}),
...(call.endReason !== undefined ? { endReason: call.endReason } : {}),
};
}
const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
@@ -623,7 +651,10 @@ export default definePluginEntry({
normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
const rt = await ensureRuntime();
if (!raw) {
respond(true, { found: true, calls: rt.manager.getActiveCalls() });
respond(true, {
found: true,
calls: rt.manager.getActiveCalls().map(toVoiceCallStatus),
});
return;
}
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
@@ -631,7 +662,7 @@ export default definePluginEntry({
respond(true, { found: false });
return;
}
respond(true, { found: true, call });
respond(true, { found: true, call: toVoiceCallStatus(call) });
} catch (err) {
sendError(respond, err);
}
@@ -765,7 +796,9 @@ export default definePluginEntry({
}
const call =
rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
return json(call ? { found: true, call } : { found: false });
return json(
call ? { found: true, call: toVoiceCallStatus(call) } : { found: false },
);
}
}
}
@@ -777,7 +810,7 @@ export default definePluginEntry({
throw new Error("sid required for status");
}
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
return json(call ? { found: true, call } : { found: false });
return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false });
}
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;