mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
8668aeb969
* fix(discord): bind transcript capture to source account * style(agents): keep transcript tool wiring compact * fix(transcripts): declare account binding channels * fix(transcripts): report effective capture account * fix(transcripts): enforce account lifecycle ownership * fix(transcripts): preserve cross-surface control * fix(copilot): preserve transcript channel context * fix(transcripts): fail closed for legacy channel owners * fix(transcripts): add trusted legacy recovery * fix(transcripts): preserve auto-start cleanup ownership * fix(transcripts): reject untrusted account starts * fix(transcripts): keep persisted ownership authoritative * fix(transcripts): harden legacy recovery * fix(transcripts): preserve agent ownership boundary * fix(transcripts): scope account binding to source channel * fix(transcripts): preserve unattributed owner isolation * fix(transcripts): own configured captures by account * docs(plugins): clarify transcript auto-start ownership * test(transcripts): cover account-less recovery * docs(transcripts): scope legacy recovery by provider * fix(discord): reuse eligible account ordering for transcripts * test(discord): use neutral transcript account fixtures * fix(transcripts): keep accountless recovery local * fix(discord): resolve transcript accounts by voice capability * fix(transcripts): bound account resolution failures * fix(transcripts): bound account tool output * fix(transcripts): honor unresolved provider accounts * fix(transcripts): preserve binding when providers are missing * fix(transcripts): fail closed on unknown binding provenance * fix(transcripts): qualify account lifecycle capability * fix(transcripts): normalize provable legacy owners * fix(transcripts): bind scheduled capture to caller authority * fix(transcripts): preserve scheduled caller identity tuple * fix(transcripts): preserve channel-less scheduled authority * fix(plugin-sdk): publish transcript provider types * fix(transcripts): use exact lifecycle ownership tokens * fix(transcripts): preserve local ownerless lifecycle access * fix(transcripts): allow local configured capture control * fix(transcripts): preserve scheduled caller channel * fix(transcripts): retain named-agent legacy recovery * fix(transcripts): deny unrelated remote channels * fix(doctor): validate transcript owner inference * fix(transcripts): restrict legacy remote recovery * fix(ci): align transcript Doctor checks * fix(transcripts): require Doctor-owned legacy metadata * fix(transcripts): reject unowned remote capture starts * fix(transcripts): reject unbound Discord lifecycle calls * fix(transcripts): distinguish legacy owner rows * test(discord): keep unavailable account fixture typed * fix(transcripts): mark current imports for Doctor * fix(transcripts): complete account ownership validation * fix(discord): restore transcript package boundary * fix(discord): preserve bundled transcript entry boundary * docs(transcripts): clarify Discord auto-start account * fix(transcripts): bind account-owned imports * fix: preserve transcript and cron policy state * fix(cron): preserve scheduled transcript authority * fix(discord): keep legacy transcript rows local * fix(transcripts): narrow account ownership boundary * fix(transcripts): preserve trusted caller ownership * fix(discord): enforce transcript source authorization * fix(ci): bound Control UI gzip build variance * test(qa): align transcript scenario contracts * fix(agents): repair rebased caller context * fix(discord): restore rebased account ownership * test(discord): restore voice account fixtures --------- Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
268 lines
8.7 KiB
TypeScript
268 lines
8.7 KiB
TypeScript
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
discordVoiceTranscriptsSourceProvider,
|
|
setDiscordTranscriptsVoiceManager,
|
|
} from "../extensions/discord/test-api.js";
|
|
import { activeSessions } from "../src/agents/tools/transcripts-tool-runtime.js";
|
|
import { createTranscriptsTool } from "../src/agents/tools/transcripts-tool.js";
|
|
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
|
|
import { createEmptyPluginRegistry } from "../src/plugins/registry-empty.js";
|
|
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
|
import { closeOpenClawStateDatabaseForTest } from "../src/state/openclaw-state-db.js";
|
|
import { TranscriptsStore } from "../src/transcripts/store.js";
|
|
import { createTempDirTracker } from "./helpers/temp-dir.js";
|
|
|
|
type DiscordTranscriptsVoiceManager = NonNullable<
|
|
Parameters<typeof setDiscordTranscriptsVoiceManager>[0]["manager"]
|
|
>;
|
|
|
|
const tempDirs = createTempDirTracker();
|
|
|
|
const resolveAccessTarget = async (guildId: string, channelId: string) => ({
|
|
guild: { id: guildId, name: guildId },
|
|
channelName: channelId,
|
|
channelSlug: channelId,
|
|
scope: "channel" as const,
|
|
});
|
|
|
|
function createTool(params: {
|
|
accountId: string;
|
|
caller:
|
|
| { kind: "operator"; source: "channel-owner" | "local" | "scheduled" }
|
|
| {
|
|
kind: "channel";
|
|
channel: string;
|
|
accountId?: string;
|
|
senderId: string;
|
|
groupSpace?: string;
|
|
roleIds: readonly string[];
|
|
};
|
|
config: OpenClawConfig;
|
|
stateDir: string;
|
|
}) {
|
|
return createTranscriptsTool({
|
|
agentId: "main",
|
|
agentAccountId: params.accountId,
|
|
agentChannel: "discord",
|
|
caller: params.caller,
|
|
config: params.config,
|
|
stateDir: params.stateDir,
|
|
});
|
|
}
|
|
|
|
function storeFor(stateDir: string): TranscriptsStore {
|
|
return new TranscriptsStore(path.join(stateDir, "transcripts"), {
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
|
|
});
|
|
}
|
|
|
|
describe("transcripts tool with the registered Discord provider", () => {
|
|
beforeEach(() => {
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.transcriptSourceProviders.push({
|
|
pluginId: "discord",
|
|
provider: discordVoiceTranscriptsSourceProvider,
|
|
source: "extensions/discord/index.ts",
|
|
});
|
|
setActivePluginRegistry(registry, "discord-transcripts-tool-test");
|
|
});
|
|
|
|
afterEach(() => {
|
|
activeSessions.clear();
|
|
setDiscordTranscriptsVoiceManager({ accountId: "account-a", manager: null });
|
|
setDiscordTranscriptsVoiceManager({ accountId: "account-b", manager: null });
|
|
setActivePluginRegistry(createEmptyPluginRegistry(), "discord-transcripts-tool-test-cleanup");
|
|
closeOpenClawStateDatabaseForTest();
|
|
tempDirs.cleanup();
|
|
});
|
|
|
|
it("keeps a model-requested account switch on the trusted Discord account", async () => {
|
|
const stateDir = tempDirs.make("openclaw-transcripts-discord-provider-");
|
|
const accountAJoin = vi.fn(async () => ({ ok: true, message: "joined account-a" }));
|
|
const accountALeave = vi.fn(async () => ({ ok: true, message: "left account-a" }));
|
|
const accountBJoin = vi.fn(async () => ({ ok: true, message: "joined account-b" }));
|
|
const accountBLeave = vi.fn(async () => ({ ok: true, message: "left account-b" }));
|
|
setDiscordTranscriptsVoiceManager({
|
|
accountId: "account-a",
|
|
manager: {
|
|
join: accountAJoin,
|
|
leave: accountALeave,
|
|
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
|
|
resolveAccessTarget(guildId, channelId),
|
|
} as unknown as DiscordTranscriptsVoiceManager,
|
|
});
|
|
setDiscordTranscriptsVoiceManager({
|
|
accountId: "account-b",
|
|
manager: {
|
|
join: accountBJoin,
|
|
leave: accountBLeave,
|
|
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
|
|
resolveAccessTarget(guildId, channelId),
|
|
} as unknown as DiscordTranscriptsVoiceManager,
|
|
});
|
|
const config = {
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
"account-a": {
|
|
token: "token-a",
|
|
allowFrom: ["discord:allowed"],
|
|
voice: { enabled: true },
|
|
},
|
|
"account-b": { token: "token-b", voice: { enabled: true } },
|
|
},
|
|
},
|
|
},
|
|
transcripts: { enabled: true },
|
|
} satisfies OpenClawConfig;
|
|
const ownerTool = createTool({
|
|
accountId: "account-a",
|
|
caller: {
|
|
kind: "channel",
|
|
channel: "discord",
|
|
accountId: "account-a",
|
|
senderId: "allowed",
|
|
groupSpace: "guild-a",
|
|
roleIds: [],
|
|
},
|
|
config,
|
|
stateDir,
|
|
});
|
|
const otherAccountTool = createTool({
|
|
accountId: "account-b",
|
|
caller: {
|
|
kind: "channel",
|
|
channel: "discord",
|
|
accountId: "account-b",
|
|
senderId: "allowed",
|
|
groupSpace: "guild-a",
|
|
roleIds: [],
|
|
},
|
|
config,
|
|
stateDir,
|
|
});
|
|
const deniedSameAccountTool = createTool({
|
|
accountId: "account-a",
|
|
caller: {
|
|
kind: "channel",
|
|
channel: "discord",
|
|
accountId: "account-a",
|
|
senderId: "blocked",
|
|
groupSpace: "guild-a",
|
|
roleIds: [],
|
|
},
|
|
config,
|
|
stateDir,
|
|
});
|
|
|
|
const startResult = await ownerTool.execute("start-account-bound", {
|
|
action: "start",
|
|
providerId: "discord-voice",
|
|
accountId: "account-b",
|
|
guildId: "guild-a",
|
|
channelId: "voice-a",
|
|
sessionId: "account-bound",
|
|
});
|
|
|
|
expect(startResult.details).toMatchObject({
|
|
accountId: "account-a",
|
|
sessionId: "account-bound",
|
|
});
|
|
expect(accountAJoin).toHaveBeenCalledOnce();
|
|
expect(accountBJoin).not.toHaveBeenCalled();
|
|
await expect(storeFor(stateDir).readSession("account-bound")).resolves.toMatchObject({
|
|
source: { accountId: "account-a" },
|
|
metadata: { agentId: "main" },
|
|
});
|
|
|
|
await expect(
|
|
otherAccountTool.execute("status-other-account", { action: "status" }),
|
|
).resolves.toMatchObject({ details: { active: [] } });
|
|
await expect(
|
|
otherAccountTool.execute("stop-other-account", {
|
|
action: "stop",
|
|
sessionId: "account-bound",
|
|
}),
|
|
).rejects.toThrow("transcripts session not found: account-bound");
|
|
expect(accountBLeave).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
deniedSameAccountTool.execute("status-denied-sender", { action: "status" }),
|
|
).resolves.toMatchObject({ details: { active: [] } });
|
|
await expect(
|
|
deniedSameAccountTool.execute("summarize-denied-sender", {
|
|
action: "summarize",
|
|
sessionId: "account-bound",
|
|
}),
|
|
).rejects.toThrow("transcripts session not found: account-bound");
|
|
await expect(
|
|
deniedSameAccountTool.execute("stop-denied-sender", {
|
|
action: "stop",
|
|
sessionId: "account-bound",
|
|
}),
|
|
).rejects.toThrow("transcripts session not found: account-bound");
|
|
expect(accountALeave).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
ownerTool.execute("stop-owner-account", {
|
|
action: "stop",
|
|
sessionId: "account-bound",
|
|
}),
|
|
).resolves.toMatchObject({ details: { sessionId: "account-bound" } });
|
|
expect(accountALeave).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects a Discord sender that the voice command policy denies", async () => {
|
|
const stateDir = tempDirs.make("openclaw-transcripts-discord-provider-denied-");
|
|
const join = vi.fn(async () => ({ ok: true, message: "joined" }));
|
|
setDiscordTranscriptsVoiceManager({
|
|
accountId: "account-a",
|
|
manager: {
|
|
join,
|
|
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
|
|
resolveAccessTarget(guildId, channelId),
|
|
} as unknown as DiscordTranscriptsVoiceManager,
|
|
});
|
|
const config = {
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
"account-a": {
|
|
token: "token-a",
|
|
allowFrom: ["discord:allowed"],
|
|
voice: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
transcripts: { enabled: true },
|
|
} satisfies OpenClawConfig;
|
|
const deniedTool = createTool({
|
|
accountId: "account-a",
|
|
caller: {
|
|
kind: "channel",
|
|
channel: "discord",
|
|
accountId: "account-a",
|
|
senderId: "blocked",
|
|
groupSpace: "guild-a",
|
|
roleIds: [],
|
|
},
|
|
config,
|
|
stateDir,
|
|
});
|
|
|
|
await expect(
|
|
deniedTool.execute("denied-sender", {
|
|
action: "start",
|
|
providerId: "discord-voice",
|
|
guildId: "guild-a",
|
|
channelId: "voice-a",
|
|
sessionId: "denied-sender",
|
|
}),
|
|
).rejects.toThrow("not authorized");
|
|
expect(join).not.toHaveBeenCalled();
|
|
await expect(storeFor(stateDir).readSession("denied-sender")).resolves.toBeUndefined();
|
|
});
|
|
});
|