diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts index 73fdd29e048..85fc32f8a2f 100644 --- a/src/browser/control-auth.auto-token.test.ts +++ b/src/browser/control-auth.auto-token.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js"; const mocks = vi.hoisted(() => ({ loadConfig: vi.fn<() => OpenClawConfig>(), @@ -38,12 +39,12 @@ describe("ensureBrowserControlAuth", () => { generatedToken?: string; auth: { token?: string }; }) => { - expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); - expect(result.auth.token).toBe(result.generatedToken); expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); - const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; - expect(persisted?.gateway?.auth?.mode).toBe("token"); - expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); + expectGeneratedTokenPersistedToGatewayAuth({ + generatedToken: result.generatedToken, + authToken: result.auth.token, + persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0], + }); }; beforeEach(() => { diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 22aba46d90d..be1a80ae789 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -426,7 +426,7 @@ function createProfileContext( return chosen; }; - const focusTab = async (targetId: string): Promise => { + const resolveTargetIdOrThrow = async (targetId: string): Promise => { const tabs = await listTabs(); const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { @@ -435,6 +435,11 @@ function createProfileContext( } throw new Error("tab not found"); } + return resolved.targetId; + }; + + const focusTab = async (targetId: string): Promise => { + const resolvedTargetId = await resolveTargetIdOrThrow(targetId); if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); @@ -443,28 +448,21 @@ function createProfileContext( if (typeof focusPageByTargetIdViaPlaywright === "function") { await focusPageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, - targetId: resolved.targetId, + targetId: resolvedTargetId, }); const profileState = getProfileState(); - profileState.lastTargetId = resolved.targetId; + profileState.lastTargetId = resolvedTargetId; return; } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`)); + await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`)); const profileState = getProfileState(); - profileState.lastTargetId = resolved.targetId; + profileState.lastTargetId = resolvedTargetId; }; const closeTab = async (targetId: string): Promise => { - const tabs = await listTabs(); - const resolved = resolveTargetIdFromTabs(targetId, tabs); - if (!resolved.ok) { - if (resolved.reason === "ambiguous") { - throw new Error("ambiguous target id prefix"); - } - throw new Error("tab not found"); - } + const resolvedTargetId = await resolveTargetIdOrThrow(targetId); // For remote profiles, use Playwright's persistent connection to close tabs if (!profile.cdpIsLoopback) { @@ -474,13 +472,13 @@ function createProfileContext( if (typeof closePageByTargetIdViaPlaywright === "function") { await closePageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, - targetId: resolved.targetId, + targetId: resolvedTargetId, }); return; } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`)); + await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`)); }; const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index a3cf8efa380..592f5bd4654 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -90,6 +90,40 @@ function createRuntimeWithExitSignal(exitCallOrder?: string[]) { return { runtime, exited }; } +type GatewayCloseFn = (...args: unknown[]) => Promise; +type LoopRuntime = { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: (code: number) => void; +}; + +function createSignaledStart(close: GatewayCloseFn) { + let resolveStarted: (() => void) | null = null; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const start = vi.fn(async () => { + resolveStarted?.(); + return { close }; + }); + return { start, started }; +} + +async function runLoopWithStart(params: { start: ReturnType; runtime: LoopRuntime }) { + vi.resetModules(); + const { runGatewayLoop } = await import("./run-loop.js"); + const loopPromise = runGatewayLoop({ + start: params.start as unknown as Parameters[0]["start"], + runtime: params.runtime, + }); + return { loopPromise }; +} + +async function waitForStart(started: Promise) { + await started; + await new Promise((resolve) => setImmediate(resolve)); +} + describe("runGatewayLoop", () => { it("exits 0 on SIGTERM after graceful close", async () => { vi.clearAllMocks(); @@ -221,15 +255,7 @@ describe("runGatewayLoop", () => { }); const close = vi.fn(async () => {}); - let resolveStarted: (() => void) | null = null; - const started = new Promise((resolve) => { - resolveStarted = resolve; - }); - - const start = vi.fn(async () => { - resolveStarted?.(); - return { close }; - }); + const { start, started } = createSignaledStart(close); const exitCallOrder: string[] = []; const { runtime, exited } = createRuntimeWithExitSignal(exitCallOrder); @@ -237,15 +263,9 @@ describe("runGatewayLoop", () => { exitCallOrder.push("lockRelease"); }); - vi.resetModules(); - const { runGatewayLoop } = await import("./run-loop.js"); - const _loopPromise = runGatewayLoop({ - start: start as unknown as Parameters[0]["start"], - runtime: runtime as unknown as Parameters[0]["runtime"], - }); + const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime }); - await started; - await new Promise((resolve) => setImmediate(resolve)); + await waitForStart(started); process.emit("SIGUSR1"); @@ -272,26 +292,13 @@ describe("runGatewayLoop", () => { }); const close = vi.fn(async () => {}); - let resolveStarted: (() => void) | null = null; - const started = new Promise((resolve) => { - resolveStarted = resolve; - }); - const start = vi.fn(async () => { - resolveStarted?.(); - return { close }; - }); + const { start, started } = createSignaledStart(close); const { runtime, exited } = createRuntimeWithExitSignal(); - vi.resetModules(); - const { runGatewayLoop } = await import("./run-loop.js"); - const _loopPromise = runGatewayLoop({ - start: start as unknown as Parameters[0]["start"], - runtime: runtime as unknown as Parameters[0]["runtime"], - }); + const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime }); - await started; - await new Promise((resolve) => setImmediate(resolve)); + await waitForStart(started); process.emit("SIGUSR1"); await expect(exited).resolves.toBe(1); diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 00a7d62ca30..a4007d8c66b 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -138,6 +138,68 @@ function createDefaultThreadConfig(): LoadedConfig { } as LoadedConfig; } +function createMentionRequiredGuildConfig( + params: { + messages?: LoadedConfig["messages"]; + } = {}, +): LoadedConfig { + return { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/openclaw", + }, + }, + session: { store: "/tmp/openclaw-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { "*": { requireMention: true } }, + }, + }, + ...(params.messages ? { messages: params.messages } : {}), + } as LoadedConfig; +} + +function createGuildTextClient() { + return { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + }), + } as unknown as Client; +} + +function createGuildMessageEvent(params: { + messageId: string; + content: string; + messagePatch?: Record; + eventPatch?: Record; +}) { + return { + message: { + id: params.messageId, + content: params.content, + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + ...params.messagePatch, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + ...params.eventPatch, + }; +} + function createThreadChannel(params: { includeStarter?: boolean } = {}) { return { type: ChannelType.GuildText, @@ -209,56 +271,18 @@ describe("discord tool result dispatch", () => { it( "accepts guild messages when mentionPatterns match", async () => { - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: "/tmp/openclaw", - }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { "*": { requireMention: true } }, - }, - }, + const cfg = createMentionRequiredGuildConfig({ messages: { responsePrefix: "PFX", groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, }, - } as ReturnType; + }); const handler = await createHandler(cfg); - - const client = { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.GuildText, - name: "general", - }), - } as unknown as Client; + const client = createGuildTextClient(); await handler( - { - message: { - id: "m2", - content: "openclaw: hello", - channelId: "c1", - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - author: { id: "u1", bot: false, username: "Ada" }, - }, - author: { id: "u1", bot: false, username: "Ada" }, - member: { nickname: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }, + createGuildMessageEvent({ messageId: "m2", content: "openclaw: hello" }), client, ); @@ -323,46 +347,16 @@ describe("discord tool result dispatch", () => { ); it("accepts guild reply-to-bot messages as implicit mentions", async () => { - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: "/tmp/openclaw", - }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { "*": { requireMention: true } }, - }, - }, - } as ReturnType; + const cfg = createMentionRequiredGuildConfig(); const handler = await createHandler(cfg); - - const client = { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.GuildText, - name: "general", - }), - } as unknown as Client; + const client = createGuildTextClient(); await handler( - { - message: { - id: "m3", - content: "following up", - channelId: "c1", - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - author: { id: "u1", bot: false, username: "Ada" }, + createGuildMessageEvent({ + messageId: "m3", + content: "following up", + messagePatch: { referencedMessage: { id: "m2", channelId: "c1", @@ -377,21 +371,19 @@ describe("discord tool result dispatch", () => { author: { id: "bot-id", bot: true, username: "OpenClaw" }, }, }, - author: { id: "u1", bot: false, username: "Ada" }, - member: { nickname: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - channel: { id: "c1", type: ChannelType.GuildText }, - client, - data: { - id: "m3", - content: "following up", - channel_id: "c1", - guild_id: "g1", - type: MessageType.Default, - mentions: [], + eventPatch: { + channel: { id: "c1", type: ChannelType.GuildText }, + client, + data: { + id: "m3", + content: "following up", + channel_id: "c1", + guild_id: "g1", + type: MessageType.Default, + mentions: [], + }, }, - }, + }), client, ); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index c43752754f3..99fa5c9ddcf 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -51,15 +51,18 @@ const CATEGORY_GUILD_CFG = { }, } satisfies Config; -async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) { - return createDiscordMessageHandler({ - cfg: opts.cfg, - discordConfig: opts.cfg.channels?.discord, +function createHandlerBaseConfig( + cfg: Config, + runtimeError?: (err: unknown) => void, +): Parameters[0] { + return { + cfg, + discordConfig: cfg.channels?.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), - error: opts.runtimeError ?? vi.fn(), + error: runtimeError ?? vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, @@ -73,7 +76,11 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown dmEnabled: true, groupDmEnabled: false, threadBindings: createNoopThreadBindingManager("default"), - }); + }; +} + +async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) { + return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError)); } function createDmClient() { @@ -87,29 +94,10 @@ function createDmClient() { async function createCategoryGuildHandler() { return createDiscordMessageHandler({ - cfg: CATEGORY_GUILD_CFG, - discordConfig: CATEGORY_GUILD_CFG.channels?.discord, - accountId: "default", - token: "token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "bot-id", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off", - dmEnabled: true, - groupDmEnabled: false, + ...createHandlerBaseConfig(CATEGORY_GUILD_CFG), guildEntries: { "*": { requireMention: false, channels: { c1: { allow: true } } }, }, - threadBindings: createNoopThreadBindingManager("default"), }); } @@ -124,6 +112,32 @@ function createCategoryGuildClient() { } as unknown as Client; } +function createCategoryGuildEvent(params: { + messageId: string; + timestamp?: string; + author: Record; +}) { + return { + message: { + id: params.messageId, + content: "hello", + channelId: "c1", + timestamp: params.timestamp ?? new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: params.author, + }, + author: params.author, + member: { displayName: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }; +} + describe("discord tool result dispatch", () => { it("uses channel id allowlists for non-thread channels with categories", async () => { let capturedCtx: { SessionKey?: string } | undefined; @@ -137,25 +151,10 @@ describe("discord tool result dispatch", () => { const client = createCategoryGuildClient(); await handler( - { - message: { - id: "m-category", - content: "hello", - channelId: "c1", - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, - }, + createCategoryGuildEvent({ + messageId: "m-category", author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, - member: { displayName: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }, + }), client, ); @@ -174,25 +173,11 @@ describe("discord tool result dispatch", () => { const client = createCategoryGuildClient(); await handler( - { - message: { - id: "m-prefix", - content: "hello", - channelId: "c1", - timestamp: new Date("2026-01-17T00:00:00Z").toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" }, - }, + createCategoryGuildEvent({ + messageId: "m-prefix", + timestamp: new Date("2026-01-17T00:00:00Z").toISOString(), author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" }, - member: { displayName: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }, + }), client, ); diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 3acab4e439f..66f3c85905f 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -223,6 +223,12 @@ function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayload return { components }; } +function formatCommandPreview(commandText: string, maxChars: number): string { + const commandRaw = + commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText; + return commandRaw.replace(/`/g, "\u200b`"); +} + function createExecApprovalRequestContainer(params: { request: ExecApprovalRequest; cfg: OpenClawConfig; @@ -230,8 +236,7 @@ function createExecApprovalRequestContainer(params: { actionRow?: Row