diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index e5133830618..b0a01929eea 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -38,6 +38,53 @@ afterAll(async () => { await cleanupMockRuntimeFixtures(); }); +async function expectSessionEnsureFallback(params: { + sessionKey: string; + env?: Record; + expectNewAfterStatus: boolean; + expectedRecordId?: string; +}) { + const previousEnv = new Map(); + for (const [key, value] of Object.entries(params.env ?? {})) { + previousEnv.set(key, process.env[key]); + process.env[key] = value; + } + + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const handle = await runtime.ensureSession({ + sessionKey: params.sessionKey, + agent: "codex", + mode: "persistent", + }); + + expect(handle.backend).toBe("acpx"); + if (params.expectedRecordId) { + expect(handle.acpxRecordId).toBe(params.expectedRecordId); + } + + const logs = await readMockRuntimeLogEntries(logPath); + const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); + const statusIndex = logs.findIndex((entry) => entry.kind === "status"); + const newIndex = logs.findIndex((entry) => entry.kind === "new"); + expect(ensureIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(ensureIndex); + if (params.expectNewAfterStatus) { + expect(newIndex).toBeGreaterThan(statusIndex); + } else { + expect(newIndex).toBe(-1); + } + } finally { + for (const [key, value] of previousEnv.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + describe("AcpxRuntime", () => { it("passes the shared ACP adapter contract suite", async () => { const fixture = await createMockRuntimeFixture(); @@ -155,87 +202,38 @@ describe("AcpxRuntime", () => { }); it("replaces dead named sessions returned by sessions ensure", async () => { - process.env.MOCK_ACPX_STATUS_STATUS = "dead"; - process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; - try { - const { runtime, logPath } = await createMockRuntimeFixture(); - const sessionKey = "agent:codex:acp:dead-session"; - - const handle = await runtime.ensureSession({ - sessionKey, - agent: "codex", - mode: "persistent", - }); - - expect(handle.backend).toBe("acpx"); - const logs = await readMockRuntimeLogEntries(logPath); - const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); - const statusIndex = logs.findIndex((entry) => entry.kind === "status"); - const newIndex = logs.findIndex((entry) => entry.kind === "new"); - expect(ensureIndex).toBeGreaterThanOrEqual(0); - expect(statusIndex).toBeGreaterThan(ensureIndex); - expect(newIndex).toBeGreaterThan(statusIndex); - } finally { - delete process.env.MOCK_ACPX_STATUS_STATUS; - delete process.env.MOCK_ACPX_STATUS_SUMMARY; - } + await expectSessionEnsureFallback({ + sessionKey: "agent:codex:acp:dead-session", + env: { + MOCK_ACPX_STATUS_STATUS: "dead", + MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable", + }, + expectNewAfterStatus: true, + }); }); it("reuses a live named session when sessions ensure exits before returning identifiers", async () => { - process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; - process.env.MOCK_ACPX_STATUS_STATUS = "alive"; - try { - const { runtime, logPath } = await createMockRuntimeFixture(); - const sessionKey = "agent:codex:acp:ensure-fallback-alive"; - - const handle = await runtime.ensureSession({ - sessionKey, - agent: "codex", - mode: "persistent", - }); - - expect(handle.backend).toBe("acpx"); - expect(handle.acpxRecordId).toBe("rec-" + sessionKey); - const logs = await readMockRuntimeLogEntries(logPath); - const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); - const statusIndex = logs.findIndex((entry) => entry.kind === "status"); - const newIndex = logs.findIndex((entry) => entry.kind === "new"); - expect(ensureIndex).toBeGreaterThanOrEqual(0); - expect(statusIndex).toBeGreaterThan(ensureIndex); - expect(newIndex).toBe(-1); - } finally { - delete process.env.MOCK_ACPX_ENSURE_EXIT_1; - delete process.env.MOCK_ACPX_STATUS_STATUS; - } + await expectSessionEnsureFallback({ + sessionKey: "agent:codex:acp:ensure-fallback-alive", + env: { + MOCK_ACPX_ENSURE_EXIT_1: "1", + MOCK_ACPX_STATUS_STATUS: "alive", + }, + expectNewAfterStatus: false, + expectedRecordId: "rec-agent:codex:acp:ensure-fallback-alive", + }); }); it("creates a fresh named session when sessions ensure exits and status is dead", async () => { - process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1"; - process.env.MOCK_ACPX_STATUS_STATUS = "dead"; - process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable"; - try { - const { runtime, logPath } = await createMockRuntimeFixture(); - const sessionKey = "agent:codex:acp:ensure-fallback-dead"; - - const handle = await runtime.ensureSession({ - sessionKey, - agent: "codex", - mode: "persistent", - }); - - expect(handle.backend).toBe("acpx"); - const logs = await readMockRuntimeLogEntries(logPath); - const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure"); - const statusIndex = logs.findIndex((entry) => entry.kind === "status"); - const newIndex = logs.findIndex((entry) => entry.kind === "new"); - expect(ensureIndex).toBeGreaterThanOrEqual(0); - expect(statusIndex).toBeGreaterThan(ensureIndex); - expect(newIndex).toBeGreaterThan(statusIndex); - } finally { - delete process.env.MOCK_ACPX_ENSURE_EXIT_1; - delete process.env.MOCK_ACPX_STATUS_STATUS; - delete process.env.MOCK_ACPX_STATUS_SUMMARY; - } + await expectSessionEnsureFallback({ + sessionKey: "agent:codex:acp:ensure-fallback-dead", + env: { + MOCK_ACPX_ENSURE_EXIT_1: "1", + MOCK_ACPX_STATUS_STATUS: "dead", + MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable", + }, + expectNewAfterStatus: true, + }); }); it("serializes text plus image attachments into ACP prompt blocks", async () => { diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index ce82bc5a42e..6224d41c5bc 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { __testing } from "./brave-web-search-provider.js"; +import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js"; describe("brave web search provider", () => { it("normalizes brave language parameters and swaps reversed ui/search inputs", () => { @@ -49,4 +49,25 @@ describe("brave web search provider", () => { }, ]); }); + + it("returns validation errors for invalid date ranges", async () => { + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { brave: { apiKey: "BSA..." } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "latest gpu news", + date_after: "2026-03-20", + date_before: "2026-03-01", + }); + + expect(result).toMatchObject({ + error: "invalid_date_range", + }); + }); }); diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 844ba3f8ac4..d4e3bd4ce03 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -6,7 +6,7 @@ import { formatCliCommand, mergeScopedSearchConfig, normalizeFreshness, - normalizeToIsoDate, + parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -478,29 +478,17 @@ function createBraveToolDefinition( docs: "https://docs.openclaw.ai/tools/web", }; } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - if (rawDateAfter && !dateAfter) { - return { - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateBefore && !dateBefore) { - return { - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return { - error: "invalid_date_range", - message: "date_after must be before date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }; + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be before date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; } + const { dateAfter, dateBefore } = parsedDateRange; const cacheKey = buildSearchCacheKey([ "brave", diff --git a/extensions/device-pair/qr-image.ts b/extensions/device-pair/qr-image.ts index be6b10f5b0e..fe28c771a4e 100644 --- a/extensions/device-pair/qr-image.ts +++ b/extensions/device-pair/qr-image.ts @@ -1,54 +1 @@ -import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; -import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; -import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; - -type QRCodeConstructor = new ( - typeNumber: number, - errorCorrectLevel: unknown, -) => { - addData: (data: string) => void; - make: () => void; - getModuleCount: () => number; - isDark: (row: number, col: number) => boolean; -}; - -const QRCode = QRCodeModule as QRCodeConstructor; -const QRErrorCorrectLevel = QRErrorCorrectLevelModule; - -function createQrMatrix(input: string) { - const qr = new QRCode(-1, QRErrorCorrectLevel.L); - qr.addData(input); - qr.make(); - return qr; -} - -export async function renderQrPngBase64( - input: string, - opts: { scale?: number; marginModules?: number } = {}, -): Promise { - const { scale = 6, marginModules = 4 } = opts; - const qr = createQrMatrix(input); - const modules = qr.getModuleCount(); - const size = (modules + marginModules * 2) * scale; - - const buf = Buffer.alloc(size * size * 4, 255); - for (let row = 0; row < modules; row += 1) { - for (let col = 0; col < modules; col += 1) { - if (!qr.isDark(row, col)) { - continue; - } - const startX = (col + marginModules) * scale; - const startY = (row + marginModules) * scale; - for (let y = 0; y < scale; y += 1) { - const pixelY = startY + y; - for (let x = 0; x < scale; x += 1) { - const pixelX = startX + x; - fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); - } - } - } - } - - const png = encodePngRgba(buf, size, size); - return png.toString("base64"); -} +export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index 8a1e7b13d43..b3e8f0f0f9b 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,17 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../../test/helpers/extensions/configured-binding-runtime.js"; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - ensureConfiguredBindingRouteReady: (...args: unknown[]) => - ensureConfiguredBindingRouteReadyMock(...args), - resolveConfiguredBindingRoute: (...args: unknown[]) => - resolveConfiguredBindingRouteMock(...args), - }; + return await createConfiguredBindingConversationRuntimeModuleMock( + { + ensureConfiguredBindingRouteReadyMock, + resolveConfiguredBindingRouteMock, + }, + importOriginal, + ); }); import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js"; diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts index e2c8b749d24..33875a7d4ba 100644 --- a/extensions/discord/src/outbound-adapter.interactive-order.test.ts +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -1,46 +1,30 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDiscordOutboundHoisted, + createDiscordSendModuleMock, + createDiscordThreadBindingsModuleMock, + resetDiscordOutboundMocks, +} from "./outbound-adapter.test-harness.js"; -const hoisted = vi.hoisted(() => ({ - sendDiscordComponentMessageMock: vi.fn(), - sendMessageDiscordMock: vi.fn(), - sendPollDiscordMock: vi.fn(), - sendWebhookMessageDiscordMock: vi.fn(), - getThreadBindingManagerMock: vi.fn(), -})); +const hoisted = createDiscordOutboundHoisted(); vi.mock("./send.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - sendDiscordComponentMessage: (...args: unknown[]) => - hoisted.sendDiscordComponentMessageMock(...args), - sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), - sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), - sendWebhookMessageDiscord: (...args: unknown[]) => - hoisted.sendWebhookMessageDiscordMock(...args), - }; + return await createDiscordSendModuleMock(hoisted, importOriginal); }); vi.mock("./monitor/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args), - }; + return await createDiscordThreadBindingsModuleMock(hoisted, importOriginal); }); const { discordOutbound } = await import("./outbound-adapter.js"); describe("discordOutbound shared interactive ordering", () => { beforeEach(() => { - hoisted.sendDiscordComponentMessageMock.mockReset().mockResolvedValue({ + resetDiscordOutboundMocks(hoisted); + hoisted.sendDiscordComponentMessageMock.mockResolvedValue({ messageId: "msg-1", channelId: "123456", }); - hoisted.sendMessageDiscordMock.mockReset(); - hoisted.sendPollDiscordMock.mockReset(); - hoisted.sendWebhookMessageDiscordMock.mockReset(); - hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null); }); it("keeps shared text blocks in authored order without hoisting fallback text", async () => { diff --git a/extensions/discord/src/outbound-adapter.test-harness.ts b/extensions/discord/src/outbound-adapter.test-harness.ts new file mode 100644 index 00000000000..2562863c68d --- /dev/null +++ b/extensions/discord/src/outbound-adapter.test-harness.ts @@ -0,0 +1,109 @@ +import { expect, vi } from "vitest"; + +export function createDiscordOutboundHoisted() { + const sendMessageDiscordMock = vi.fn(); + const sendDiscordComponentMessageMock = vi.fn(); + const sendPollDiscordMock = vi.fn(); + const sendWebhookMessageDiscordMock = vi.fn(); + const getThreadBindingManagerMock = vi.fn(); + return { + sendMessageDiscordMock, + sendDiscordComponentMessageMock, + sendPollDiscordMock, + sendWebhookMessageDiscordMock, + getThreadBindingManagerMock, + }; +} + +type DiscordSendModule = typeof import("./send.js"); +type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js"); + +export const DEFAULT_DISCORD_SEND_RESULT = { + channel: "discord", + messageId: "msg-1", + channelId: "ch-1", +} as const; + +type DiscordOutboundHoisted = ReturnType; + +export async function createDiscordSendModuleMock( + hoisted: DiscordOutboundHoisted, + importOriginal: () => Promise, +) { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), + sendDiscordComponentMessage: (...args: unknown[]) => + hoisted.sendDiscordComponentMessageMock(...args), + sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => + hoisted.sendWebhookMessageDiscordMock(...args), + }; +} + +export async function createDiscordThreadBindingsModuleMock( + hoisted: DiscordOutboundHoisted, + importOriginal: () => Promise, +) { + const actual = await importOriginal(); + return { + ...actual, + getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args), + }; +} + +export function resetDiscordOutboundMocks(hoisted: DiscordOutboundHoisted) { + hoisted.sendMessageDiscordMock.mockReset().mockResolvedValue({ + messageId: "msg-1", + channelId: "ch-1", + }); + hoisted.sendDiscordComponentMessageMock.mockReset().mockResolvedValue({ + messageId: "component-1", + channelId: "ch-1", + }); + hoisted.sendPollDiscordMock.mockReset().mockResolvedValue({ + messageId: "poll-1", + channelId: "ch-1", + }); + hoisted.sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({ + messageId: "msg-webhook-1", + channelId: "thread-1", + }); + hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null); +} + +export function expectDiscordThreadBotSend(params: { + hoisted: DiscordOutboundHoisted; + text: string; + result: unknown; + options?: Record; +}) { + expect(params.hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:thread-1", + params.text, + expect.objectContaining({ + accountId: "default", + ...params.options, + }), + ); + expect(params.result).toEqual(DEFAULT_DISCORD_SEND_RESULT); +} + +export function mockDiscordBoundThreadManager(hoisted: DiscordOutboundHoisted) { + hoisted.getThreadBindingManagerMock.mockReturnValue({ + getByThreadId: () => ({ + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:child", + agentId: "main", + label: "codex-thread", + webhookId: "wh-1", + webhookToken: "tok-1", + boundBy: "system", + boundAt: Date.now(), + }), + }); +} diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index d6eba6da699..e4be854b895 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -1,39 +1,21 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDiscordOutboundHoisted, + createDiscordSendModuleMock, + createDiscordThreadBindingsModuleMock, + expectDiscordThreadBotSend, + mockDiscordBoundThreadManager, + resetDiscordOutboundMocks, +} from "./outbound-adapter.test-harness.js"; -const hoisted = vi.hoisted(() => { - const sendMessageDiscordMock = vi.fn(); - const sendDiscordComponentMessageMock = vi.fn(); - const sendPollDiscordMock = vi.fn(); - const sendWebhookMessageDiscordMock = vi.fn(); - const getThreadBindingManagerMock = vi.fn(); - return { - sendMessageDiscordMock, - sendDiscordComponentMessageMock, - sendPollDiscordMock, - sendWebhookMessageDiscordMock, - getThreadBindingManagerMock, - }; -}); +const hoisted = createDiscordOutboundHoisted(); vi.mock("./send.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), - sendDiscordComponentMessage: (...args: unknown[]) => - hoisted.sendDiscordComponentMessageMock(...args), - sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), - sendWebhookMessageDiscord: (...args: unknown[]) => - hoisted.sendWebhookMessageDiscordMock(...args), - }; + return await createDiscordSendModuleMock(hoisted, importOriginal); }); vi.mock("./monitor/thread-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args), - }; + return await createDiscordThreadBindingsModuleMock(hoisted, importOriginal); }); let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget; @@ -44,46 +26,6 @@ beforeAll(async () => { ({ discordOutbound } = await import("./outbound-adapter.js")); }); -const DEFAULT_DISCORD_SEND_RESULT = { - channel: "discord", - messageId: "msg-1", - channelId: "ch-1", -} as const; - -function expectThreadBotSend(params: { - text: string; - result: unknown; - options?: Record; -}) { - expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( - "channel:thread-1", - params.text, - expect.objectContaining({ - accountId: "default", - ...params.options, - }), - ); - expect(params.result).toEqual(DEFAULT_DISCORD_SEND_RESULT); -} - -function mockBoundThreadManager() { - hoisted.getThreadBindingManagerMock.mockReturnValue({ - getByThreadId: () => ({ - accountId: "default", - channelId: "parent-1", - threadId: "thread-1", - targetKind: "subagent", - targetSessionKey: "agent:main:subagent:child", - agentId: "main", - label: "codex-thread", - webhookId: "wh-1", - webhookToken: "tok-1", - boundBy: "system", - boundAt: Date.now(), - }), - }); -} - describe("normalizeDiscordOutboundTarget", () => { it("normalizes bare numeric IDs to channel: prefix", () => { expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({ @@ -119,23 +61,7 @@ describe("normalizeDiscordOutboundTarget", () => { describe("discordOutbound", () => { beforeEach(() => { - hoisted.sendMessageDiscordMock.mockClear().mockResolvedValue({ - messageId: "msg-1", - channelId: "ch-1", - }); - hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({ - messageId: "component-1", - channelId: "ch-1", - }); - hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({ - messageId: "poll-1", - channelId: "ch-1", - }); - hoisted.sendWebhookMessageDiscordMock.mockClear().mockResolvedValue({ - messageId: "msg-webhook-1", - channelId: "thread-1", - }); - hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null); + resetDiscordOutboundMocks(hoisted); }); it("routes text sends to thread target when threadId is provided", async () => { @@ -147,14 +73,15 @@ describe("discordOutbound", () => { threadId: "thread-1", }); - expectThreadBotSend({ + expectDiscordThreadBotSend({ + hoisted, text: "hello", result, }); }); it("uses webhook persona delivery for bound thread text replies", async () => { - mockBoundThreadManager(); + mockDiscordBoundThreadManager(hoisted); const cfg = { channels: { discord: { @@ -201,7 +128,7 @@ describe("discordOutbound", () => { }); it("falls back to bot send for silent delivery on bound threads", async () => { - mockBoundThreadManager(); + mockDiscordBoundThreadManager(hoisted); const result = await discordOutbound.sendText?.({ cfg: {}, @@ -213,7 +140,8 @@ describe("discordOutbound", () => { }); expect(hoisted.sendWebhookMessageDiscordMock).not.toHaveBeenCalled(); - expectThreadBotSend({ + expectDiscordThreadBotSend({ + hoisted, text: "silent update", result, options: { silent: true }, @@ -221,7 +149,7 @@ describe("discordOutbound", () => { }); it("falls back to bot send when webhook send fails", async () => { - mockBoundThreadManager(); + mockDiscordBoundThreadManager(hoisted); hoisted.sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited")); const result = await discordOutbound.sendText?.({ @@ -233,7 +161,8 @@ describe("discordOutbound", () => { }); expect(hoisted.sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1); - expectThreadBotSend({ + expectDiscordThreadBotSend({ + hoisted, text: "fallback", result, }); diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 30b682f7992..3e216368d68 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -141,6 +141,7 @@ export function createDiscordSetupWizardBase(handlers: { }, ], groupAccess: createAccountScopedGroupAccessSection({ + channel, label: "Discord channels", placeholder: "My Server/#general, guildId/channelId, #support", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => @@ -172,6 +173,7 @@ export function createDiscordSetupWizardBase(handlers: { }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), }), allowFrom: createAccountScopedAllowFromSection({ + channel, credentialInputKey: "token", helpTitle: "Discord allowlist", helpLines: [ diff --git a/extensions/discord/src/setup-runtime-helpers.ts b/extensions/discord/src/setup-runtime-helpers.ts index 0d5cfff68a4..869442957e3 100644 --- a/extensions/discord/src/setup-runtime-helpers.ts +++ b/extensions/discord/src/setup-runtime-helpers.ts @@ -1,436 +1,11 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, - WizardPrompter, +export { + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createAllowlistSetupWizardProxy, + createLegacyCompatChannelDmPolicy, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + promptLegacyChannelAllowFromForAccount, + resolveEntriesWithOptionalToken, + setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup-runtime"; -import { - resolveDefaultDiscordSetupAccountId, - resolveDiscordSetupAccountConfig, -} from "./setup-account-state.js"; - -export function parseMentionOrPrefixedId(params: { - value: string; - mentionPattern: RegExp; - prefixPattern?: RegExp; - idPattern: RegExp; - normalizeId?: (id: string) => string; -}): string | null { - const trimmed = params.value.trim(); - if (!trimmed) { - return null; - } - const mentionMatch = trimmed.match(params.mentionPattern); - if (mentionMatch?.[1]) { - return params.normalizeId ? params.normalizeId(mentionMatch[1]) : mentionMatch[1]; - } - if (params.prefixPattern?.test(trimmed)) { - const stripped = trimmed.replace(params.prefixPattern, "").trim(); - if (!stripped || !params.idPattern.test(stripped)) { - return null; - } - return params.normalizeId ? params.normalizeId(stripped) : stripped; - } - if (!params.idPattern.test(trimmed)) { - return null; - } - return params.normalizeId ? params.normalizeId(trimmed) : trimmed; -} - -function splitSetupEntries(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function mergeAllowFromEntries( - current: Array | null | undefined, - additions: Array, -): string[] { - const merged = [...(current ?? []), ...additions] - .map((value) => String(value).trim()) - .filter(Boolean); - return [...new Set(merged)]; -} - -function patchDiscordChannelConfigForAccount(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const channelConfig = (params.cfg.channels?.discord as Record | undefined) ?? {}; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - discord: { - ...channelConfig, - ...params.patch, - enabled: true, - }, - }, - }; - } - const accounts = - (channelConfig.accounts as Record> | undefined) ?? {}; - const accountConfig = accounts[accountId] ?? {}; - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - discord: { - ...channelConfig, - enabled: true, - accounts: { - ...accounts, - [accountId]: { - ...accountConfig, - ...params.patch, - enabled: true, - }, - }, - }, - }, - }; -} - -export function setSetupChannelEnabled( - cfg: OpenClawConfig, - channel: string, - enabled: boolean, -): OpenClawConfig { - const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - [channel]: { - ...channelConfig, - enabled, - }, - }, - }; -} - -export function patchChannelConfigForAccount(params: { - cfg: OpenClawConfig; - channel: "discord"; - accountId: string; - patch: Record; -}): OpenClawConfig { - return patchDiscordChannelConfigForAccount({ - cfg: params.cfg, - accountId: params.accountId, - patch: params.patch, - }); -} - -export function createLegacyCompatChannelDmPolicy(params: { - label: string; - channel: "discord"; - promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; -}): ChannelSetupDmPolicy { - return { - label: params.label, - channel: params.channel, - policyKey: `channels.${params.channel}.dmPolicy`, - allowFromKey: `channels.${params.channel}.allowFrom`, - getCurrent: (cfg) => - ( - cfg.channels?.[params.channel] as - | { - dmPolicy?: "open" | "pairing" | "allowlist"; - dm?: { policy?: "open" | "pairing" | "allowlist" }; - } - | undefined - )?.dmPolicy ?? - ( - cfg.channels?.[params.channel] as - | { - dmPolicy?: "open" | "pairing" | "allowlist"; - dm?: { policy?: "open" | "pairing" | "allowlist" }; - } - | undefined - )?.dm?.policy ?? - "pairing", - setPolicy: (cfg, policy) => - patchDiscordChannelConfigForAccount({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - patch: { - dmPolicy: policy, - ...(policy === "open" - ? { - allowFrom: [ - ...new Set( - [ - ...((( - cfg.channels?.discord as { allowFrom?: Array } | undefined - )?.allowFrom ?? []) as Array), - "*", - ] - .map((value) => String(value).trim()) - .filter(Boolean), - ), - ], - } - : {}), - }, - }), - ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), - }; -} - -async function noteChannelLookupFailure(params: { - prompter: Pick; - label: string; - error: unknown; -}) { - await params.prompter.note( - `Channel lookup failed; keeping entries as typed. ${String(params.error)}`, - params.label, - ); -} - -export function createAccountScopedAllowFromSection(params: { - credentialInputKey?: NonNullable["credentialInputKey"]; - helpTitle?: string; - helpLines?: string[]; - message: string; - placeholder: string; - invalidWithoutCredentialNote: string; - parseId: NonNullable["parseId"]>; - resolveEntries: NonNullable["resolveEntries"]>; -}): NonNullable { - return { - ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), - ...(params.helpLines ? { helpLines: params.helpLines } : {}), - ...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}), - message: params.message, - placeholder: params.placeholder, - invalidWithoutCredentialNote: params.invalidWithoutCredentialNote, - parseId: params.parseId, - resolveEntries: params.resolveEntries, - apply: ({ cfg, accountId, allowFrom }) => - patchDiscordChannelConfigForAccount({ - cfg, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }; -} - -export function createAccountScopedGroupAccessSection(params: { - label: string; - placeholder: string; - helpTitle?: string; - helpLines?: string[]; - skipAllowlistEntries?: boolean; - currentPolicy: NonNullable["currentPolicy"]; - currentEntries: NonNullable["currentEntries"]; - updatePrompt: NonNullable["updatePrompt"]; - resolveAllowlist?: NonNullable< - NonNullable["resolveAllowlist"] - >; - fallbackResolved: (entries: string[]) => TResolved; - applyAllowlist: (params: { - cfg: OpenClawConfig; - accountId: string; - resolved: TResolved; - }) => OpenClawConfig; -}): NonNullable { - return { - label: params.label, - placeholder: params.placeholder, - ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), - ...(params.helpLines ? { helpLines: params.helpLines } : {}), - ...(params.skipAllowlistEntries ? { skipAllowlistEntries: true } : {}), - currentPolicy: params.currentPolicy, - currentEntries: params.currentEntries, - updatePrompt: params.updatePrompt, - setPolicy: ({ cfg, accountId, policy }) => - patchDiscordChannelConfigForAccount({ - cfg, - accountId, - patch: { groupPolicy: policy }, - }), - ...(params.resolveAllowlist - ? { - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - try { - return await params.resolveAllowlist!({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: params.label, - error, - }); - return params.fallbackResolved(entries); - } - }, - } - : {}), - applyAllowlist: ({ cfg, accountId, resolved }) => - params.applyAllowlist({ - cfg, - accountId, - resolved: resolved as TResolved, - }), - }; -} - -export function createAllowlistSetupWizardProxy(params: { - loadWizard: () => Promise; - createBase: (handlers: { - promptAllowFrom: NonNullable; - resolveAllowFromEntries: NonNullable< - NonNullable["resolveEntries"] - >; - resolveGroupAllowlist: NonNullable< - NonNullable["resolveAllowlist"]> - >; - }) => ChannelSetupWizard; - fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved; -}) { - return params.createBase({ - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = await params.loadWizard(); - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, - resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { - const wizard = await params.loadWizard(); - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, - resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const wizard = await params.loadWizard(); - if (!wizard.groupAccess?.resolveAllowlist) { - return params.fallbackResolvedGroupAllowlist(entries) as Awaited< - ReturnType< - NonNullable["resolveAllowlist"]> - > - >; - } - return (await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - })) as Awaited< - ReturnType["resolveAllowlist"]>> - >; - }, - }); -} - -export async function resolveEntriesWithOptionalToken(params: { - token?: string | null; - entries: string[]; - buildWithoutToken: (input: string) => TResult; - resolveEntries: (params: { token: string; entries: string[] }) => Promise; -}): Promise { - const token = params.token?.trim(); - if (!token) { - return params.entries.map(params.buildWithoutToken); - } - return await params.resolveEntries({ - token, - entries: params.entries, - }); -} - -export async function promptLegacyChannelAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; - noteTitle: string; - noteLines: string[]; - message: string; - placeholder: string; - parseId: (value: string) => string | null; - invalidWithoutTokenNote: string; - resolveEntries: (params: { - token: string; - entries: string[]; - }) => Promise>; - resolveToken: (accountId: string) => string | null | undefined; - resolveExisting: (accountId: string, cfg: OpenClawConfig) => Array; -}): Promise { - const accountId = normalizeAccountId( - params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg), - ); - await params.prompter.note(params.noteLines.join("\n"), params.noteTitle); - const token = params.resolveToken(accountId); - const existing = params.resolveExisting(accountId, params.cfg); - - while (true) { - const entry = await params.prompter.text({ - message: params.message, - placeholder: params.placeholder, - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = splitSetupEntries(String(entry)); - if (!token) { - const ids = parts.map(params.parseId).filter(Boolean) as string[]; - if (ids.length !== parts.length) { - await params.prompter.note(params.invalidWithoutTokenNote, params.noteTitle); - continue; - } - return patchDiscordChannelConfigForAccount({ - cfg: params.cfg, - accountId, - patch: { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries(existing, ids), - }, - }); - } - - const results = await params.resolveEntries({ token, entries: parts }).catch(() => null); - if (!results) { - await params.prompter.note("Failed to resolve usernames. Try again.", params.noteTitle); - continue; - } - const unresolved = results.filter((result) => !result.resolved || !result.id); - if (unresolved.length > 0) { - await params.prompter.note( - `Could not resolve: ${unresolved.map((result) => result.input).join(", ")}`, - params.noteTitle, - ); - continue; - } - return patchDiscordChannelConfigForAccount({ - cfg: params.cfg, - accountId, - patch: { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries( - existing, - results.map((result) => result.id as string), - ), - }, - }); - } -} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index a970ff5773b..48222c85a5a 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -54,8 +54,11 @@ async function promptDiscordAllowFrom(params: { }): Promise { return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, + channel, prompter: params.prompter, accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordSetupAccountId(params.cfg), + resolveAccount: (cfg, accountId) => resolveDiscordSetupAccountConfig({ cfg, accountId }), noteTitle: "Discord allowlist", noteLines: [ "Allowlist Discord DMs by username (we resolve to user ids).", @@ -70,11 +73,12 @@ async function promptDiscordAllowFrom(params: { placeholder: "@alice, 123456789012345678", parseId: parseDiscordAllowFromId, invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveExisting: (accountId, cfg) => { - const account = resolveDiscordSetupAccountConfig({ cfg, accountId }).config; - return account.allowFrom ?? account.dm?.allowFrom ?? []; + resolveExisting: (account) => { + const config = account.config; + return config.allowFrom ?? config.dm?.allowFrom ?? []; }, - resolveToken: (accountId) => resolveDiscordToken(params.cfg, { accountId }).token, + resolveToken: (account) => + resolveDiscordToken(params.cfg, { accountId: account.accountId }).token, resolveEntries: async ({ token, entries }) => ( await resolveDiscordUserAllowlist({ diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index d3f98322db7..562a8596939 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -119,4 +119,24 @@ describe("exa web search provider", () => { error: "conflicting_time_filters", }); }); + + it("returns validation errors for invalid date input", async () => { + const provider = createExaWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { exa: { apiKey: "exa-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "latest gpu news", + date_after: "2026-02-31", + }); + + expect(result).toMatchObject({ + error: "invalid_date", + }); + }); }); diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index 002300aa283..a7776010bc8 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -5,7 +5,7 @@ import { enablePluginInConfig, getScopedCredentialValue, mergeScopedSearchConfig, - normalizeToIsoDate, + parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -493,32 +493,17 @@ function createExaToolDefinition( docs: "https://docs.openclaw.ai/tools/web", }; } - - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - if (rawDateAfter && !dateAfter) { - return { - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateBefore && !dateBefore) { - return { - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return { - error: "invalid_date_range", - message: "date_after must be earlier than or equal to date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }; + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; } + const { dateAfter, dateBefore } = parsedDateRange; const parsedContents = parseExaContents(params.contents); if (isErrorPayload(parsedContents)) { diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index d8dcc1c0aa2..8f0f90f1c14 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../runtime-api.js"; +import { buildFeishuConversationId } from "./conversation-id.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { downloadMessageResourceFeishu } from "./media.js"; import { parsePostContent } from "./post.js"; @@ -45,24 +46,6 @@ export type ResolvedFeishuGroupSession = { threadReply: boolean; }; -function buildFeishuConversationId(params: { - chatId: string; - scope: GroupSessionScope | "group_sender"; - topicId?: string; - senderOpenId?: string; -}): string { - switch (params.scope) { - case "group_sender": - return `${params.chatId}:sender:${params.senderOpenId}`; - case "group_topic": - return `${params.chatId}:topic:${params.topicId}`; - case "group_topic_sender": - return `${params.chatId}:topic:${params.topicId}:sender:${params.senderOpenId}`; - default: - return params.chatId; - } -} - export function resolveFeishuGroupSession(params: { chatId: string; senderOpenId: string; diff --git a/extensions/feishu/src/conversation-id.ts b/extensions/feishu/src/conversation-id.ts index 39cb8cc74b6..4f25e0adb56 100644 --- a/extensions/feishu/src/conversation-id.ts +++ b/extensions/feishu/src/conversation-id.ts @@ -41,6 +41,49 @@ export function buildFeishuConversationId(params: { } } +export function parseFeishuTargetId(raw: unknown): string | undefined { + const target = normalizeText(raw); + if (!target) { + return undefined; + } + const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim(); + if (!withoutProvider) { + return undefined; + } + const lowered = withoutProvider.toLowerCase(); + for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) { + if (lowered.startsWith(prefix)) { + return normalizeText(withoutProvider.slice(prefix.length)); + } + } + return withoutProvider; +} + +export function parseFeishuDirectConversationId(raw: unknown): string | undefined { + const target = normalizeText(raw); + if (!target) { + return undefined; + } + const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim(); + if (!withoutProvider) { + return undefined; + } + const lowered = withoutProvider.toLowerCase(); + for (const prefix of ["user:", "dm:", "open_id:"]) { + if (lowered.startsWith(prefix)) { + return normalizeText(withoutProvider.slice(prefix.length)); + } + } + const id = parseFeishuTargetId(target); + if (!id) { + return undefined; + } + if (id.startsWith("ou_") || id.startsWith("on_")) { + return id; + } + return undefined; +} + export function parseFeishuConversationId(params: { conversationId: string; parentConversationId?: string; diff --git a/extensions/github-copilot/token.ts b/extensions/github-copilot/token.ts index f743cf8bb88..5c1076ad710 100644 --- a/extensions/github-copilot/token.ts +++ b/extensions/github-copilot/token.ts @@ -1,137 +1 @@ -import path from "node:path"; -import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; -import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; - -const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; - -export type CachedCopilotToken = { - token: string; - /** milliseconds since epoch */ - expiresAt: number; - /** milliseconds since epoch */ - updatedAt: number; -}; - -function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { - return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); -} - -function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { - // Keep a small safety margin when checking expiry. - return cache.expiresAt - now > 5 * 60 * 1000; -} - -function parseCopilotTokenResponse(value: unknown): { - token: string; - expiresAt: number; -} { - if (!value || typeof value !== "object") { - throw new Error("Unexpected response from GitHub Copilot token endpoint"); - } - const asRecord = value as Record; - const token = asRecord.token; - const expiresAt = asRecord.expires_at; - if (typeof token !== "string" || token.trim().length === 0) { - throw new Error("Copilot token response missing token"); - } - - // GitHub returns a unix timestamp (seconds), but we defensively accept ms too. - let expiresAtMs: number; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000; - } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { - const parsed = Number.parseInt(expiresAt, 10); - if (!Number.isFinite(parsed)) { - throw new Error("Copilot token response has invalid expires_at"); - } - expiresAtMs = parsed > 10_000_000_000 ? parsed : parsed * 1000; - } else { - throw new Error("Copilot token response missing expires_at"); - } - - return { token, expiresAt: expiresAtMs }; -} - -export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; - -export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { - const trimmed = token.trim(); - if (!trimmed) { - return null; - } - - // The token returned from the Copilot token endpoint is a semicolon-delimited - // set of key/value pairs. One of them is `proxy-ep=...`. - const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i); - const proxyEp = match?.[1]?.trim(); - if (!proxyEp) { - return null; - } - - // pi-ai expects converting proxy.* -> api.* - // (see upstream getGitHubCopilotBaseUrl). - const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api."); - if (!host) { - return null; - } - - return `https://${host}`; -} - -export async function resolveCopilotApiToken(params: { - githubToken: string; - env?: NodeJS.ProcessEnv; - fetchImpl?: typeof fetch; - cachePath?: string; - loadJsonFileImpl?: (path: string) => unknown; - saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void; -}): Promise<{ - token: string; - expiresAt: number; - source: string; - baseUrl: string; -}> { - const env = params.env ?? process.env; - const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env); - const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile; - const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile; - const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined; - if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { - if (isTokenUsable(cached)) { - return { - token: cached.token, - expiresAt: cached.expiresAt, - source: `cache:${cachePath}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, - }; - } - } - - const fetchImpl = params.fetchImpl ?? fetch; - const res = await fetchImpl(COPILOT_TOKEN_URL, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.githubToken}`, - }, - }); - - if (!res.ok) { - throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); - } - - const json = parseCopilotTokenResponse(await res.json()); - const payload: CachedCopilotToken = { - token: json.token, - expiresAt: json.expiresAt, - updatedAt: Date.now(), - }; - saveJsonFileFn(cachePath, payload); - - return { - token: payload.token, - expiresAt: payload.expiresAt, - source: `fetched:${COPILOT_TOKEN_URL}`, - baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, - }; -} +export * from "openclaw/plugin-sdk/github-copilot-token"; diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index cc4a66022c1..4db871fa77e 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -2,6 +2,45 @@ import * as providerAuth from "openclaw/plugin-sdk/provider-auth"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js"; +function mockGoogleApiKeyAuth() { + vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); +} + +function installGoogleFetchMock(params?: { + data?: string; + mimeType?: string; + inlineDataKey?: "inlineData" | "inline_data"; +}) { + const mimeType = params?.mimeType ?? "image/png"; + const data = params?.data ?? "png-data"; + const inlineDataKey = params?.inlineDataKey ?? "inlineData"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + [inlineDataKey]: { + [inlineDataKey === "inlineData" ? "mimeType" : "mime_type"]: mimeType, + data: Buffer.from(data).toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + describe("Google image-generation provider", () => { afterEach(() => { vi.restoreAllMocks(); @@ -133,31 +172,8 @@ describe("Google image-generation provider", () => { }); it("sends reference images and explicit resolution for edit flows", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "google-test-key", - source: "env", - mode: "api-key", - }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [ - { - content: { - parts: [ - { - inlineData: { - mimeType: "image/png", - data: Buffer.from("png-data").toString("base64"), - }, - }, - ], - }, - }, - ], - }), - }); - vi.stubGlobal("fetch", fetchMock); + mockGoogleApiKeyAuth(); + const fetchMock = installGoogleFetchMock(); const provider = buildGoogleImageGenerationProvider(); await provider.generateImage({ @@ -206,31 +222,8 @@ describe("Google image-generation provider", () => { }); it("forwards explicit aspect ratio without forcing a default when size is omitted", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "google-test-key", - source: "env", - mode: "api-key", - }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [ - { - content: { - parts: [ - { - inlineData: { - mimeType: "image/png", - data: Buffer.from("png-data").toString("base64"), - }, - }, - ], - }, - }, - ], - }), - }); - vi.stubGlobal("fetch", fetchMock); + mockGoogleApiKeyAuth(); + const fetchMock = installGoogleFetchMock(); const provider = buildGoogleImageGenerationProvider(); await provider.generateImage({ @@ -264,31 +257,8 @@ describe("Google image-generation provider", () => { }); it("normalizes a configured bare Google host to the v1beta API root", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "google-test-key", - source: "env", - mode: "api-key", - }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [ - { - content: { - parts: [ - { - inlineData: { - mimeType: "image/png", - data: Buffer.from("png-data").toString("base64"), - }, - }, - ], - }, - }, - ], - }), - }); - vi.stubGlobal("fetch", fetchMock); + mockGoogleApiKeyAuth(); + const fetchMock = installGoogleFetchMock(); const provider = buildGoogleImageGenerationProvider(); await provider.generateImage({ diff --git a/extensions/line/src/webhook.test.ts b/extensions/line/src/webhook.test.ts index 5c38c58f3ce..954e96c6b8c 100644 --- a/extensions/line/src/webhook.test.ts +++ b/extensions/line/src/webhook.test.ts @@ -53,6 +53,35 @@ async function invokeWebhook(params: { return { res, onEvents: onEventsMock }; } +async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) { + const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); + const reqBody = { + events: [{ type: "message", source: { userId: "tampered-user" } }], + }; + const middleware = createLineWebhookMiddleware({ + channelSecret: SECRET, + onEvents, + }); + const rawBodyText = + typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8"); + const req = { + headers: { "x-line-signature": sign(rawBodyText, SECRET) }, + rawBody: params.rawBody, + body: reqBody, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const res = createRes(); + + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(200); + expect(onEvents).toHaveBeenCalledTimes(1); + const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; + expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId); + expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); +} + describe("createLineWebhookMiddleware", () => { it("rejects startup when channel secret is missing", () => { expect(() => @@ -139,65 +168,24 @@ describe("createLineWebhookMiddleware", () => { }); it("uses the signed raw body instead of a pre-parsed req.body object", async () => { - const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); - const rawBody = JSON.stringify({ - events: [{ type: "message", source: { userId: "signed-user" } }], + await expectSignedRawBodyWins({ + rawBody: JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-user" } }], + }), + signedUserId: "signed-user", }); - const reqBody = { - events: [{ type: "message", source: { userId: "tampered-user" } }], - }; - const middleware = createLineWebhookMiddleware({ - channelSecret: SECRET, - onEvents, - }); - - const req = { - headers: { "x-line-signature": sign(rawBody, SECRET) }, - rawBody, - body: reqBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - - expect(res.status).toHaveBeenCalledWith(200); - expect(onEvents).toHaveBeenCalledTimes(1); - const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; - expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-user"); - expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); }); it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => { - const onEvents = vi.fn(async (_body: WebhookRequestBody) => {}); - const rawBodyText = JSON.stringify({ - events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + await expectSignedRawBodyWins({ + rawBody: Buffer.from( + JSON.stringify({ + events: [{ type: "message", source: { userId: "signed-buffer-user" } }], + }), + "utf-8", + ), + signedUserId: "signed-buffer-user", }); - const reqBody = { - events: [{ type: "message", source: { userId: "tampered-user" } }], - }; - const middleware = createLineWebhookMiddleware({ - channelSecret: SECRET, - onEvents, - }); - - const req = { - headers: { "x-line-signature": sign(rawBodyText, SECRET) }, - rawBody: Buffer.from(rawBodyText, "utf-8"), - body: reqBody, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - const res = createRes(); - - // oxlint-disable-next-line typescript/no-explicit-any - await middleware(req, res, {} as any); - - expect(res.status).toHaveBeenCalledWith(200); - expect(onEvents).toHaveBeenCalledTimes(1); - const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined; - expect(processedBody?.events?.[0]?.source?.userId).toBe("signed-buffer-user"); - expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user"); }); it("rejects invalid signed raw JSON even when req.body is a valid object", async () => { diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 318db978f6b..2a8249250b4 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -84,6 +84,33 @@ function formatExpectedLocalTimestamp(value: string): string { return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value; } +function mockMatrixVerificationStatus(params: { + recoveryKeyCreatedAt: string | null; + verifiedAt?: string; +}) { + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "1", + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: params.recoveryKeyCreatedAt, + pendingVerifications: 0, + verifiedAt: params.verifiedAt, + }); +} + describe("matrix CLI verification commands", () => { beforeEach(() => { vi.clearAllMocks(); @@ -642,26 +669,7 @@ describe("matrix CLI verification commands", () => { it("prints local timezone timestamps for verify status output in verbose mode", async () => { const recoveryCreatedAt = "2026-02-25T20:10:11.000Z"; - getMatrixVerificationStatusMock.mockResolvedValue({ - encryptionEnabled: true, - verified: true, - localVerified: true, - crossSigningVerified: true, - signedByOwner: true, - userId: "@bot:example.org", - deviceId: "DEVICE123", - backupVersion: "1", - backup: { - serverVersion: "1", - activeVersion: "1", - trusted: true, - matchesDecryptionKey: true, - decryptionKeyCached: true, - }, - recoveryKeyStored: true, - recoveryKeyCreatedAt: recoveryCreatedAt, - pendingVerifications: 0, - }); + mockMatrixVerificationStatus({ recoveryKeyCreatedAt: recoveryCreatedAt }); const program = buildProgram(); await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" }); @@ -750,26 +758,7 @@ describe("matrix CLI verification commands", () => { it("keeps default output concise when verbose is not provided", async () => { const recoveryCreatedAt = "2026-02-25T20:10:11.000Z"; - getMatrixVerificationStatusMock.mockResolvedValue({ - encryptionEnabled: true, - verified: true, - localVerified: true, - crossSigningVerified: true, - signedByOwner: true, - userId: "@bot:example.org", - deviceId: "DEVICE123", - backupVersion: "1", - backup: { - serverVersion: "1", - activeVersion: "1", - trusted: true, - matchesDecryptionKey: true, - decryptionKeyCached: true, - }, - recoveryKeyStored: true, - recoveryKeyCreatedAt: recoveryCreatedAt, - pendingVerifications: 0, - }); + mockMatrixVerificationStatus({ recoveryKeyCreatedAt: recoveryCreatedAt }); const program = buildProgram(); await program.parseAsync(["matrix", "verify", "status"], { from: "user" }); diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts index 09421482757..64051d5dd79 100644 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -1,4 +1,5 @@ import { + buildTimeoutAbortSignal, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, @@ -81,41 +82,6 @@ function buildBufferedResponse(params: { return response; } -function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { - signal?: AbortSignal; - cleanup: () => void; -} { - const { timeoutMs, signal } = params; - if (!timeoutMs && !signal) { - return { signal: undefined, cleanup: () => {} }; - } - if (!timeoutMs) { - return { signal, cleanup: () => {} }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - const onAbort = () => controller.abort(); - - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } - - return { - signal: controller.signal, - cleanup: () => { - clearTimeout(timeoutId); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - }, - }; -} - async function fetchWithMatrixGuardedRedirects(params: { url: string; init?: RequestInit; @@ -129,7 +95,7 @@ async function fetchWithMatrixGuardedRedirects(params: { let headers = new Headers(params.init?.headers ?? {}); const maxRedirects = 5; const visited = new Set(); - const { signal, cleanup } = buildAbortSignal({ + const { signal, cleanup } = buildTimeoutAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal, }); diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts index dffed4a5fd1..9f3aae61bac 100644 --- a/extensions/matrix/src/onboarding.resolve.test.ts +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; -import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; +import { + runMatrixAddAccountAllowlistConfigure, + runMatrixInteractiveConfigure, +} from "./onboarding.test-harness.js"; import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; @@ -24,53 +26,7 @@ describe("matrix onboarding account-scoped resolution", () => { }); it("passes accountId into Matrix allowlist target resolution during onboarding", async () => { - const prompter = { - note: vi.fn(async () => {}), - select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix already configured. What do you want to do?") { - return "add-account"; - } - if (message === "Matrix auth method") { - return "token"; - } - if (message === "Matrix rooms access") { - return "allowlist"; - } - throw new Error(`unexpected select prompt: ${message}`); - }), - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix account name") { - return "ops"; - } - if (message === "Matrix homeserver URL") { - return "https://matrix.ops.example.org"; - } - if (message === "Matrix access token") { - return "ops-token"; - } - if (message === "Matrix device name (optional)") { - return ""; - } - if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { - return "Alice"; - } - if (message === "Matrix rooms allowlist (comma-separated)") { - return ""; - } - throw new Error(`unexpected text prompt: ${message}`); - }), - confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Enable end-to-end encryption (E2EE)?") { - return false; - } - if (message === "Configure Matrix rooms access?") { - return true; - } - return false; - }), - } as unknown as WizardPrompter; - - const result = await matrixOnboardingAdapter.configureInteractive!({ + const result = await runMatrixAddAccountAllowlistConfigure({ cfg: { channels: { matrix: { @@ -83,14 +39,8 @@ describe("matrix onboarding account-scoped resolution", () => { }, }, } as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), - prompter, - options: undefined, - accountOverrides: {}, - shouldPromptAccountIds: true, - forceAllowFrom: true, - configured: true, - label: "Matrix", + allowFromInput: "Alice", + roomsAllowlistInput: "", }); expect(result).not.toBe("skip"); diff --git a/extensions/matrix/src/onboarding.test-harness.ts b/extensions/matrix/src/onboarding.test-harness.ts new file mode 100644 index 00000000000..9fe5fe39c25 --- /dev/null +++ b/extensions/matrix/src/onboarding.test-harness.ts @@ -0,0 +1,145 @@ +import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { afterEach, vi } from "vitest"; +import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import type { CoreConfig } from "./types.js"; + +const MATRIX_ENV_KEYS = [ + "MATRIX_HOMESERVER", + "MATRIX_USER_ID", + "MATRIX_ACCESS_TOKEN", + "MATRIX_PASSWORD", + "MATRIX_DEVICE_ID", + "MATRIX_DEVICE_NAME", + "MATRIX_OPS_HOMESERVER", + "MATRIX_OPS_ACCESS_TOKEN", +] as const; + +const previousMatrixEnv = Object.fromEntries( + MATRIX_ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record<(typeof MATRIX_ENV_KEYS)[number], string | undefined>; + +function createNonExitingTypedRuntimeEnv(): TRuntime { + return { + log: vi.fn(), + error: vi.fn(), + writeStdout: vi.fn(), + writeJson: vi.fn(), + exit: vi.fn(), + } as OutputRuntimeEnv as TRuntime; +} + +export function installMatrixOnboardingEnvRestoreHooks() { + afterEach(() => { + for (const [key, value] of Object.entries(previousMatrixEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +type PromptHandler = (message: string) => T; + +export function createMatrixWizardPrompter(params: { + notes?: string[]; + select?: Record; + text?: Record; + confirm?: Record; + onNote?: PromptHandler>; + onSelect?: PromptHandler>; + onText?: PromptHandler>; + onConfirm?: PromptHandler>; +}): WizardPrompter { + const resolvePromptValue = async ( + kind: string, + message: string, + values: Record | undefined, + fallback: PromptHandler> | undefined, + ): Promise => { + if (values && message in values) { + return values[message] as T; + } + if (fallback) { + return await fallback(message); + } + throw new Error(`unexpected ${kind} prompt: ${message}`); + }; + + return { + note: vi.fn(async (message: unknown) => { + const text = String(message); + params.notes?.push(text); + await params.onNote?.(text); + }), + select: vi.fn(async ({ message }: { message: string }) => { + return await resolvePromptValue("select", message, params.select, params.onSelect); + }), + text: vi.fn(async ({ message }: { message: string }) => { + return await resolvePromptValue("text", message, params.text, params.onText); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + return await resolvePromptValue("confirm", message, params.confirm, params.onConfirm); + }), + } as unknown as WizardPrompter; +} + +export async function runMatrixInteractiveConfigure(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + options?: unknown; + accountOverrides?: Record; + shouldPromptAccountIds?: boolean; + forceAllowFrom?: boolean; + configured?: boolean; +}) { + return await matrixOnboardingAdapter.configureInteractive!({ + cfg: params.cfg, + runtime: createNonExitingTypedRuntimeEnv(), + prompter: params.prompter, + options: params.options, + accountOverrides: params.accountOverrides ?? {}, + shouldPromptAccountIds: params.shouldPromptAccountIds ?? false, + forceAllowFrom: params.forceAllowFrom ?? false, + configured: params.configured ?? false, + label: "Matrix", + }); +} + +export async function runMatrixAddAccountAllowlistConfigure(params: { + cfg: CoreConfig; + allowFromInput: string; + roomsAllowlistInput: string; + deviceName?: string; +}) { + const prompter = createMatrixWizardPrompter({ + select: { + "Matrix already configured. What do you want to do?": "add-account", + "Matrix auth method": "token", + "Matrix rooms access": "allowlist", + }, + text: { + "Matrix account name": "ops", + "Matrix homeserver URL": "https://matrix.ops.example.org", + "Matrix access token": "ops-token", + "Matrix device name (optional)": params.deviceName ?? "", + "Matrix allowFrom (full @user:server; display name only if unique)": params.allowFromInput, + "Matrix rooms allowlist (comma-separated)": params.roomsAllowlistInput, + }, + confirm: { + "Enable end-to-end encryption (E2EE)?": false, + "Configure Matrix rooms access?": true, + }, + onConfirm: async () => false, + }); + + return await runMatrixInteractiveConfigure({ + cfg: params.cfg, + prompter, + shouldPromptAccountIds: true, + forceAllowFrom: true, + configured: true, + }); +} diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 7860ef93245..5f7ab96a986 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; -import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; +import { describe, expect, it, vi } from "vitest"; import { matrixOnboardingAdapter } from "./onboarding.js"; +import { + installMatrixOnboardingEnvRestoreHooks, + createMatrixWizardPrompter, + runMatrixAddAccountAllowlistConfigure, + runMatrixInteractiveConfigure, +} from "./onboarding.test-harness.js"; import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; @@ -11,26 +15,7 @@ vi.mock("./matrix/deps.js", () => ({ })); describe("matrix onboarding", () => { - const previousEnv = { - MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER, - MATRIX_USER_ID: process.env.MATRIX_USER_ID, - MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN, - MATRIX_PASSWORD: process.env.MATRIX_PASSWORD, - MATRIX_DEVICE_ID: process.env.MATRIX_DEVICE_ID, - MATRIX_DEVICE_NAME: process.env.MATRIX_DEVICE_NAME, - MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER, - MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, - }; - - afterEach(() => { - for (const [key, value] of Object.entries(previousEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }); + installMatrixOnboardingEnvRestoreHooks(); it("offers env shortcut for non-default account when scoped env vars are present", async () => { installMatrixTestRuntime(); @@ -43,33 +28,21 @@ describe("matrix onboarding", () => { process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token"; const confirmMessages: string[] = []; - const prompter = { - note: vi.fn(async () => {}), - select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix already configured. What do you want to do?") { - return "add-account"; - } - if (message === "Matrix auth method") { - return "token"; - } - throw new Error(`unexpected select prompt: ${message}`); - }), - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix account name") { - return "ops"; - } - throw new Error(`unexpected text prompt: ${message}`); - }), - confirm: vi.fn(async ({ message }: { message: string }) => { + const prompter = createMatrixWizardPrompter({ + select: { + "Matrix already configured. What do you want to do?": "add-account", + "Matrix auth method": "token", + }, + text: { + "Matrix account name": "ops", + }, + onConfirm: (message) => { confirmMessages.push(message); - if (message.startsWith("Matrix env vars detected")) { - return true; - } - return false; - }), - } as unknown as WizardPrompter; + return message.startsWith("Matrix env vars detected"); + }, + }); - const result = await matrixOnboardingAdapter.configureInteractive!({ + const result = await runMatrixInteractiveConfigure({ cfg: { channels: { matrix: { @@ -82,14 +55,9 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), prompter, - options: undefined, - accountOverrides: {}, shouldPromptAccountIds: true, - forceAllowFrom: false, configured: true, - label: "Matrix", }); expect(result).not.toBe("skip"); @@ -113,36 +81,21 @@ describe("matrix onboarding", () => { it("promotes legacy top-level Matrix config before adding a named account", async () => { installMatrixTestRuntime(); - const prompter = { - note: vi.fn(async () => {}), - select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix already configured. What do you want to do?") { - return "add-account"; - } - if (message === "Matrix auth method") { - return "token"; - } - throw new Error(`unexpected select prompt: ${message}`); - }), - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix account name") { - return "ops"; - } - if (message === "Matrix homeserver URL") { - return "https://matrix.ops.example.org"; - } - if (message === "Matrix access token") { - return "ops-token"; - } - if (message === "Matrix device name (optional)") { - return ""; - } - throw new Error(`unexpected text prompt: ${message}`); - }), - confirm: vi.fn(async () => false), - } as unknown as WizardPrompter; + const prompter = createMatrixWizardPrompter({ + select: { + "Matrix already configured. What do you want to do?": "add-account", + "Matrix auth method": "token", + }, + text: { + "Matrix account name": "ops", + "Matrix homeserver URL": "https://matrix.ops.example.org", + "Matrix access token": "ops-token", + "Matrix device name (optional)": "", + }, + onConfirm: async () => false, + }); - const result = await matrixOnboardingAdapter.configureInteractive!({ + const result = await runMatrixInteractiveConfigure({ cfg: { channels: { matrix: { @@ -152,14 +105,9 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), prompter, - options: undefined, - accountOverrides: {}, shouldPromptAccountIds: true, - forceAllowFrom: false, configured: true, - label: "Matrix", }); expect(result).not.toBe("skip"); @@ -185,28 +133,19 @@ describe("matrix onboarding", () => { installMatrixTestRuntime(); const notes: string[] = []; - const prompter = { - note: vi.fn(async (message: unknown) => { - notes.push(String(message)); - }), - text: vi.fn(async () => { + const prompter = createMatrixWizardPrompter({ + notes, + onText: async () => { throw new Error("stop-after-help"); - }), - confirm: vi.fn(async () => false), - select: vi.fn(async () => "token"), - } as unknown as WizardPrompter; + }, + onConfirm: async () => false, + onSelect: async () => "token", + }); await expect( - matrixOnboardingAdapter.configureInteractive!({ + runMatrixInteractiveConfigure({ cfg: { channels: {} } as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), prompter, - options: undefined, - accountOverrides: {}, - shouldPromptAccountIds: false, - forceAllowFrom: false, - configured: false, - label: "Matrix", }), ).rejects.toThrow("stop-after-help"); @@ -220,47 +159,25 @@ describe("matrix onboarding", () => { it("prompts for private-network access when onboarding an internal http homeserver", async () => { installMatrixTestRuntime(); - const prompter = { - note: vi.fn(async () => {}), - select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix auth method") { - return "token"; - } - throw new Error(`unexpected select prompt: ${message}`); - }), - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix homeserver URL") { - return "http://localhost.localdomain:8008"; - } - if (message === "Matrix access token") { - return "ops-token"; - } - if (message === "Matrix device name (optional)") { - return ""; - } - throw new Error(`unexpected text prompt: ${message}`); - }), - confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Allow private/internal Matrix homeserver traffic for this account?") { - return true; - } - if (message === "Enable end-to-end encryption (E2EE)?") { - return false; - } - return false; - }), - } as unknown as WizardPrompter; + const prompter = createMatrixWizardPrompter({ + select: { + "Matrix auth method": "token", + }, + text: { + "Matrix homeserver URL": "http://localhost.localdomain:8008", + "Matrix access token": "ops-token", + "Matrix device name (optional)": "", + }, + confirm: { + "Allow private/internal Matrix homeserver traffic for this account?": true, + "Enable end-to-end encryption (E2EE)?": false, + }, + onConfirm: async () => false, + }); - const result = await matrixOnboardingAdapter.configureInteractive!({ + const result = await runMatrixInteractiveConfigure({ cfg: {} as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), prompter, - options: undefined, - accountOverrides: {}, - shouldPromptAccountIds: false, - forceAllowFrom: false, - configured: false, - label: "Matrix", }); expect(result).not.toBe("skip"); @@ -307,53 +224,7 @@ describe("matrix onboarding", () => { it("writes allowlists and room access to the selected Matrix account", async () => { installMatrixTestRuntime(); - const prompter = { - note: vi.fn(async () => {}), - select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix already configured. What do you want to do?") { - return "add-account"; - } - if (message === "Matrix auth method") { - return "token"; - } - if (message === "Matrix rooms access") { - return "allowlist"; - } - throw new Error(`unexpected select prompt: ${message}`); - }), - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix account name") { - return "ops"; - } - if (message === "Matrix homeserver URL") { - return "https://matrix.ops.example.org"; - } - if (message === "Matrix access token") { - return "ops-token"; - } - if (message === "Matrix device name (optional)") { - return "Ops Gateway"; - } - if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { - return "@alice:example.org"; - } - if (message === "Matrix rooms allowlist (comma-separated)") { - return "!ops-room:example.org"; - } - throw new Error(`unexpected text prompt: ${message}`); - }), - confirm: vi.fn(async ({ message }: { message: string }) => { - if (message === "Enable end-to-end encryption (E2EE)?") { - return false; - } - if (message === "Configure Matrix rooms access?") { - return true; - } - return false; - }), - } as unknown as WizardPrompter; - - const result = await matrixOnboardingAdapter.configureInteractive!({ + const result = await runMatrixAddAccountAllowlistConfigure({ cfg: { channels: { matrix: { @@ -366,14 +237,9 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: createNonExitingTypedRuntimeEnv(), - prompter, - options: undefined, - accountOverrides: {}, - shouldPromptAccountIds: true, - forceAllowFrom: true, - configured: true, - label: "Matrix", + allowFromInput: "@alice:example.org", + roomsAllowlistInput: "!ops-room:example.org", + deviceName: "Ops Gateway", }); expect(result).not.toBe("skip"); diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 5992deb18f6..92c502f0c45 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1,6 +1,7 @@ export * from "openclaw/plugin-sdk/matrix"; export { assertHttpUrlTargetsPrivateNetwork, + buildTimeoutAbortSignal, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, diff --git a/extensions/modelstudio/model-definitions.ts b/extensions/modelstudio/model-definitions.ts index 7a1b8c4522d..789a19dedeb 100644 --- a/extensions/modelstudio/model-definitions.ts +++ b/extensions/modelstudio/model-definitions.ts @@ -1,105 +1,13 @@ -import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"; +export { + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "openclaw/plugin-sdk/provider-models"; -export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; export const MODELSTUDIO_STANDARD_CN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; export const MODELSTUDIO_STANDARD_GLOBAL_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; -export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; -export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; -export const MODELSTUDIO_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MODELSTUDIO_MODEL_CATALOG = { - "qwen3.5-plus": { - name: "qwen3.5-plus", - reasoning: false, - input: ["text", "image"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "qwen3-max-2026-01-23": { - name: "qwen3-max-2026-01-23", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-next": { - name: "qwen3-coder-next", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-plus": { - name: "qwen3-coder-plus", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "MiniMax-M2.5": { - name: "MiniMax-M2.5", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "glm-5": { - name: "glm-5", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "glm-4.7": { - name: "glm-4.7", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "kimi-k2.5": { - name: "kimi-k2.5", - reasoning: false, - input: ["text", "image"], - contextWindow: 262144, - maxTokens: 32768, - }, -} as const; - -type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; - -export function buildModelStudioModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - input?: string[]; - cost?: ModelDefinitionConfig["cost"]; - contextWindow?: number; - maxTokens?: number; -}): ModelDefinitionConfig { - const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? params.id, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: - (params.input as ("text" | "image")[]) ?? - ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), - cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, - contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, - maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, - }; -} - -export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { - return buildModelStudioModelDefinition({ - id: MODELSTUDIO_DEFAULT_MODEL_ID, - }); -} diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 29dd3017c4d..3c11cf4d55e 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -58,6 +58,25 @@ vi.mock("./graph-chat.js", () => ({ buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard, })); +function mockContinueConversationFailure(error: string) { + const mockContinueConversation = vi.fn().mockRejectedValue(new Error(error)); + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + adapter: { continueConversation: mockContinueConversation }, + appId: "app-id", + conversationId: "19:conversation@thread.tacv2", + ref: { + user: { id: "user-1" }, + agent: { id: "agent-1" }, + conversation: { id: "19:conversation@thread.tacv2" }, + channelId: "msteams", + }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + conversationType: "personal", + tokenProvider: {}, + }); + return mockContinueConversation; +} + describe("sendMessageMSTeams", () => { beforeEach(() => { mockState.loadOutboundMediaFromUrl.mockReset(); @@ -312,21 +331,7 @@ describe("editMessageMSTeams", () => { }); it("throws a descriptive error when continueConversation fails", async () => { - const mockContinueConversation = vi.fn().mockRejectedValue(new Error("Service unavailable")); - mockState.resolveMSTeamsSendContext.mockResolvedValue({ - adapter: { continueConversation: mockContinueConversation }, - appId: "app-id", - conversationId: "19:conversation@thread.tacv2", - ref: { - user: { id: "user-1" }, - agent: { id: "agent-1" }, - conversation: { id: "19:conversation@thread.tacv2" }, - channelId: "msteams", - }, - log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - conversationType: "personal", - tokenProvider: {}, - }); + mockContinueConversationFailure("Service unavailable"); await expect( editMessageMSTeams({ @@ -387,21 +392,7 @@ describe("deleteMessageMSTeams", () => { }); it("throws a descriptive error when continueConversation fails", async () => { - const mockContinueConversation = vi.fn().mockRejectedValue(new Error("Not found")); - mockState.resolveMSTeamsSendContext.mockResolvedValue({ - adapter: { continueConversation: mockContinueConversation }, - appId: "app-id", - conversationId: "19:conversation@thread.tacv2", - ref: { - user: { id: "user-1" }, - agent: { id: "agent-1" }, - conversation: { id: "19:conversation@thread.tacv2" }, - channelId: "msteams", - }, - log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - conversationType: "personal", - tokenProvider: {}, - }); + mockContinueConversationFailure("Not found"); await expect( deleteMessageMSTeams({ diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index bc05d93a5a5..0090253782e 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -22,6 +22,16 @@ vi.mock("./nostr-bus.js", () => ({ startNostrBus: mocks.startNostrBus, })); +function createMockBus() { + return { + sendDm: vi.fn(async () => {}), + close: vi.fn(), + getMetrics: vi.fn(() => ({ counters: {} })), + publishProfile: vi.fn(), + getProfileState: vi.fn(async () => null), + }; +} + function createRuntimeHarness() { const recordInboundSession = vi.fn(async () => {}); const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => { @@ -69,6 +79,25 @@ function createRuntimeHarness() { }; } +async function startGatewayHarness(params: { + account: ReturnType; + cfg?: Parameters[0]["cfg"]; +}) { + const harness = createRuntimeHarness(); + const bus = createMockBus(); + setNostrRuntime(harness.runtime); + mocks.startNostrBus.mockResolvedValueOnce(bus as never); + + const cleanup = (await nostrPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: params.account, + cfg: params.cfg, + }), + )) as { stop: () => void }; + + return { harness, bus, cleanup }; +} + describe("nostr inbound gateway path", () => { afterEach(() => { mocks.normalizePubkey.mockClear(); @@ -76,25 +105,11 @@ describe("nostr inbound gateway path", () => { }); it("issues a pairing reply before decrypt for unknown senders", async () => { - const harness = createRuntimeHarness(); - setNostrRuntime(harness.runtime); - - const bus = { - sendDm: vi.fn(async () => {}), - close: vi.fn(), - getMetrics: vi.fn(() => ({ counters: {} })), - publishProfile: vi.fn(), - getProfileState: vi.fn(async () => null), - }; - mocks.startNostrBus.mockResolvedValueOnce(bus as never); - - const cleanup = (await nostrPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildResolvedNostrAccount({ - config: { dmPolicy: "pairing", allowFrom: [] }, - }), + const { cleanup } = await startGatewayHarness({ + account: buildResolvedNostrAccount({ + config: { dmPolicy: "pairing", allowFrom: [] }, }), - )) as { stop: () => void }; + }); const options = mocks.startNostrBus.mock.calls[0]?.[0] as { authorizeSender: (params: { @@ -117,30 +132,16 @@ describe("nostr inbound gateway path", () => { }); it("routes allowed DMs through the standard reply pipeline", async () => { - const harness = createRuntimeHarness(); - setNostrRuntime(harness.runtime); - - const bus = { - sendDm: vi.fn(async () => {}), - close: vi.fn(), - getMetrics: vi.fn(() => ({ counters: {} })), - publishProfile: vi.fn(), - getProfileState: vi.fn(async () => null), - }; - mocks.startNostrBus.mockResolvedValueOnce(bus as never); - - const cleanup = (await nostrPlugin.gateway!.startAccount!( - createStartAccountContext({ - account: buildResolvedNostrAccount({ - publicKey: "bot-pubkey", - config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, - }), - cfg: { - session: { store: { type: "jsonl" } }, - commands: { useAccessGroups: true }, - } as never, + const { harness, cleanup } = await startGatewayHarness({ + account: buildResolvedNostrAccount({ + publicKey: "bot-pubkey", + config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, }), - )) as { stop: () => void }; + cfg: { + session: { store: { type: "jsonl" } }, + commands: { useAccessGroups: true }, + } as never, + }); const options = mocks.startNostrBus.mock.calls[0]?.[0] as { onMessage: ( diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 54c0e7fb279..92b137e3024 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -7,7 +7,10 @@ import type { OpenClawConfig } from "../../src/config/config.js"; import { loadConfig } from "../../src/config/config.js"; import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js"; import type { ResolvedTtsConfig } from "../../src/tts/tts.js"; -import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import { + registerProviderPlugin, + requireRegisteredProvider, +} from "../../test/helpers/extensions/provider-registration.js"; import plugin from "./index.js"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; @@ -64,48 +67,12 @@ function createTemplateModel(modelId: string) { } } -function registerOpenAIPlugin() { - const providers: unknown[] = []; - const speechProviders: unknown[] = []; - const mediaProviders: unknown[] = []; - const imageProviders: unknown[] = []; - - plugin.register( - createTestPluginApi({ - id: "openai", - name: "OpenAI Provider", - source: "test", - config: {}, - runtime: {} as never, - registerProvider: (provider) => { - providers.push(provider); - }, - registerSpeechProvider: (provider) => { - speechProviders.push(provider); - }, - registerMediaUnderstandingProvider: (provider) => { - mediaProviders.push(provider); - }, - registerImageGenerationProvider: (provider) => { - imageProviders.push(provider); - }, - }), - ); - - return { providers, speechProviders, mediaProviders, imageProviders }; -} - -function requireOpenAIProvider(entries: unknown[], id: string): T { - const entry = entries.find( - (candidate) => - // oxlint-disable-next-line typescript/no-explicit-any - (candidate as any).id === id, - ); - if (!entry) { - throw new Error(`provider ${id} was not registered`); - } - return entry as T; -} +const registerOpenAIPlugin = () => + registerProviderPlugin({ + plugin, + id: "openai", + name: "OpenAI Provider", + }); function createReferencePng(): Buffer { const width = 96; @@ -217,7 +184,7 @@ describe("openai plugin", () => { describeLive("openai plugin live", () => { it("registers an OpenAI provider that can complete a live request", async () => { const { providers } = registerOpenAIPlugin(); - const provider = requireOpenAIProvider(providers, "openai"); + const provider = requireRegisteredProvider(providers, "openai"); // oxlint-disable-next-line typescript/no-explicit-any const resolved = (provider as any).resolveDynamicModel?.({ @@ -267,7 +234,7 @@ describeLive("openai plugin live", () => { it("lists voices and synthesizes audio through the registered speech provider", async () => { const { speechProviders } = registerOpenAIPlugin(); - const speechProvider = requireOpenAIProvider(speechProviders, "openai"); + const speechProvider = requireRegisteredProvider(speechProviders, "openai"); // oxlint-disable-next-line typescript/no-explicit-any const voices = await (speechProvider as any).listVoices?.({}); @@ -303,8 +270,8 @@ describeLive("openai plugin live", () => { it("transcribes synthesized speech through the registered media provider", async () => { const { speechProviders, mediaProviders } = registerOpenAIPlugin(); - const speechProvider = requireOpenAIProvider(speechProviders, "openai"); - const mediaProvider = requireOpenAIProvider(mediaProviders, "openai"); + const speechProvider = requireRegisteredProvider(speechProviders, "openai"); + const mediaProvider = requireRegisteredProvider(mediaProviders, "openai"); const cfg = createLiveConfig(); const ttsConfig = createLiveTtsConfig(); @@ -334,7 +301,7 @@ describeLive("openai plugin live", () => { it("generates an image through the registered image provider", async () => { const { imageProviders } = registerOpenAIPlugin(); - const imageProvider = requireOpenAIProvider(imageProviders, "openai"); + const imageProvider = requireRegisteredProvider(imageProviders, "openai"); const cfg = createLiveConfig(); const agentDir = await createTempAgentDir(); @@ -363,7 +330,7 @@ describeLive("openai plugin live", () => { it("describes a deterministic image through the registered media provider", async () => { const { mediaProviders } = registerOpenAIPlugin(); - const mediaProvider = requireOpenAIProvider(mediaProviders, "openai"); + const mediaProvider = requireRegisteredProvider(mediaProviders, "openai"); const cfg = createLiveConfig(); const agentDir = await createTempAgentDir(); diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index f4be75c847d..e39e175cfd6 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -1,6 +1,9 @@ import OpenAI from "openai"; import { describe, expect, it } from "vitest"; -import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import { + registerProviderPlugin, + requireRegisteredProvider, +} from "../../test/helpers/extensions/provider-registration.js"; import plugin from "./index.js"; const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? ""; @@ -9,36 +12,12 @@ const LIVE_MODEL_ID = const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; -function registerOpenRouterPlugin() { - const providers: unknown[] = []; - const speechProviders: unknown[] = []; - const mediaProviders: unknown[] = []; - const imageProviders: unknown[] = []; - - plugin.register( - createTestPluginApi({ - id: "openrouter", - name: "OpenRouter Provider", - source: "test", - config: {}, - runtime: {} as never, - registerProvider: (provider) => { - providers.push(provider); - }, - registerSpeechProvider: (provider) => { - speechProviders.push(provider); - }, - registerMediaUnderstandingProvider: (provider) => { - mediaProviders.push(provider); - }, - registerImageGenerationProvider: (provider) => { - imageProviders.push(provider); - }, - }), - ); - - return { providers, speechProviders, mediaProviders, imageProviders }; -} +const registerOpenRouterPlugin = () => + registerProviderPlugin({ + plugin, + id: "openrouter", + name: "OpenRouter Provider", + }); describe("openrouter plugin", () => { it("registers the expected provider surfaces", () => { @@ -62,12 +41,7 @@ describe("openrouter plugin", () => { describeLive("openrouter plugin live", () => { it("registers an OpenRouter provider that can complete a live request", async () => { const { providers } = registerOpenRouterPlugin(); - const provider = - // oxlint-disable-next-line typescript/no-explicit-any - providers.find((entry) => (entry as any).id === "openrouter"); - if (!provider) { - throw new Error("openrouter provider was not registered"); - } + const provider = requireRegisteredProvider(providers, "openrouter"); // oxlint-disable-next-line typescript/no-explicit-any const resolved = (provider as any).resolveDynamicModel?.({ diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 195e8ec555e..a66c3ae545d 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -6,6 +6,7 @@ import type { SandboxFsStat, SandboxResolvedPath, } from "openclaw/plugin-sdk/sandbox"; +import { resolveWritableRenameTargetsForBridge } from "openclaw/plugin-sdk/sandbox"; import type { OpenShellSandboxBackend } from "./backend.js"; import { movePathWithCopyFallback } from "./mirror.js"; @@ -28,6 +29,14 @@ class OpenShellFsBridge implements SandboxFsBridge { private readonly backend: OpenShellSandboxBackend, ) {} + private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) { + return resolveWritableRenameTargetsForBridge( + params, + (target) => this.resolveTarget(target), + (target, action) => this.ensureWritable(target, action), + ); + } + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { const target = this.resolveTarget(params); return { @@ -140,12 +149,9 @@ class OpenShellFsBridge implements SandboxFsBridge { cwd?: string; signal?: AbortSignal; }): Promise { - const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); - const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + const { from, to } = this.resolveRenameTargets(params); const fromHostPath = this.requireHostPath(from); const toHostPath = this.requireHostPath(to); - this.ensureWritable(from, "rename files"); - this.ensureWritable(to, "rename files"); await assertLocalPathSafety({ target: from, root: from.mountHostRoot, diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index f19d7ac188d..8446da642db 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -10,6 +10,9 @@ import type { PluginCommandContext, } from "./runtime-api.js"; +const PHONE_CONTROL_STATE_PREFIX = "openclaw-phone-control-test-"; +const WRITE_COMMANDS = ["calendar.add", "contacts.add", "reminders.add", "sms.send"] as const; + function createApi(params: { stateDir: string; getConfig: () => Record; @@ -51,93 +54,80 @@ function createCommandContext(args: string): PluginCommandContext { }; } +function createPhoneControlConfig(): Record { + return { + gateway: { + nodes: { + allowCommands: [], + denyCommands: [...WRITE_COMMANDS], + }, + }, + }; +} + +async function withRegisteredPhoneControl( + run: (params: { + command: OpenClawPluginCommandDefinition; + writeConfigFile: ReturnType; + getConfig: () => Record; + }) => Promise, +) { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), PHONE_CONTROL_STATE_PREFIX)); + try { + let config = createPhoneControlConfig(); + const writeConfigFile = vi.fn(async (next: Record) => { + config = next; + }); + + let command: OpenClawPluginCommandDefinition | undefined; + registerPhoneControl.register( + createApi({ + stateDir, + getConfig: () => config, + writeConfig: writeConfigFile, + registerCommand: (nextCommand) => { + command = nextCommand; + }, + }), + ); + + if (!command) { + throw new Error("phone-control plugin did not register its command"); + } + + await run({ + command, + writeConfigFile, + getConfig: () => config, + }); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } +} + describe("phone-control plugin", () => { it("arms sms.send as part of the writes group", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-")); - try { - let config: Record = { - gateway: { - nodes: { - allowCommands: [], - denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"], - }, - }, - }; - const writeConfigFile = vi.fn(async (next: Record) => { - config = next; - }); - - let command: OpenClawPluginCommandDefinition | undefined; - registerPhoneControl.register( - createApi({ - stateDir, - getConfig: () => config, - writeConfig: writeConfigFile, - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - - if (!command) { - throw new Error("phone-control plugin did not register its command"); - } + await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => { expect(command.name).toBe("phone"); const res = await command.handler(createCommandContext("arm writes 30s")); const text = String(res?.text ?? ""); const nodes = ( - config.gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } + getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } ).nodes; if (!nodes) { throw new Error("phone-control command did not persist gateway node config"); } expect(writeConfigFile).toHaveBeenCalledTimes(1); - expect(nodes.allowCommands).toEqual([ - "calendar.add", - "contacts.add", - "reminders.add", - "sms.send", - ]); + expect(nodes.allowCommands).toEqual([...WRITE_COMMANDS]); expect(nodes.denyCommands).toEqual([]); expect(text).toContain("sms.send"); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); it("blocks internal operator.write callers from mutating phone control", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-")); - try { - let config: Record = { - gateway: { - nodes: { - allowCommands: [], - denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"], - }, - }, - }; - const writeConfigFile = vi.fn(async (next: Record) => { - config = next; - }); - - let command: OpenClawPluginCommandDefinition | undefined; - registerPhoneControl.register( - createApi({ - stateDir, - getConfig: () => config, - writeConfig: writeConfigFile, - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - - if (!command) { - throw new Error("phone-control plugin did not register its command"); - } - + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { const res = await command.handler({ ...createCommandContext("arm writes 30s"), channel: "webchat", @@ -146,42 +136,11 @@ describe("phone-control plugin", () => { expect(String(res?.text ?? "")).toContain("requires operator.admin"); expect(writeConfigFile).not.toHaveBeenCalled(); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); it("allows internal operator.admin callers to mutate phone control", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-test-")); - try { - let config: Record = { - gateway: { - nodes: { - allowCommands: [], - denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"], - }, - }, - }; - const writeConfigFile = vi.fn(async (next: Record) => { - config = next; - }); - - let command: OpenClawPluginCommandDefinition | undefined; - registerPhoneControl.register( - createApi({ - stateDir, - getConfig: () => config, - writeConfig: writeConfigFile, - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - - if (!command) { - throw new Error("phone-control plugin did not register its command"); - } - + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { const res = await command.handler({ ...createCommandContext("arm writes 30s"), channel: "webchat", @@ -190,8 +149,6 @@ describe("phone-control plugin", () => { expect(String(res?.text ?? "")).toContain("sms.send"); expect(writeConfigFile).toHaveBeenCalledTimes(1); - } finally { - await fs.rm(stateDir, { recursive: true, force: true }); - } + }); }); }); diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index a99ed55da4a..f1505b6c66c 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -2,6 +2,7 @@ export * from "./src/accounts.js"; export * from "./src/identity.js"; export * from "./src/message-actions.js"; export * from "./src/monitor.js"; +export * from "./src/outbound-session.js"; export * from "./src/probe.js"; export * from "./src/reaction-level.js"; export * from "./src/send-reactions.js"; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b29227be793..0e1af819978 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -12,13 +12,8 @@ import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; -import { - looksLikeUuid, - resolveSignalPeerId, - resolveSignalRecipient, - resolveSignalSender, -} from "./identity.js"; import { signalMessageActions } from "./message-actions.js"; +import { resolveSignalOutboundTarget } from "./outbound-session.js"; import type { SignalProbe } from "./probe.js"; import { buildBaseChannelStatusSummary, @@ -125,63 +120,20 @@ function resolveSignalOutboundSessionRoute(params: { accountId?: string | null; target: string; }) { - const stripped = params.target.replace(/^signal:/i, "").trim(); - const lowered = stripped.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = stripped.slice("group:".length).trim(); - if (!groupId) { - return null; - } - const peer: RoutePeer = { kind: "group", id: groupId }; - const baseSessionKey = buildSignalBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "group" as const, - from: `group:${groupId}`, - to: `group:${groupId}`, - }; - } - - let recipient = stripped.trim(); - if (lowered.startsWith("username:")) { - recipient = stripped.slice("username:".length).trim(); - } else if (lowered.startsWith("u:")) { - recipient = stripped.slice("u:".length).trim(); - } - if (!recipient) { + const resolved = resolveSignalOutboundTarget(params.target); + if (!resolved) { return null; } - - const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") - ? recipient.slice("uuid:".length) - : recipient; - const sender = resolveSignalSender({ - sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, - sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, - }); - const peerId = sender ? resolveSignalPeerId(sender) : recipient; - const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; - const peer: RoutePeer = { kind: "direct", id: peerId }; const baseSessionKey = buildSignalBaseSessionKey({ cfg: params.cfg, agentId: params.agentId, accountId: params.accountId, - peer, + peer: resolved.peer, }); return { sessionKey: baseSessionKey, baseSessionKey, - peer, - chatType: "direct" as const, - from: `signal:${displayRecipient}`, - to: `signal:${displayRecipient}`, + ...resolved, }; } diff --git a/extensions/signal/src/outbound-session.ts b/extensions/signal/src/outbound-session.ts new file mode 100644 index 00000000000..b239b6c252c --- /dev/null +++ b/extensions/signal/src/outbound-session.ts @@ -0,0 +1,57 @@ +import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { + looksLikeUuid, + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "./identity.js"; + +export type ResolvedSignalOutboundTarget = { + peer: RoutePeer; + chatType: "direct" | "group"; + from: string; + to: string; +}; + +export function resolveSignalOutboundTarget(target: string): ResolvedSignalOutboundTarget | null { + const stripped = target.replace(/^signal:/i, "").trim(); + const lowered = stripped.toLowerCase(); + if (lowered.startsWith("group:")) { + const groupId = stripped.slice("group:".length).trim(); + if (!groupId) { + return null; + } + return { + peer: { kind: "group", id: groupId }, + chatType: "group", + from: `group:${groupId}`, + to: `group:${groupId}`, + }; + } + + let recipient = stripped.trim(); + if (lowered.startsWith("username:")) { + recipient = stripped.slice("username:".length).trim(); + } else if (lowered.startsWith("u:")) { + recipient = stripped.slice("u:".length).trim(); + } + if (!recipient) { + return null; + } + + const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") + ? recipient.slice("uuid:".length) + : recipient; + const sender = resolveSignalSender({ + sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, + sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, + }); + const peerId = sender ? resolveSignalPeerId(sender) : recipient; + const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; + return { + peer: { kind: "direct", id: peerId }, + chatType: "direct", + from: `signal:${displayRecipient}`, + to: `signal:${displayRecipient}`, + }; +} diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts index 4c1001e1e59..9e57d58e6c9 100644 --- a/extensions/slack/api.ts +++ b/extensions/slack/api.ts @@ -3,6 +3,7 @@ export * from "./src/accounts.js"; export * from "./src/actions.js"; export * from "./src/blocks-input.js"; export * from "./src/blocks-render.js"; +export * from "./src/channel-type.js"; export * from "./src/client.js"; export * from "./src/directory-config.js"; export * from "./src/http/index.js"; diff --git a/extensions/slack/src/channel-type.ts b/extensions/slack/src/channel-type.ts new file mode 100644 index 00000000000..37f2e3cbb3d --- /dev/null +++ b/extensions/slack/src/channel-type.ts @@ -0,0 +1,69 @@ +import { resolveSlackAccount } from "./accounts.js"; +import { createSlackWebClient } from "./client.js"; +import { normalizeAllowListLower } from "./monitor/allow-list.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +const SLACK_CHANNEL_TYPE_CACHE = new Map(); + +export async function resolveSlackChannelType(params: { + cfg: OpenClawConfig; + accountId?: string | null; + channelId: string; +}): Promise<"channel" | "group" | "dm" | "unknown"> { + const channelId = params.channelId.trim(); + if (!channelId) { + return "unknown"; + } + const cacheKey = `${params.accountId ?? "default"}:${channelId}`; + const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey); + if (cached) { + return cached; + } + + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); + const channelIdLower = channelId.toLowerCase(); + if ( + groupChannels.includes(channelIdLower) || + groupChannels.includes(`slack:${channelIdLower}`) || + groupChannels.includes(`channel:${channelIdLower}`) || + groupChannels.includes(`group:${channelIdLower}`) || + groupChannels.includes(`mpim:${channelIdLower}`) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group"); + return "group"; + } + + const channelKeys = Object.keys(account.channels ?? {}); + if ( + channelKeys.some((key) => { + const normalized = key.trim().toLowerCase(); + return ( + normalized === channelIdLower || + normalized === `channel:${channelIdLower}` || + normalized.replace(/^#/, "") === channelIdLower + ); + }) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "channel"); + return "channel"; + } + + const token = account.botToken?.trim() || account.config.userToken?.trim() || ""; + if (!token) { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); + return "unknown"; + } + + try { + const client = createSlackWebClient(token); + const info = await client.conversations.info({ channel: channelId }); + const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; + const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type); + return type; + } catch { + SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); + return "unknown"; + } +} diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 2bc03ba5c86..271571e9091 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -39,7 +39,7 @@ import { import type { SlackActionContext } from "./action-runtime.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; -import { createSlackWebClient } from "./client.js"; +import { resolveSlackChannelType } from "./channel-type.js"; import { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, @@ -47,7 +47,6 @@ import { import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; -import { normalizeAllowListLower } from "./monitor/allow-list.js"; import { slackOutbound } from "./outbound-adapter.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; @@ -74,8 +73,6 @@ import { import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; -const SLACK_CHANNEL_TYPE_CACHE = new Map(); - const resolveSlackDmPolicy = createScopedDmSecurityResolver({ channelKey: "slack", resolvePolicy: (account) => account.dm?.policy, @@ -176,69 +173,6 @@ function buildSlackBaseSessionKey(params: { return buildOutboundBaseSessionKey({ ...params, channel: "slack" }); } -async function resolveSlackChannelType(params: { - cfg: OpenClawConfig; - accountId?: string | null; - channelId: string; -}): Promise<"channel" | "group" | "dm" | "unknown"> { - const channelId = params.channelId.trim(); - if (!channelId) { - return "unknown"; - } - const cacheKey = `${params.accountId ?? "default"}:${channelId}`; - const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey); - if (cached) { - return cached; - } - - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); - const channelIdLower = channelId.toLowerCase(); - if ( - groupChannels.includes(channelIdLower) || - groupChannels.includes(`slack:${channelIdLower}`) || - groupChannels.includes(`channel:${channelIdLower}`) || - groupChannels.includes(`group:${channelIdLower}`) || - groupChannels.includes(`mpim:${channelIdLower}`) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group"); - return "group"; - } - - const channelKeys = Object.keys(account.channels ?? {}); - if ( - channelKeys.some((key) => { - const normalized = key.trim().toLowerCase(); - return ( - normalized === channelIdLower || - normalized === `channel:${channelIdLower}` || - normalized.replace(/^#/, "") === channelIdLower - ); - }) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "channel"); - return "channel"; - } - - const token = account.botToken?.trim() || account.config.userToken?.trim() || ""; - if (!token) { - SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); - return "unknown"; - } - - try { - const client = createSlackWebClient(token); - const info = await client.conversations.info({ channel: channelId }); - const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; - const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; - SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type); - return type; - } catch { - SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown"); - return "unknown"; - } -} - async function resolveSlackOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 9c4052a944e..3fad041383c 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -1,17 +1,17 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../test/helpers/extensions/configured-binding-runtime.js"; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - ensureConfiguredBindingRouteReady: (...args: unknown[]) => - ensureConfiguredBindingRouteReadyMock(...args), - resolveConfiguredBindingRoute: (...args: unknown[]) => - resolveConfiguredBindingRouteMock(...args), - }; + return await createConfiguredBindingConversationRuntimeModuleMock( + { + ensureConfiguredBindingRouteReadyMock, + resolveConfiguredBindingRouteMock, + }, + importOriginal, + ); }); let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest; diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index 4e8439d4c0a..69f9f345454 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -227,6 +227,45 @@ export function createWebInboundDeliverySpies(): AnyExport { }; } +export function createWebAutoReplyRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +export function startWebAutoReplyMonitor(params: { + monitorWebChannelFn: (...args: unknown[]) => Promise; + listenerFactory: unknown; + sleep: ReturnType; + signal?: AbortSignal; + heartbeatSeconds?: number; + messageTimeoutMs?: number; + watchdogCheckMs?: number; + reconnect?: { initialMs: number; maxMs: number; maxAttempts: number; factor: number }; +}) { + const runtime = createWebAutoReplyRuntime(); + const controller = new AbortController(); + const run = params.monitorWebChannelFn( + false, + params.listenerFactory as never, + true, + async () => ({ text: "ok" }), + runtime as never, + params.signal ?? controller.signal, + { + heartbeatSeconds: params.heartbeatSeconds ?? 1, + messageTimeoutMs: params.messageTimeoutMs, + watchdogCheckMs: params.watchdogCheckMs, + reconnect: params.reconnect ?? { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + sleep: params.sleep, + }, + ); + + return { runtime, controller, run }; +} + export async function sendWebGroupInboundMessage(params: { onMessage: (msg: WebInboundMessage) => Promise; body: string; @@ -270,6 +309,7 @@ export async function sendWebDirectInboundMessage(params: { to: string; spies: ReturnType; accountId?: string; + timestamp?: number; }) { const accountId = params.accountId ?? "default"; await params.onMessage({ @@ -279,7 +319,7 @@ export async function sendWebDirectInboundMessage(params: { conversationId: params.from, to: params.to, body: params.body, - timestamp: Date.now(), + timestamp: params.timestamp ?? Date.now(), chatType: "direct", chatId: `direct:${params.from}`, sendComposing: params.spies.sendComposing, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 5f4b3cb6b70..5ec6f9145fd 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -7,6 +7,7 @@ import { setLoggerOverride } from "../../../src/logging.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { + createWebInboundDeliverySpies, createMockWebListener, createScriptedWebListenerFactory, createWebListenerFactoryCapture, @@ -14,75 +15,47 @@ import { installWebAutoReplyUnitTestHooks, makeSessionStore, resetLoadConfigMock, + sendWebDirectInboundMessage, setLoadConfigMock, + startWebAutoReplyMonitor, } from "./auto-reply.test-harness.js"; -import type { WebInboundMessage } from "./inbound.js"; installWebAutoReplyTestHomeHooks(); -function createRuntime() { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; -} - -function startMonitorWebChannel(params: { - monitorWebChannelFn: (...args: unknown[]) => Promise; - listenerFactory: unknown; - sleep: ReturnType; - signal?: AbortSignal; - heartbeatSeconds?: number; - messageTimeoutMs?: number; - watchdogCheckMs?: number; - reconnect?: { initialMs: number; maxMs: number; maxAttempts: number; factor: number }; +async function startWatchdogScenario(params: { + monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; }) { - const runtime = createRuntime(); - const controller = new AbortController(); - const run = params.monitorWebChannelFn( - false, - params.listenerFactory as never, - true, - async () => ({ text: "ok" }), - runtime as never, - params.signal ?? controller.signal, - { - heartbeatSeconds: params.heartbeatSeconds ?? 1, - messageTimeoutMs: params.messageTimeoutMs, - watchdogCheckMs: params.watchdogCheckMs, - reconnect: params.reconnect ?? { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, - sleep: params.sleep, + const sleep = vi.fn(async () => {}); + const scripted = createScriptedWebListenerFactory(); + const started = startWebAutoReplyMonitor({ + monitorWebChannelFn: params.monitorWebChannel as never, + listenerFactory: scripted.listenerFactory, + sleep, + heartbeatSeconds: 60, + messageTimeoutMs: 30, + watchdogCheckMs: 5, + }); + + await Promise.resolve(); + expect(scripted.getListenerCount()).toBe(1); + await vi.waitFor( + () => { + expect(scripted.getOnMessage()).toBeTypeOf("function"); }, + { timeout: 250, interval: 2 }, ); - return { runtime, controller, run }; -} + const spies = createWebInboundDeliverySpies(); + await sendWebDirectInboundMessage({ + onMessage: scripted.getOnMessage()!, + body: "hi", + from: "+1", + to: "+2", + id: "m1", + spies, + }); -function makeInboundMessage(params: { - body: string; - from: string; - to: string; - id?: string; - timestamp?: number; - sendComposing: ReturnType; - reply: ReturnType; - sendMedia: ReturnType; -}): WebInboundMessage { - return { - body: params.body, - from: params.from, - to: params.to, - id: params.id, - timestamp: params.timestamp, - conversationId: params.from, - accountId: "default", - chatType: "direct", - chatId: params.from, - sendComposing: params.sendComposing as unknown as WebInboundMessage["sendComposing"], - reply: params.reply as unknown as WebInboundMessage["reply"], - sendMedia: params.sendMedia as unknown as WebInboundMessage["sendMedia"], - }; + return { scripted, sleep, spies, ...started }; } describe("web auto-reply connection", () => { @@ -115,7 +88,7 @@ describe("web auto-reply connection", () => { ]) { const sleep = vi.fn(async () => {}); const scripted = createScriptedWebListenerFactory(); - const { runtime, controller, run } = startMonitorWebChannel({ + const { runtime, controller, run } = startWebAutoReplyMonitor({ monitorWebChannelFn: monitorWebChannel as never, listenerFactory: scripted.listenerFactory, sleep, @@ -150,7 +123,7 @@ describe("web auto-reply connection", () => { it("treats status 440 as non-retryable and stops without retrying", async () => { const sleep = vi.fn(async () => {}); const scripted = createScriptedWebListenerFactory(); - const { runtime, controller, run } = startMonitorWebChannel({ + const { runtime, controller, run } = startWebAutoReplyMonitor({ monitorWebChannelFn: monitorWebChannel as never, listenerFactory: scripted.listenerFactory, sleep, @@ -193,42 +166,10 @@ describe("web auto-reply connection", () => { it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); try { - const sleep = vi.fn(async () => {}); - const scripted = createScriptedWebListenerFactory(); - const { controller, run } = startMonitorWebChannel({ - monitorWebChannelFn: monitorWebChannel as never, - listenerFactory: scripted.listenerFactory, - sleep, - heartbeatSeconds: 60, - messageTimeoutMs: 30, - watchdogCheckMs: 5, + const { scripted, controller, run } = await startWatchdogScenario({ + monitorWebChannel, }); - await Promise.resolve(); - expect(scripted.getListenerCount()).toBe(1); - await vi.waitFor( - () => { - expect(scripted.getOnMessage()).toBeTypeOf("function"); - }, - { timeout: 250, interval: 2 }, - ); - - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const sendMedia = vi.fn(); - - void scripted.getOnMessage()?.( - makeInboundMessage({ - body: "hi", - from: "+1", - to: "+2", - id: "m1", - sendComposing, - reply, - sendMedia, - }), - ); - await vi.advanceTimersByTimeAsync(200); await Promise.resolve(); await vi.waitFor( @@ -250,43 +191,10 @@ describe("web auto-reply connection", () => { it("keeps watchdog message age across reconnects", async () => { vi.useFakeTimers(); try { - const sleep = vi.fn(async () => {}); - const scripted = createScriptedWebListenerFactory(); - const { controller, run } = startMonitorWebChannel({ - monitorWebChannelFn: monitorWebChannel as never, - listenerFactory: scripted.listenerFactory, - sleep, - heartbeatSeconds: 60, - messageTimeoutMs: 30, - watchdogCheckMs: 5, + const { scripted, controller, run } = await startWatchdogScenario({ + monitorWebChannel, }); - await Promise.resolve(); - expect(scripted.getListenerCount()).toBe(1); - await vi.waitFor( - () => { - expect(scripted.getOnMessage()).toBeTypeOf("function"); - }, - { timeout: 250, interval: 2 }, - ); - - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const sendMedia = vi.fn(); - - void scripted.getOnMessage()?.( - makeInboundMessage({ - body: "hi", - from: "+1", - to: "+2", - id: "m1", - sendComposing, - reply, - sendMedia, - }), - ); - await Promise.resolve(); - scripted.resolveClose(0, { status: 499, isLoggedOut: false, error: "first-close" }); await vi.waitFor( () => { @@ -347,30 +255,25 @@ describe("web auto-reply connection", () => { const capturedOnMessage = capture.getOnMessage(); expect(capturedOnMessage).toBeDefined(); - await capturedOnMessage?.( - makeInboundMessage({ - body: "first", - from: "+1", - to: "+2", - id: "m1", - timestamp: 1735689600000, - sendComposing, - reply, - sendMedia, - }), - ); - await capturedOnMessage?.( - makeInboundMessage({ - body: "second", - from: "+1", - to: "+2", - id: "m2", - timestamp: 1735693200000, - sendComposing, - reply, - sendMedia, - }), - ); + const spies = { sendMedia, reply, sendComposing }; + await sendWebDirectInboundMessage({ + onMessage: capturedOnMessage!, + body: "first", + from: "+1", + to: "+2", + id: "m1", + timestamp: 1735689600000, + spies, + }); + await sendWebDirectInboundMessage({ + onMessage: capturedOnMessage!, + body: "second", + from: "+1", + to: "+2", + id: "m2", + timestamp: 1735693200000, + spies, + }); expect(resolver).toHaveBeenCalledTimes(2); const firstArgs = resolver.mock.calls[0][0]; diff --git a/extensions/whatsapp/src/inbound/access-control.test-harness.ts b/extensions/whatsapp/src/inbound/access-control.test-harness.ts index 5bff5f06ff5..f4899c0bfcd 100644 --- a/extensions/whatsapp/src/inbound/access-control.test-harness.ts +++ b/extensions/whatsapp/src/inbound/access-control.test-harness.ts @@ -1,20 +1,20 @@ import { beforeEach, vi } from "vitest"; - -type AsyncMock = { - (...args: TArgs): Promise; - mockReset: () => AsyncMock; - mockResolvedValue: (value: TResult) => AsyncMock; - mockResolvedValueOnce: (value: TResult) => AsyncMock; -}; +import { + type AsyncMock, + loadConfigMock, + readAllowFromStoreMock, + resetPairingSecurityMocks, + upsertPairingRequestMock, +} from "../pairing-security.test-harness.js"; export const sendMessageMock = vi.fn() as AsyncMock; -export const readAllowFromStoreMock = vi.fn() as AsyncMock; -export const upsertPairingRequestMock = vi.fn() as AsyncMock; +export { readAllowFromStoreMock, upsertPairingRequestMock }; let config: Record = {}; export function setAccessControlTestConfig(next: Record): void { config = next; + loadConfigMock.mockReturnValue(config); } export function setupAccessControlTestHarness(): void { @@ -28,38 +28,6 @@ export function setupAccessControlTestHarness(): void { }, }; sendMessageMock.mockReset().mockResolvedValue(undefined); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + resetPairingSecurityMocks(config); }); } - -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readStoreAllowFromForDmPolicy: async ( - params: Parameters[0], - ) => - await actual.readStoreAllowFromForDmPolicy({ - ...params, - readStore: async (provider, accountId) => - (await readAllowFromStoreMock(provider, accountId)) as string[], - }), - }; -}); diff --git a/extensions/whatsapp/src/login.test.ts b/extensions/whatsapp/src/login.test.ts index 95d0c9f3977..637c3c3ff5c 100644 --- a/extensions/whatsapp/src/login.test.ts +++ b/extensions/whatsapp/src/login.test.ts @@ -65,7 +65,7 @@ describe("renderQrPngBase64", () => { }); it("avoids dynamic require of qrcode-terminal vendor modules", async () => { - const sourcePath = resolve(process.cwd(), "extensions/whatsapp/src/qr-image.ts"); + const sourcePath = resolve(process.cwd(), "src/media/qr-image.ts"); const source = await readFile(sourcePath, "utf-8"); expect(source).not.toContain("createRequire("); expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")'); diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 10db2e3eeb3..0b6b05dcab1 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -4,6 +4,12 @@ import os from "node:os"; import path from "node:path"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, expect, vi } from "vitest"; +import { + loadConfigMock, + readAllowFromStoreMock as pairingReadAllowFromStoreMock, + resetPairingSecurityMocks, + upsertPairingRequestMock as pairingUpsertPairingRequestMock, +} from "./pairing-security.test-harness.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -23,13 +29,9 @@ export const DEFAULT_WEB_INBOX_CONFIG = { responsePrefix: undefined, }, } as const; - -export const mockLoadConfig: AnyMockFn = vi.fn().mockReturnValue(DEFAULT_WEB_INBOX_CONFIG); - -export const readAllowFromStoreMock: AnyMockFn = vi.fn().mockResolvedValue([]); -export const upsertPairingRequestMock: AnyMockFn = vi - .fn() - .mockResolvedValue({ code: "PAIRCODE", created: true }); +export const mockLoadConfig = loadConfigMock; +export const readAllowFromStoreMock = pairingReadAllowFromStoreMock; +export const upsertPairingRequestMock = pairingUpsertPairingRequestMock; export type MockSock = { ev: EventEmitter; @@ -87,37 +89,6 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => mockLoadConfig(), - }; -}); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readStoreAllowFromForDmPolicy: async ( - params: Parameters[0], - ) => - await actual.readStoreAllowFromForDmPolicy({ - ...params, - readStore: async (provider, accountId) => - (await readAllowFromStoreMock(provider, accountId)) as string[], - }), - }; -}); - vi.mock("./session.js", async () => { const actual = await vi.importActual("./session.js"); return { @@ -231,12 +202,7 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean } vi.resetModules(); vi.clearAllMocks(); sessionState.sock = createMockSock(); - mockLoadConfig.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG); - readAllowFromStoreMock.mockResolvedValue([]); - upsertPairingRequestMock.mockResolvedValue({ - code: "PAIRCODE", - created: true, - }); + resetPairingSecurityMocks(DEFAULT_WEB_INBOX_CONFIG); const inboundModule = await import("./inbound.js"); monitorWebInbox = inboundModule.monitorWebInbox; const { resetWebInboundDedupe } = inboundModule; diff --git a/extensions/whatsapp/src/pairing-security.test-harness.ts b/extensions/whatsapp/src/pairing-security.test-harness.ts new file mode 100644 index 00000000000..3ccde385b69 --- /dev/null +++ b/extensions/whatsapp/src/pairing-security.test-harness.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; + +export type AsyncMock = { + (...args: TArgs): Promise; + mockReset: () => AsyncMock; + mockResolvedValue: (value: TResult) => AsyncMock; + mockResolvedValueOnce: (value: TResult) => AsyncMock; +}; + +export const loadConfigMock = vi.fn(); +export const readAllowFromStoreMock = vi.fn() as AsyncMock; +export const upsertPairingRequestMock = vi.fn() as AsyncMock; + +export function resetPairingSecurityMocks(config: Record) { + loadConfigMock.mockReset().mockReturnValue(config); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); +} + +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: (...args: unknown[]) => loadConfigMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readStoreAllowFromForDmPolicy: async ( + params: Parameters[0], + ) => + await actual.readStoreAllowFromForDmPolicy({ + ...params, + readStore: async (provider, accountId) => + (await readAllowFromStoreMock(provider, accountId)) as string[], + }), + }; +}); diff --git a/extensions/whatsapp/src/qr-image.ts b/extensions/whatsapp/src/qr-image.ts index be6b10f5b0e..fe28c771a4e 100644 --- a/extensions/whatsapp/src/qr-image.ts +++ b/extensions/whatsapp/src/qr-image.ts @@ -1,54 +1 @@ -import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; -import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; -import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; - -type QRCodeConstructor = new ( - typeNumber: number, - errorCorrectLevel: unknown, -) => { - addData: (data: string) => void; - make: () => void; - getModuleCount: () => number; - isDark: (row: number, col: number) => boolean; -}; - -const QRCode = QRCodeModule as QRCodeConstructor; -const QRErrorCorrectLevel = QRErrorCorrectLevelModule; - -function createQrMatrix(input: string) { - const qr = new QRCode(-1, QRErrorCorrectLevel.L); - qr.addData(input); - qr.make(); - return qr; -} - -export async function renderQrPngBase64( - input: string, - opts: { scale?: number; marginModules?: number } = {}, -): Promise { - const { scale = 6, marginModules = 4 } = opts; - const qr = createQrMatrix(input); - const modules = qr.getModuleCount(); - const size = (modules + marginModules * 2) * scale; - - const buf = Buffer.alloc(size * size * 4, 255); - for (let row = 0; row < modules; row += 1) { - for (let col = 0; col < modules; col += 1) { - if (!qr.isDark(row, col)) { - continue; - } - const startX = (col + marginModules) * scale; - const startY = (row + marginModules) * scale; - for (let y = 0; y < scale; y += 1) { - const pixelY = startY + y; - for (let x = 0; x < scale; x += 1) { - const pixelX = startX + x; - fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); - } - } - } - } - - const png = encodePngRgba(buf, size, size); - return png.toString("base64"); -} +export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime"; diff --git a/package.json b/package.json index b56c780ea9a..fcf19631119 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,10 @@ "types": "./dist/plugin-sdk/gateway-runtime.d.ts", "default": "./dist/plugin-sdk/gateway-runtime.js" }, + "./plugin-sdk/github-copilot-token": { + "types": "./dist/plugin-sdk/github-copilot-token.d.ts", + "default": "./dist/plugin-sdk/github-copilot-token.js" + }, "./plugin-sdk/cli-runtime": { "types": "./dist/plugin-sdk/cli-runtime.d.ts", "default": "./dist/plugin-sdk/cli-runtime.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f6b8ecf6bbe..f67c51a49a2 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -35,6 +35,7 @@ "plugin-runtime", "security-runtime", "gateway-runtime", + "github-copilot-token", "cli-runtime", "hook-runtime", "process-runtime", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 42c106244a9..843c21b1d2a 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -116,9 +116,10 @@ const parsePoolOverride = (value, fallback) => { } return fallback; }; -// Even on low-memory hosts, keep the isolated lane split so files like -// git-commit.test.ts still get the worker/process isolation they require. -const shouldSplitUnitRuns = testProfile !== "serial"; +// Even on low-memory or fully serial hosts, keep the unit lane split so +// long-lived workers do not accumulate the whole unit transform graph. +const shouldSplitUnitRuns = true; +const useLowProfileUnitSchedulingDefaults = testProfile === "low" || testProfile === "serial"; let runs = []; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); const configuredShardCount = @@ -327,26 +328,20 @@ const parseEnvNumber = (name, fallback) => { const allKnownUnitFiles = allKnownTestFiles.filter((file) => { return isUnitConfigTestFile(file); }); -const defaultHeavyUnitFileLimit = - testProfile === "serial" - ? 0 - : isMacMiniProfile - ? 90 - : testProfile === "low" - ? 36 - : highMemLocalHost - ? 80 - : 60; -const defaultHeavyUnitLaneCount = - testProfile === "serial" - ? 0 - : isMacMiniProfile - ? 6 - : testProfile === "low" - ? 4 - : highMemLocalHost - ? 5 - : 4; +const defaultHeavyUnitFileLimit = isMacMiniProfile + ? 90 + : useLowProfileUnitSchedulingDefaults + ? 36 + : highMemLocalHost + ? 80 + : 60; +const defaultHeavyUnitLaneCount = isMacMiniProfile + ? 6 + : useLowProfileUnitSchedulingDefaults + ? 4 + : highMemLocalHost + ? 5 + : 4; const heavyUnitFileLimit = parseEnvNumber( "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", defaultHeavyUnitFileLimit, @@ -356,8 +351,7 @@ const heavyUnitLaneCount = parseEnvNumber( defaultHeavyUnitLaneCount, ); const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200); -const defaultMemoryHeavyUnitFileLimit = - testProfile === "serial" ? 0 : isCI ? 64 : testProfile === "low" ? 8 : 16; +const defaultMemoryHeavyUnitFileLimit = isCI ? 64 : useLowProfileUnitSchedulingDefaults ? 8 : 16; const memoryHeavyUnitFileLimit = parseEnvNumber( "OPENCLAW_TEST_MEMORY_HEAVY_UNIT_FILE_LIMIT", defaultMemoryHeavyUnitFileLimit, @@ -502,8 +496,13 @@ const unitFastLaneCount = Math.max( 1, parseEnvNumber("OPENCLAW_TEST_UNIT_FAST_LANES", defaultUnitFastLaneCount), ); -const defaultUnitFastBatchTargetMs = - testProfile === "low" ? 10_000 : isCI && !isWindows ? 45_000 : 0; +const defaultUnitFastBatchTargetMs = useLowProfileUnitSchedulingDefaults + ? 10_000 + : isCI && !isWindows + ? 45_000 + : highMemLocalHost + ? 45_000 + : 0; const unitFastBatchTargetMs = parseEnvNumber( "OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS", defaultUnitFastBatchTargetMs, diff --git a/src/agents/bundle-mcp.test-harness.ts b/src/agents/bundle-mcp.test-harness.ts new file mode 100644 index 00000000000..4fa84028d56 --- /dev/null +++ b/src/agents/bundle-mcp.test-harness.ts @@ -0,0 +1,136 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); +const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); +const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js"); +const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js"); + +export async function writeExecutable(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); +} + +export async function writeBundleProbeMcpServer(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; +import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; + +const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); +server.tool("bundle_probe", "Bundle MCP probe", async () => { + return { + content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], + }; +}); + +await server.connect(new StdioServerTransport()); +`, + ); +} + +export async function writeClaudeBundle(params: { + pluginRoot: string; + serverScriptPath: string; +}): Promise { + await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(params.pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: [path.relative(params.pluginRoot, params.serverScriptPath)], + env: { + BUNDLE_PROBE_TEXT: "FROM-BUNDLE", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); +} + +export async function writeFakeClaudeCli(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)}; +import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)}; + +function readArg(name) { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === name) { + return args[i + 1]; + } + if (arg.startsWith(name + "=")) { + return arg.slice(name.length + 1); + } + } + return undefined; +} + +const mcpConfigPath = readArg("--mcp-config"); +if (!mcpConfigPath) { + throw new Error("missing --mcp-config"); +} + +const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8")); +const servers = raw?.mcpServers ?? raw?.servers ?? {}; +const server = servers.bundleProbe ?? Object.values(servers)[0]; +if (!server || typeof server !== "object") { + throw new Error("missing bundleProbe MCP server"); +} + +const transport = new StdioClientTransport({ + command: server.command, + args: Array.isArray(server.args) ? server.args : [], + env: server.env && typeof server.env === "object" ? server.env : undefined, + cwd: + typeof server.cwd === "string" + ? server.cwd + : typeof server.workingDirectory === "string" + ? server.workingDirectory + : undefined, +}); +const client = new Client({ name: "fake-claude", version: "1.0.0" }); +await client.connect(transport); +const tools = await client.listTools(); +if (!tools.tools.some((tool) => tool.name === "bundle_probe")) { + throw new Error("bundle_probe tool not exposed"); +} +const result = await client.callTool({ name: "bundle_probe", arguments: {} }); +await transport.close(); + +const text = Array.isArray(result.content) + ? result.content + .filter((entry) => entry?.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text) + .join("\\n") + : ""; + +process.stdout.write( + JSON.stringify({ + session_id: readArg("--session-id") ?? randomUUID(), + message: "BUNDLE MCP OK " + text, + }) + "\\n", +); +`, + ); +} diff --git a/src/agents/chutes-models.test.ts b/src/agents/chutes-models.test.ts index 66bafde50ad..0e136b01b5c 100644 --- a/src/agents/chutes-models.test.ts +++ b/src/agents/chutes-models.test.ts @@ -6,6 +6,45 @@ import { clearChutesModelCache, } from "./chutes-models.js"; +async function withLiveChutesDiscovery( + fetchMock: ReturnType, + run: () => Promise, + options?: { now?: string }, +): Promise { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + if (options?.now) { + vi.useFakeTimers(); + vi.setSystemTime(new Date(options.now)); + } + vi.stubGlobal("fetch", fetchMock); + + try { + return await run(); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + if (options?.now) { + vi.useRealTimers(); + } + } +} + +function createAuthEchoFetchMock() { + return vi.fn().mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); +} + describe("chutes-models", () => { beforeEach(() => { clearChutesModelCache(); @@ -37,11 +76,6 @@ describe("chutes-models", () => { }); it("discoverChutesModels correctly maps API response when not in test env", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -59,9 +93,7 @@ describe("chutes-models", () => { ], }), }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withLiveChutesDiscovery(mockFetch, async () => { const models = await discoverChutesModels("test-token-real-fetch"); expect(models.length).toBeGreaterThan(0); if (models.length === 3) { @@ -69,19 +101,10 @@ describe("chutes-models", () => { expect(models[1]?.reasoning).toBe(true); expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); } - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); it("discoverChutesModels retries without auth on 401", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockImplementation((url, init) => { if (init?.headers?.Authorization === "Bearer test-token-error") { // pragma: allowlist secret @@ -124,50 +147,29 @@ describe("chutes-models", () => { }), }); }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withLiveChutesDiscovery(mockFetch, async () => { const models = await discoverChutesModels("test-token-error"); expect(models.length).toBeGreaterThan(0); expect(mockFetch).toHaveBeenCalled(); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); it("caches fallback static catalog for non-OK responses", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503, }); - vi.stubGlobal("fetch", mockFetch); - try { + await withLiveChutesDiscovery(mockFetch, async () => { const first = await discoverChutesModels("chutes-fallback-token"); const second = await discoverChutesModels("chutes-fallback-token"); expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); expect(mockFetch).toHaveBeenCalledTimes(1); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); it("scopes discovery cache by access token", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi .fn() .mockImplementation((_url, init?: { headers?: Record }) => { @@ -195,9 +197,7 @@ describe("chutes-models", () => { }), }); }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withLiveChutesDiscovery(mockFetch, async () => { const modelsA = await discoverChutesModels("chutes-token-a"); const modelsB = await discoverChutesModels("chutes-token-b"); const modelsASecond = await discoverChutesModels("chutes-token-a"); @@ -206,33 +206,13 @@ describe("chutes-models", () => { expect(modelsASecond[0]?.id).toBe("private/model-a"); // One request per token, then cache hit for the repeated token-a call. expect(mockFetch).toHaveBeenCalledTimes(2); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); it("evicts oldest token entries when cache reaches max size", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; + const mockFetch = createAuthEchoFetchMock(); - const mockFetch = vi - .fn() - .mockImplementation((_url, init?: { headers?: Record }) => { - const auth = init?.headers?.Authorization ?? ""; - return Promise.resolve({ - ok: true, - json: async () => ({ - data: [{ id: auth ? `${auth}-model` : "public-model" }], - }), - }); - }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withLiveChutesDiscovery(mockFetch, async () => { for (let i = 0; i < 150; i += 1) { await discoverChutesModels(`cache-token-${i}`); } @@ -240,54 +220,26 @@ describe("chutes-models", () => { // The oldest key should have been evicted once we exceed the cap. await discoverChutesModels("cache-token-0"); expect(mockFetch).toHaveBeenCalledTimes(151); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); it("prunes expired token cache entries during subsequent discovery", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + const mockFetch = createAuthEchoFetchMock(); - const mockFetch = vi - .fn() - .mockImplementation((_url, init?: { headers?: Record }) => { - const auth = init?.headers?.Authorization ?? ""; - return Promise.resolve({ - ok: true, - json: async () => ({ - data: [{ id: auth ? `${auth}-model` : "public-model" }], - }), - }); - }); - vi.stubGlobal("fetch", mockFetch); - - try { - await discoverChutesModels("token-a"); - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - await discoverChutesModels("token-b"); - await discoverChutesModels("token-a"); - expect(mockFetch).toHaveBeenCalledTimes(3); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - vi.useRealTimers(); - } + await withLiveChutesDiscovery( + mockFetch, + async () => { + await discoverChutesModels("token-a"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await discoverChutesModels("token-b"); + await discoverChutesModels("token-a"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }, + { now: "2026-03-01T00:00:00.000Z" }, + ); }); it("does not cache 401 fallback under the failed token key", async () => { - const oldNodeEnv = process.env.NODE_ENV; - const oldVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi .fn() .mockImplementation((_url, init?: { headers?: Record }) => { @@ -304,17 +256,11 @@ describe("chutes-models", () => { }), }); }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withLiveChutesDiscovery(mockFetch, async () => { await discoverChutesModels("failed-token"); await discoverChutesModels("failed-token"); // Two calls each perform: authenticated attempt (401) + public fallback. expect(mockFetch).toHaveBeenCalledTimes(4); - } finally { - process.env.NODE_ENV = oldNodeEnv; - process.env.VITEST = oldVitest; - vi.unstubAllGlobals(); - } + }); }); }); diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts index 7210c563467..ea2d3405d44 100644 --- a/src/agents/cli-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -1,145 +1,17 @@ import fs from "node:fs/promises"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { captureEnv } from "../test-utils/env.js"; +import { + writeBundleProbeMcpServer, + writeClaudeBundle, + writeFakeClaudeCli, +} from "./bundle-mcp.test-harness.js"; import { runCliAgent } from "./cli-runner.js"; const E2E_TIMEOUT_MS = 20_000; -const require = createRequire(import.meta.url); -const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); -const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); -const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js"); -const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js"); - -async function writeExecutable(filePath: string, content: string): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); -} - -async function writeBundleProbeMcpServer(filePath: string): Promise { - await writeExecutable( - filePath, - `#!/usr/bin/env node -import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; -import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; - -const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); -server.tool("bundle_probe", "Bundle MCP probe", async () => { - return { - content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], - }; -}); - -await server.connect(new StdioServerTransport()); -`, - ); -} - -async function writeFakeClaudeCli(filePath: string): Promise { - await writeExecutable( - filePath, - `#!/usr/bin/env node -import fs from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)}; -import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)}; - -function readArg(name) { - const args = process.argv.slice(2); - for (let i = 0; i < args.length; i += 1) { - const arg = args[i] ?? ""; - if (arg === name) { - return args[i + 1]; - } - if (arg.startsWith(name + "=")) { - return arg.slice(name.length + 1); - } - } - return undefined; -} - -const mcpConfigPath = readArg("--mcp-config"); -if (!mcpConfigPath) { - throw new Error("missing --mcp-config"); -} - -const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8")); -const servers = raw?.mcpServers ?? raw?.servers ?? {}; -const server = servers.bundleProbe ?? Object.values(servers)[0]; -if (!server || typeof server !== "object") { - throw new Error("missing bundleProbe MCP server"); -} - -const transport = new StdioClientTransport({ - command: server.command, - args: Array.isArray(server.args) ? server.args : [], - env: server.env && typeof server.env === "object" ? server.env : undefined, - cwd: - typeof server.cwd === "string" - ? server.cwd - : typeof server.workingDirectory === "string" - ? server.workingDirectory - : undefined, -}); -const client = new Client({ name: "fake-claude", version: "1.0.0" }); -await client.connect(transport); -const tools = await client.listTools(); -if (!tools.tools.some((tool) => tool.name === "bundle_probe")) { - throw new Error("bundle_probe tool not exposed"); -} -const result = await client.callTool({ name: "bundle_probe", arguments: {} }); -await transport.close(); - -const text = Array.isArray(result.content) - ? result.content - .filter((entry) => entry?.type === "text" && typeof entry.text === "string") - .map((entry) => entry.text) - .join("\\n") - : ""; - -process.stdout.write( - JSON.stringify({ - session_id: readArg("--session-id") ?? randomUUID(), - message: "BUNDLE MCP OK " + text, - }) + "\\n", -); -`, - ); -} - -async function writeClaudeBundle(params: { - pluginRoot: string; - serverScriptPath: string; -}): Promise { - await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(params.pluginRoot, ".mcp.json"), - `${JSON.stringify( - { - mcpServers: { - bundleProbe: { - command: "node", - args: [path.relative(params.pluginRoot, params.serverScriptPath)], - env: { - BUNDLE_PROBE_TEXT: "FROM-BUNDLE", - }, - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); -} describe("runCliAgent bundle MCP e2e", () => { it( diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 42b51aef090..df29e08e412 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -1,12 +1,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; import { runWithModelFallback } from "./model-fallback.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; +import { + buildEmbeddedRunnerAssistant, + createResolvedEmbeddedRunnerModel, + makeEmbeddedRunnerAttempt, +} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js"; const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({ @@ -61,25 +65,8 @@ const installRunEmbeddedMocks = () => { ensureRuntimePluginsLoaded: vi.fn(), })); vi.doMock("./pi-embedded-runner/model.js", () => ({ - resolveModelAsync: async (provider: string, modelId: string) => ({ - model: { - id: modelId, - name: modelId, - api: "openai-responses", - provider, - baseUrl: `https://example.com/${provider}`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 16_000, - maxTokens: 2048, - }, - error: undefined, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }), + resolveModelAsync: async (provider: string, modelId: string) => + createResolvedEmbeddedRunnerModel(provider, modelId), })); vi.doMock("../plugins/provider-runtime.js", async (importOriginal) => { const actual = await importOriginal(); @@ -105,49 +92,9 @@ beforeEach(() => { sleepWithAbortMock.mockClear(); }); -const baseUsage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - const OVERLOADED_ERROR_PAYLOAD = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'; -const buildAssistant = (overrides: Partial): AssistantMessage => ({ - role: "assistant", - content: [], - api: "openai-responses", - provider: "openai", - model: "mock-1", - usage: baseUsage, - stopReason: "stop", - timestamp: Date.now(), - ...overrides, -}); - -const makeAttempt = (overrides: Partial): EmbeddedRunAttemptResult => ({ - aborted: false, - timedOut: false, - timedOutDuringCompaction: false, - promptError: null, - sessionIdUsed: "session:test", - systemPromptReport: undefined, - messagesSnapshot: [], - assistantTexts: [], - toolMetas: [], - lastAssistant: undefined, - didSendViaMessagingTool: false, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], - messagingToolSentTargets: [], - cloudCodeAssistFormatError: false, - ...overrides, -}); - function makeConfig(): OpenClawConfig { const apiKeyField = ["api", "Key"].join(""); return { @@ -292,9 +239,9 @@ function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) { runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; if (attemptParams.provider === "openai") { - return makeAttempt({ + return makeEmbeddedRunnerAttempt({ assistantTexts: [], - lastAssistant: buildAssistant({ + lastAssistant: buildEmbeddedRunnerAssistant({ provider: "openai", model: "mock-1", stopReason: "error", @@ -303,9 +250,9 @@ function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) { }); } if (attemptParams.provider === "groq") { - return makeAttempt({ + return makeEmbeddedRunnerAttempt({ assistantTexts: ["fallback ok"], - lastAssistant: buildAssistant({ + lastAssistant: buildEmbeddedRunnerAssistant({ provider: "groq", model: "mock-2", stopReason: "stop", @@ -336,9 +283,9 @@ function mockAllProvidersOverloaded() { runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; if (attemptParams.provider === "openai" || attemptParams.provider === "groq") { - return makeAttempt({ + return makeEmbeddedRunnerAttempt({ assistantTexts: [], - lastAssistant: buildAssistant({ + lastAssistant: buildEmbeddedRunnerAssistant({ provider: attemptParams.provider, model: attemptParams.provider === "openai" ? "mock-1" : "mock-2", stopReason: "error", diff --git a/src/agents/models-config.providers.chutes.test.ts b/src/agents/models-config.providers.chutes.test.ts index a47ee57fcb3..5aa4de9366a 100644 --- a/src/agents/models-config.providers.chutes.test.ts +++ b/src/agents/models-config.providers.chutes.test.ts @@ -12,6 +12,108 @@ const CHUTES_OAUTH_MARKER = resolveOAuthApiKeyMarker("chutes"); const ORIGINAL_VITEST_ENV = process.env.VITEST; const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +function createTempAgentDir() { + return mkdtempSync(join(tmpdir(), "openclaw-test-")); +} + +type ChutesAuthProfiles = { + [profileId: string]: + | { + type: "api_key"; + provider: "chutes"; + key: string; + } + | { + type: "oauth"; + provider: "chutes"; + access: string; + refresh: string; + expires: number; + }; +}; + +function createChutesApiKeyProfile(key = "chutes-live-api-key") { + return { + type: "api_key" as const, + provider: "chutes" as const, + key, + }; +} + +function createChutesOAuthProfile(access = "oauth-access-token") { + return { + type: "oauth" as const, + provider: "chutes" as const, + access, + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }; +} + +async function writeChutesAuthProfiles(agentDir: string, profiles: ChutesAuthProfiles) { + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles, + }, + null, + 2, + ), + "utf8", + ); +} + +async function resolveChutesProvidersForProfiles( + profiles: ChutesAuthProfiles, + env: NodeJS.ProcessEnv = {}, +) { + const agentDir = createTempAgentDir(); + await writeChutesAuthProfiles(agentDir, profiles); + return await resolveImplicitProvidersForTest({ agentDir, env }); +} + +function expectChutesApiKeyProvider( + providers: Awaited>, + apiKey = "chutes-live-api-key", +) { + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe(apiKey); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); +} + +function expectChutesOAuthMarkerProvider( + providers: Awaited>, +) { + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); +} + +async function withRealChutesDiscovery( + run: (fetchMock: ReturnType) => Promise, +) { + const originalVitest = process.env.VITEST; + const originalNodeEnv = process.env.NODE_ENV; + const originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "chutes/private-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + return await run(fetchMock); + } finally { + process.env.VITEST = originalVitest; + process.env.NODE_ENV = originalNodeEnv; + globalThis.fetch = originalFetch; + } +} + describe("chutes implicit provider auth mode", () => { beforeEach(() => { process.env.VITEST = "true"; @@ -24,7 +126,7 @@ describe("chutes implicit provider auth mode", () => { }); it("auto-loads bundled chutes discovery for env api keys", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const agentDir = createTempAgentDir(); const providers = await resolveImplicitProviders({ agentDir, env: { @@ -37,176 +139,45 @@ describe("chutes implicit provider auth mode", () => { }); it("keeps api_key-backed chutes profiles on the api-key loader path", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "chutes:default": { - type: "api_key", - provider: "chutes", - key: "chutes-live-api-key", // pragma: allowlist secret - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); - expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); - expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); - expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + const providers = await resolveChutesProvidersForProfiles({ + "chutes:default": createChutesApiKeyProfile(), + }); + expectChutesApiKeyProvider(providers); }); it("keeps api_key precedence when oauth profile is inserted first", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "chutes:oauth": { - type: "oauth", - provider: "chutes", - access: "oauth-access-token", - refresh: "oauth-refresh-token", - expires: Date.now() + 60_000, - }, - "chutes:default": { - type: "api_key", - provider: "chutes", - key: "chutes-live-api-key", // pragma: allowlist secret - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); - expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); - expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); - expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + const providers = await resolveChutesProvidersForProfiles({ + "chutes:oauth": createChutesOAuthProfile(), + "chutes:default": createChutesApiKeyProfile(), + }); + expectChutesApiKeyProvider(providers); }); it("keeps api_key precedence when api_key profile is inserted first", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "chutes:default": { - type: "api_key", - provider: "chutes", - key: "chutes-live-api-key", // pragma: allowlist secret - }, - "chutes:oauth": { - type: "oauth", - provider: "chutes", - access: "oauth-access-token", - refresh: "oauth-refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); - expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); - expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); - expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + const providers = await resolveChutesProvidersForProfiles({ + "chutes:default": createChutesApiKeyProfile(), + "chutes:oauth": createChutesOAuthProfile(), + }); + expectChutesApiKeyProvider(providers); }); it("forwards oauth access token to chutes model discovery", async () => { - // Enable real discovery so fetch is actually called. - const originalVitest = process.env.VITEST; - const originalNodeEnv = process.env.NODE_ENV; - const originalFetch = globalThis.fetch; - delete process.env.VITEST; - delete process.env.NODE_ENV; - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: [{ id: "chutes/private-model" }] }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "chutes:default": { - type: "oauth", - provider: "chutes", - access: "my-chutes-access-token", - refresh: "oauth-refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); - expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); - + await withRealChutesDiscovery(async (fetchMock) => { + const providers = await resolveChutesProvidersForProfiles({ + "chutes:default": createChutesOAuthProfile("my-chutes-access-token"), + }); + expectChutesOAuthMarkerProvider(providers); const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai")); expect(chutesCalls.length).toBeGreaterThan(0); const request = chutesCalls[0]?.[1] as { headers?: Record } | undefined; expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token"); - } finally { - process.env.VITEST = originalVitest; - process.env.NODE_ENV = originalNodeEnv; - globalThis.fetch = originalFetch; - } + }); }); it("uses CHUTES_OAUTH_MARKER only for oauth-backed chutes profiles", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await writeFile( - join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "chutes:default": { - type: "oauth", - provider: "chutes", - access: "oauth-access-token", - refresh: "oauth-refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); - expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); - expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER); + const providers = await resolveChutesProvidersForProfiles({ + "chutes:default": createChutesOAuthProfile(), + }); + expectChutesOAuthMarkerProvider(providers); }); }); diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 9326630d631..9d6de658f62 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -20,8 +20,50 @@ const createMockConfig = () => ({ let mockConfig: Record = createMockConfig(); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +function createScopedSessionStores() { + return new Map>([ + [ + "/tmp/main/sessions.json", + { + "agent:main:main": { sessionId: "s-main", updatedAt: 10 }, + }, + ], + [ + "/tmp/support/sessions.json", + { + main: { sessionId: "s-support", updatedAt: 20 }, + }, + ], + ]); +} + +function installScopedSessionStores(syncUpdates = false) { + const stores = createScopedSessionStores(); + loadSessionStoreMock.mockClear(); + updateSessionStoreMock.mockClear(); + callGatewayMock.mockClear(); + loadCombinedSessionStoreForGatewayMock.mockClear(); + loadSessionStoreMock.mockImplementation((storePath: string) => stores.get(storePath) ?? {}); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store: Object.fromEntries([...stores.values()].flatMap((store) => Object.entries(store))), + }); + if (syncUpdates) { + updateSessionStoreMock.mockImplementation( + (storePath: string, store: Record) => { + if (storePath) { + stores.set(storePath, store); + } + }, + ); + } + return stores; +} + +async function createSessionsModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), @@ -37,108 +79,37 @@ vi.mock("../config/sessions.js", async (importOriginal) => { resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) => opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json", }; -}); +} -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); +function createGatewayCallModuleMock() { + return { + callGateway: (opts: unknown) => callGatewayMock(opts), + }; +} -vi.mock("../gateway/session-utils.js", async (importOriginal) => { - const actual = await importOriginal(); +async function createGatewaySessionUtilsModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, loadCombinedSessionStoreForGateway: (cfg: unknown) => loadCombinedSessionStoreForGatewayMock(cfg), }; -}); +} -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +async function createConfigModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, loadConfig: () => mockConfig, }; -}); +} -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: async () => [ - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - contextWindow: 200000, - }, - { - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - contextWindow: 400000, - }, - ], -})); - -vi.mock("../agents/auth-profiles.js", () => ({ - ensureAuthProfileStore: () => ({ profiles: {} }), - resolveAuthProfileDisplayLabel: () => undefined, - resolveAuthProfileOrder: () => [], -})); - -vi.mock("../agents/model-auth.js", () => ({ - resolveEnvApiKey: () => null, - resolveUsableCustomProviderApiKey: () => null, - resolveModelAuthMode: () => "api-key", -})); - -vi.mock("../infra/provider-usage.js", () => ({ - resolveUsageProviderId: () => undefined, - loadProviderUsageSummary: async () => ({ - updatedAt: Date.now(), - providers: [], - }), - formatUsageSummaryLine: () => null, -})); - -let createSessionStatusTool: typeof import("./tools/session-status-tool.js").createSessionStatusTool; - -async function loadFreshOpenClawToolsForSessionStatusTest() { - vi.resetModules(); - vi.doMock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), - updateSessionStore: async ( - storePath: string, - mutator: (store: Record) => Promise | void, - ) => { - const store = loadSessionStoreMock(storePath) as Record; - await mutator(store); - updateSessionStoreMock(storePath, store); - return store; - }, - resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) => - opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json", - }; - }); - vi.doMock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), - })); - vi.doMock("../gateway/session-utils.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadCombinedSessionStoreForGateway: (cfg: unknown) => - loadCombinedSessionStoreForGatewayMock(cfg), - }; - }); - vi.doMock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => mockConfig, - }; - }); - vi.doMock("../agents/model-catalog.js", () => ({ +function createModelCatalogModuleMock() { + return { loadModelCatalog: async () => [ { provider: "anthropic", @@ -153,25 +124,57 @@ async function loadFreshOpenClawToolsForSessionStatusTest() { contextWindow: 400000, }, ], - })); - vi.doMock("../agents/auth-profiles.js", () => ({ + }; +} + +function createAuthProfilesModuleMock() { + return { ensureAuthProfileStore: () => ({ profiles: {} }), resolveAuthProfileDisplayLabel: () => undefined, resolveAuthProfileOrder: () => [], - })); - vi.doMock("../agents/model-auth.js", () => ({ + }; +} + +function createModelAuthModuleMock() { + return { resolveEnvApiKey: () => null, resolveUsableCustomProviderApiKey: () => null, resolveModelAuthMode: () => "api-key", - })); - vi.doMock("../infra/provider-usage.js", () => ({ + }; +} + +function createProviderUsageModuleMock() { + return { resolveUsageProviderId: () => undefined, loadProviderUsageSummary: async () => ({ updatedAt: Date.now(), providers: [], }), formatUsageSummaryLine: () => null, - })); + }; +} + +vi.mock("../config/sessions.js", createSessionsModuleMock); +vi.mock("../gateway/call.js", createGatewayCallModuleMock); +vi.mock("../gateway/session-utils.js", createGatewaySessionUtilsModuleMock); +vi.mock("../config/config.js", createConfigModuleMock); +vi.mock("../agents/model-catalog.js", createModelCatalogModuleMock); +vi.mock("../agents/auth-profiles.js", createAuthProfilesModuleMock); +vi.mock("../agents/model-auth.js", createModelAuthModuleMock); +vi.mock("../infra/provider-usage.js", createProviderUsageModuleMock); + +let createSessionStatusTool: typeof import("./tools/session-status-tool.js").createSessionStatusTool; + +async function loadFreshOpenClawToolsForSessionStatusTest() { + vi.resetModules(); + vi.doMock("../config/sessions.js", createSessionsModuleMock); + vi.doMock("../gateway/call.js", createGatewayCallModuleMock); + vi.doMock("../gateway/session-utils.js", createGatewaySessionUtilsModuleMock); + vi.doMock("../config/config.js", createConfigModuleMock); + vi.doMock("../agents/model-catalog.js", createModelCatalogModuleMock); + vi.doMock("../agents/auth-profiles.js", createAuthProfilesModuleMock); + vi.doMock("../agents/model-auth.js", createModelAuthModuleMock); + vi.doMock("../infra/provider-usage.js", createProviderUsageModuleMock); vi.doMock("../auto-reply/group-activation.js", () => ({ normalizeGroupActivation: (value: unknown) => value ?? "always", })); @@ -306,31 +309,7 @@ describe("session_status tool", () => { }); it("resolves sessionKey=current to the requester agent session", async () => { - loadSessionStoreMock.mockClear(); - updateSessionStoreMock.mockClear(); - callGatewayMock.mockClear(); - loadCombinedSessionStoreForGatewayMock.mockClear(); - const stores = new Map>([ - [ - "/tmp/main/sessions.json", - { - "agent:main:main": { sessionId: "s-main", updatedAt: 10 }, - }, - ], - [ - "/tmp/support/sessions.json", - { - main: { sessionId: "s-support", updatedAt: 20 }, - }, - ], - ]); - loadSessionStoreMock.mockImplementation((storePath: string) => { - return stores.get(storePath) ?? {}; - }); - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store: Object.fromEntries([...stores.values()].flatMap((s) => Object.entries(s))), - }); + installScopedSessionStores(); const tool = getSessionStatusTool("agent:support:main"); @@ -559,33 +538,7 @@ describe("session_status tool", () => { }); it("scopes bare session keys to the requester agent", async () => { - loadSessionStoreMock.mockClear(); - updateSessionStoreMock.mockClear(); - const stores = new Map>([ - [ - "/tmp/main/sessions.json", - { - "agent:main:main": { sessionId: "s-main", updatedAt: 10 }, - }, - ], - [ - "/tmp/support/sessions.json", - { - main: { sessionId: "s-support", updatedAt: 20 }, - }, - ], - ]); - loadSessionStoreMock.mockImplementation((storePath: string) => { - return stores.get(storePath) ?? {}; - }); - updateSessionStoreMock.mockImplementation( - (_storePath: string, store: Record) => { - // Keep map in sync for resolveSessionEntry fallbacks if needed. - if (_storePath) { - stores.set(_storePath, store); - } - }, - ); + installScopedSessionStores(true); const tool = getSessionStatusTool("agent:support:main"); diff --git a/src/agents/pi-bundle-mcp-tools.test.ts b/src/agents/pi-bundle-mcp-tools.test.ts index 69b2839eb94..cb0f896a0ef 100644 --- a/src/agents/pi-bundle-mcp-tools.test.ts +++ b/src/agents/pi-bundle-mcp-tools.test.ts @@ -1,14 +1,10 @@ import fs from "node:fs/promises"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { writeBundleProbeMcpServer, writeClaudeBundle } from "./bundle-mcp.test-harness.js"; import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js"; -const require = createRequire(import.meta.url); -const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); -const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); - const tempDirs: string[] = []; async function makeTempDir(prefix: string): Promise { @@ -17,85 +13,35 @@ async function makeTempDir(prefix: string): Promise { return dir; } -async function writeExecutable(filePath: string, content: string): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); -} - -async function writeBundleProbeMcpServer(filePath: string): Promise { - await writeExecutable( - filePath, - `#!/usr/bin/env node -import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; -import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; - -const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); -server.tool("bundle_probe", "Bundle MCP probe", async () => { - return { - content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], - }; -}); - -await server.connect(new StdioServerTransport()); -`, - ); -} - -async function writeClaudeBundle(params: { - pluginRoot: string; - serverScriptPath: string; -}): Promise { - await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(params.pluginRoot, ".mcp.json"), - `${JSON.stringify( - { - mcpServers: { - bundleProbe: { - command: "node", - args: [path.relative(params.pluginRoot, params.serverScriptPath)], - env: { - BUNDLE_PROBE_TEXT: "FROM-BUNDLE", - }, - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); -} - afterEach(async () => { await Promise.all( tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), ); }); -describe("createBundleMcpToolRuntime", () => { - it("loads bundle MCP tools and executes them", async () => { - const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); - const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); - const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); - await writeBundleProbeMcpServer(serverScriptPath); - await writeClaudeBundle({ pluginRoot, serverScriptPath }); +async function createBundledRuntime(options?: { reservedToolNames?: string[] }) { + const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); + const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); + await writeBundleProbeMcpServer(serverScriptPath); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); - const runtime = await createBundleMcpToolRuntime({ - workspaceDir, - cfg: { - plugins: { - entries: { - "bundle-probe": { enabled: true }, - }, + return createBundleMcpToolRuntime({ + workspaceDir, + cfg: { + plugins: { + entries: { + "bundle-probe": { enabled: true }, }, }, - }); + }, + reservedToolNames: options?.reservedToolNames, + }); +} + +describe("createBundleMcpToolRuntime", () => { + it("loads bundle MCP tools and executes them", async () => { + const runtime = await createBundledRuntime(); try { expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]); @@ -114,23 +60,7 @@ describe("createBundleMcpToolRuntime", () => { }); it("skips bundle MCP tools that collide with existing tool names", async () => { - const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-"); - const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe"); - const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); - await writeBundleProbeMcpServer(serverScriptPath); - await writeClaudeBundle({ pluginRoot, serverScriptPath }); - - const runtime = await createBundleMcpToolRuntime({ - workspaceDir, - cfg: { - plugins: { - entries: { - "bundle-probe": { enabled: true }, - }, - }, - }, - reservedToolNames: ["bundle_probe"], - }); + const runtime = await createBundledRuntime({ reservedToolNames: ["bundle_probe"] }); try { expect(runtime.tools).toEqual([]); diff --git a/src/agents/pi-embedded-runner.e2e.test.ts b/src/agents/pi-embedded-runner.e2e.test.ts index f9fb3bd27e9..1dfc0b93384 100644 --- a/src/agents/pi-embedded-runner.e2e.test.ts +++ b/src/agents/pi-embedded-runner.e2e.test.ts @@ -1,35 +1,20 @@ import fs from "node:fs/promises"; import path from "node:path"; import "./test-helpers/fast-coding-tools.js"; -import type { AssistantMessage } from "@mariozechner/pi-ai"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; import { + buildEmbeddedRunnerAssistant, cleanupEmbeddedPiRunnerTestWorkspace, + createMockUsage, createEmbeddedPiRunnerOpenAiConfig, + createResolvedEmbeddedRunnerModel, createEmbeddedPiRunnerTestWorkspace, type EmbeddedPiRunnerTestWorkspace, immediateEnqueue, + makeEmbeddedRunnerAttempt, } from "./test-helpers/pi-embedded-runner-e2e-fixtures.js"; -const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise>(); - -function createMockUsage(input: number, output: number) { - return { - input, - output, - cacheRead: 0, - cacheWrite: 0, - totalTokens: input + output, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }; -} +const runEmbeddedAttemptMock = vi.fn(); vi.mock("@mariozechner/pi-ai", async (importOriginal) => { const actual = await importOriginal(); @@ -113,25 +98,8 @@ const installRunEmbeddedMocks = () => { const actual = await importOriginal(); return { ...actual, - resolveModelAsync: async (provider: string, modelId: string) => ({ - model: { - id: modelId, - name: modelId, - api: "openai-responses", - provider, - baseUrl: `https://example.com/${provider}`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 16_000, - maxTokens: 2048, - }, - error: undefined, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }), + resolveModelAsync: async (provider: string, modelId: string) => + createResolvedEmbeddedRunnerModel(provider, modelId), }; }); vi.doMock("../plugins/provider-runtime.js", async (importOriginal) => { @@ -188,46 +156,6 @@ const nextSessionFile = () => { const nextRunId = (prefix = "run-embedded-test") => `${prefix}-${++runCounter}`; const nextSessionKey = () => `agent:test:embedded:${nextRunId("session-key")}`; -const baseUsage = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -const buildAssistant = (overrides: Partial): AssistantMessage => ({ - role: "assistant", - content: [], - api: "openai-responses", - provider: "openai", - model: "mock-1", - usage: baseUsage, - stopReason: "stop", - timestamp: Date.now(), - ...overrides, -}); - -const makeAttempt = (overrides: Partial): EmbeddedRunAttemptResult => ({ - aborted: false, - timedOut: false, - timedOutDuringCompaction: false, - promptError: null, - sessionIdUsed: "session:test", - systemPromptReport: undefined, - messagesSnapshot: [], - assistantTexts: [], - toolMetas: [], - lastAssistant: undefined, - didSendViaMessagingTool: false, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], - messagingToolSentTargets: [], - cloudCodeAssistFormatError: false, - ...overrides, -}); - const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string) => { const sessionFile = nextSessionFile(); const sessionManager = SessionManager.open(sessionFile); @@ -238,9 +166,9 @@ const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string }); runEmbeddedAttemptMock.mockResolvedValueOnce( - makeAttempt({ + makeEmbeddedRunnerAttempt({ assistantTexts: ["ok"], - lastAssistant: buildAssistant({ + lastAssistant: buildEmbeddedRunnerAssistant({ content: [{ type: "text", text: "ok" }], }), }), @@ -293,9 +221,9 @@ const readSessionMessages = async (sessionFile: string) => { const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessionKey: string) => { const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-error"]); runEmbeddedAttemptMock.mockResolvedValueOnce( - makeAttempt({ + makeEmbeddedRunnerAttempt({ assistantTexts: ["ok"], - lastAssistant: buildAssistant({ + lastAssistant: buildEmbeddedRunnerAssistant({ content: [{ type: "text", text: "ok" }], }), }), @@ -322,7 +250,7 @@ describe("runEmbeddedPiAgent", () => { const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-error"]); const sessionKey = nextSessionKey(); runEmbeddedAttemptMock.mockResolvedValueOnce( - makeAttempt({ + makeEmbeddedRunnerAttempt({ promptError: new Error("boom"), }), ); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index d26dc75204d..83da60465fa 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -46,6 +46,8 @@ export { uploadDirectoryToSshTarget, } from "./sandbox/ssh.js"; export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; +export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js"; +export { resolveWritableRenameTargetsForBridge } from "./sandbox/fs-bridge-rename-targets.js"; export type { CreateSandboxBackendParams, diff --git a/src/agents/sandbox/fs-bridge-rename-targets.ts b/src/agents/sandbox/fs-bridge-rename-targets.ts new file mode 100644 index 00000000000..b2bd072a05b --- /dev/null +++ b/src/agents/sandbox/fs-bridge-rename-targets.ts @@ -0,0 +1,32 @@ +export function resolveWritableRenameTargets(params: { + from: string; + to: string; + cwd?: string; + action?: string; + resolveTarget: (params: { filePath: string; cwd?: string }) => T; + ensureWritable: (target: T, action: string) => void; +}): { from: T; to: T } { + const action = params.action ?? "rename files"; + const from = params.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = params.resolveTarget({ filePath: params.to, cwd: params.cwd }); + params.ensureWritable(from, action); + params.ensureWritable(to, action); + return { from, to }; +} + +export function resolveWritableRenameTargetsForBridge( + params: { + from: string; + to: string; + cwd?: string; + action?: string; + }, + resolveTarget: (params: { filePath: string; cwd?: string }) => T, + ensureWritable: (target: T, action: string) => void, +): { from: T; to: T } { + return resolveWritableRenameTargets({ + ...params, + resolveTarget, + ensureWritable, + }); +} diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts index 878bdacc3c3..868d76f2ca0 100644 --- a/src/agents/sandbox/remote-fs-bridge.ts +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { isPathInside } from "../../infra/path-guards.js"; import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js"; import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; +import { resolveWritableRenameTargetsForBridge } from "./fs-bridge-rename-targets.js"; import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js"; import { isPathInsideContainerRoot, @@ -40,6 +41,14 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { private readonly runtime: RemoteShellSandboxHandle, ) {} + private resolveRenameTargets(params: { from: string; to: string; cwd?: string }) { + return resolveWritableRenameTargetsForBridge( + params, + (target) => this.resolveTarget(target), + (target, action) => this.ensureWritable(target, action), + ); + } + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { const target = this.resolveTarget(params); return { @@ -165,10 +174,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { cwd?: string; signal?: AbortSignal; }): Promise { - const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); - const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); - this.ensureWritable(from, "rename files"); - this.ensureWritable(to, "rename files"); + const { from, to } = this.resolveRenameTargets(params); const fromPinned = await this.resolvePinnedParent({ containerPath: from.containerPath, action: "rename files", diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 8d5560d408a..172072b832b 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -31,47 +31,59 @@ let fallbackRequesterResolution: { } | null = null; let chatHistoryMessages: Array> = []; -vi.mock("../gateway/call.js", () => ({ - callGateway: vi.fn(async (request: GatewayCall) => { - gatewayCalls.push(request); - if (request.method === "chat.history") { - return { messages: chatHistoryMessages }; - } - return await callGatewayImpl(request); - }), -})); +function createGatewayCallModuleMock() { + return { + callGateway: vi.fn(async (request: GatewayCall) => { + gatewayCalls.push(request); + if (request.method === "chat.history") { + return { messages: chatHistoryMessages }; + } + return await callGatewayImpl(request); + }), + }; +} -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +async function createConfigModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, loadConfig: () => configOverride, }; -}); +} -vi.mock("../config/sessions.js", () => ({ - loadSessionStore: vi.fn(() => sessionStore), - resolveAgentIdFromSessionKey: () => "main", - resolveStorePath: () => "/tmp/sessions-main.json", - resolveMainSessionKey: () => "agent:main:main", -})); +function createSessionsModuleMock() { + return { + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: () => "main", + resolveStorePath: () => "/tmp/sessions-main.json", + resolveMainSessionKey: () => "agent:main:main", + }; +} -vi.mock("./subagent-depth.js", () => ({ - getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey), -})); +function createSubagentDepthModuleMock() { + return { + getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey), + }; +} -vi.mock("./pi-embedded.js", async (importOriginal) => { - const actual = await importOriginal(); +async function createPiEmbeddedModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, isEmbeddedPiRunActive: () => false, queueEmbeddedPiMessage: () => false, waitForEmbeddedPiRunEnd: async () => true, }; -}); +} -vi.mock("./subagent-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +async function createSubagentRegistryModuleMock( + importOriginal: () => Promise, +) { + const actual = await importOriginal(); return { ...actual, countActiveDescendantRuns: () => 0, @@ -81,7 +93,32 @@ vi.mock("./subagent-registry.js", async (importOriginal) => { shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion, resolveRequesterForChildSession: () => fallbackRequesterResolution, }; -}); +} + +function createTimeoutHistoryWithNoReply() { + return [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [ + { type: "text", text: "Still working through the files." }, + { type: "toolCall", id: "call1", name: "read", arguments: {} }, + ], + }, + { role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] }, + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + }, + ]; +} + +vi.mock("../gateway/call.js", createGatewayCallModuleMock); +vi.mock("../config/config.js", createConfigModuleMock); +vi.mock("../config/sessions.js", createSessionsModuleMock); +vi.mock("./subagent-depth.js", createSubagentDepthModuleMock); +vi.mock("./pi-embedded.js", createPiEmbeddedModuleMock); +vi.mock("./subagent-registry.js", createSubagentRegistryModuleMock); let runSubagentAnnounceFlow: typeof import("./subagent-announce.js").runSubagentAnnounceFlow; type AnnounceFlowParams = Parameters< @@ -90,52 +127,12 @@ type AnnounceFlowParams = Parameters< async function loadFreshSubagentAnnounceFlowForTest() { vi.resetModules(); - vi.doMock("../gateway/call.js", () => ({ - callGateway: vi.fn(async (request: GatewayCall) => { - gatewayCalls.push(request); - if (request.method === "chat.history") { - return { messages: chatHistoryMessages }; - } - return await callGatewayImpl(request); - }), - })); - vi.doMock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => configOverride, - }; - }); - vi.doMock("../config/sessions.js", () => ({ - loadSessionStore: vi.fn(() => sessionStore), - resolveAgentIdFromSessionKey: () => "main", - resolveStorePath: () => "/tmp/sessions-main.json", - resolveMainSessionKey: () => "agent:main:main", - })); - vi.doMock("./subagent-depth.js", () => ({ - getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey), - })); - vi.doMock("./pi-embedded.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isEmbeddedPiRunActive: () => false, - queueEmbeddedPiMessage: () => false, - waitForEmbeddedPiRunEnd: async () => true, - }; - }); - vi.doMock("./subagent-registry.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - countActiveDescendantRuns: () => 0, - countPendingDescendantRuns: () => pendingDescendantRuns, - listSubagentRunsForRequester: () => [], - isSubagentSessionRunActive: () => subagentSessionRunActive, - shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion, - resolveRequesterForChildSession: () => fallbackRequesterResolution, - }; - }); + vi.doMock("../gateway/call.js", createGatewayCallModuleMock); + vi.doMock("../config/config.js", createConfigModuleMock); + vi.doMock("../config/sessions.js", createSessionsModuleMock); + vi.doMock("./subagent-depth.js", createSubagentDepthModuleMock); + vi.doMock("./pi-embedded.js", createPiEmbeddedModuleMock); + vi.doMock("./subagent-registry.js", createSubagentRegistryModuleMock); ({ runSubagentAnnounceFlow } = await import("./subagent-announce.js")); } @@ -453,19 +450,7 @@ describe("subagent announce timeout config", () => { it("preserves NO_REPLY when timeout partial-progress history mixes prior text and later silence", async () => { chatHistoryMessages = [ - { role: "user", content: "do something" }, - { - role: "assistant", - content: [ - { type: "text", text: "Still working through the files." }, - { type: "toolCall", id: "call1", name: "read", arguments: {} }, - ], - }, - { role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] }, - { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - }, + ...createTimeoutHistoryWithNoReply(), { role: "assistant", content: [{ type: "toolCall", id: "call2", name: "exec", arguments: {} }], @@ -484,19 +469,7 @@ describe("subagent announce timeout config", () => { it("prefers later visible assistant progress over an earlier NO_REPLY marker", async () => { chatHistoryMessages = [ - { role: "user", content: "do something" }, - { - role: "assistant", - content: [ - { type: "text", text: "Still working through the files." }, - { type: "toolCall", id: "call1", name: "read", arguments: {} }, - ], - }, - { role: "toolResult", toolCallId: "call1", content: [{ type: "text", text: "data" }] }, - { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], - }, + ...createTimeoutHistoryWithNoReply(), { role: "assistant", content: [{ type: "text", text: "A longer partial summary that should stay silent." }], diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index b3682c59b4c..3c2ee076c34 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -8,39 +8,71 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; * forever via the max-retry and expiration guards. */ -vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ - session: { store: "/tmp/test-store", mainKey: "main" }, - agents: {}, - }), -})); +function createLoopGuardConfigModuleMock() { + return { + loadConfig: () => ({ + session: { store: "/tmp/test-store", mainKey: "main" }, + agents: {}, + }), + }; +} -vi.mock("../config/sessions.js", () => ({ - loadSessionStore: () => ({ - "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, - "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, - "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, - }), - resolveAgentIdFromSessionKey: (key: string) => { - const match = key.match(/^agent:([^:]+)/); - return match?.[1] ?? "main"; - }, - resolveMainSessionKey: () => "agent:main:main", - resolveStorePath: () => "/tmp/test-store", - updateSessionStore: vi.fn(), -})); +function createLoopGuardSessionsModuleMock() { + return { + loadSessionStore: () => ({ + "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, + "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, + "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, + }), + resolveAgentIdFromSessionKey: (key: string) => { + const match = key.match(/^agent:([^:]+)/); + return match?.[1] ?? "main"; + }, + resolveMainSessionKey: () => "agent:main:main", + resolveStorePath: () => "/tmp/test-store", + updateSessionStore: vi.fn(), + }; +} -vi.mock("../gateway/call.js", () => ({ - callGateway: vi.fn().mockResolvedValue({ status: "ok" }), -})); +function createLoopGuardGatewayCallModuleMock() { + return { + callGateway: vi.fn().mockResolvedValue({ status: "ok" }), + }; +} -vi.mock("../infra/agent-events.js", () => ({ - onAgentEvent: vi.fn().mockReturnValue(() => {}), -})); +function createLoopGuardAgentEventsModuleMock() { + return { + onAgentEvent: vi.fn().mockReturnValue(() => {}), + }; +} -vi.mock("./subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false), -})); +function createLoopGuardSubagentAnnounceModuleMock() { + return { + runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false), + }; +} + +function createLoopGuardAnnounceQueueModuleMock() { + return { + resetAnnounceQueuesForTests: vi.fn(), + }; +} + +function createLoopGuardTimeoutModuleMock() { + return { + resolveAgentTimeoutMs: () => 60_000, + }; +} + +vi.mock("../config/config.js", createLoopGuardConfigModuleMock); + +vi.mock("../config/sessions.js", createLoopGuardSessionsModuleMock); + +vi.mock("../gateway/call.js", createLoopGuardGatewayCallModuleMock); + +vi.mock("../infra/agent-events.js", createLoopGuardAgentEventsModuleMock); + +vi.mock("./subagent-announce.js", createLoopGuardSubagentAnnounceModuleMock); const loadSubagentRegistryFromDisk = vi.fn(() => new Map()); const saveSubagentRegistryToDisk = vi.fn(); @@ -50,13 +82,9 @@ vi.mock("./subagent-registry.store.js", () => ({ saveSubagentRegistryToDisk, })); -vi.mock("./subagent-announce-queue.js", () => ({ - resetAnnounceQueuesForTests: vi.fn(), -})); +vi.mock("./subagent-announce-queue.js", createLoopGuardAnnounceQueueModuleMock); -vi.mock("./timeout.js", () => ({ - resolveAgentTimeoutMs: () => 60_000, -})); +vi.mock("./timeout.js", createLoopGuardTimeoutModuleMock); describe("announce loop guard (#18264)", () => { let registry: typeof import("./subagent-registry.js"); @@ -64,45 +92,17 @@ describe("announce loop guard (#18264)", () => { async function loadFreshSubagentRegistryLoopGuardModulesForTest() { vi.resetModules(); - vi.doMock("../config/config.js", () => ({ - loadConfig: () => ({ - session: { store: "/tmp/test-store", mainKey: "main" }, - agents: {}, - }), - })); - vi.doMock("../config/sessions.js", () => ({ - loadSessionStore: () => ({ - "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, - "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, - "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, - }), - resolveAgentIdFromSessionKey: (key: string) => { - const match = key.match(/^agent:([^:]+)/); - return match?.[1] ?? "main"; - }, - resolveMainSessionKey: () => "agent:main:main", - resolveStorePath: () => "/tmp/test-store", - updateSessionStore: vi.fn(), - })); - vi.doMock("../gateway/call.js", () => ({ - callGateway: vi.fn().mockResolvedValue({ status: "ok" }), - })); - vi.doMock("../infra/agent-events.js", () => ({ - onAgentEvent: vi.fn().mockReturnValue(() => {}), - })); - vi.doMock("./subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false), - })); + vi.doMock("../config/config.js", createLoopGuardConfigModuleMock); + vi.doMock("../config/sessions.js", createLoopGuardSessionsModuleMock); + vi.doMock("../gateway/call.js", createLoopGuardGatewayCallModuleMock); + vi.doMock("../infra/agent-events.js", createLoopGuardAgentEventsModuleMock); + vi.doMock("./subagent-announce.js", createLoopGuardSubagentAnnounceModuleMock); vi.doMock("./subagent-registry.store.js", () => ({ loadSubagentRegistryFromDisk, saveSubagentRegistryToDisk, })); - vi.doMock("./subagent-announce-queue.js", () => ({ - resetAnnounceQueuesForTests: vi.fn(), - })); - vi.doMock("./timeout.js", () => ({ - resolveAgentTimeoutMs: () => 60_000, - })); + vi.doMock("./subagent-announce-queue.js", createLoopGuardAnnounceQueueModuleMock); + vi.doMock("./timeout.js", createLoopGuardTimeoutModuleMock); registry = await import("./subagent-registry.js"); const subagentAnnounce = await import("./subagent-announce.js"); announceFn = vi.mocked(subagentAnnounce.runSubagentAnnounceFlow); diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 84405c301ac..c6c039a94ad 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -45,6 +45,18 @@ export function setupAcceptedSubagentGatewayMock(callGatewayMock: MockImplementa }); } +export function identityDeliveryContext(value: unknown) { + return value; +} + +export function createDefaultSessionHelperMocks() { + return { + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + }; +} + export async function loadSubagentSpawnModuleForTest(params: { callGatewayMock: MockFn; loadConfig?: () => Record; @@ -127,6 +139,12 @@ export async function loadSubagentSpawnModuleForTest(params: { getGlobalHookRunner: () => ({ hasHooks: () => false }), })); + vi.doMock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: identityDeliveryContext, + })); + + vi.doMock("./tools/sessions-helpers.js", () => createDefaultSessionHelperMocks()); + const { resetSubagentRegistryForTests } = await import("./subagent-registry.js"); return { ...(await import("./subagent-spawn.js")), diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 14652970eeb..2f9a2874dcd 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -1,5 +1,9 @@ import os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDefaultSessionHelperMocks, + identityDeliveryContext, +} from "./subagent-spawn.test-helpers.js"; import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js"; const hoisted = vi.hoisted(() => ({ @@ -79,14 +83,10 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })); vi.mock("../utils/delivery-context.js", () => ({ - normalizeDeliveryContext: (value: unknown) => value, + normalizeDeliveryContext: identityDeliveryContext, })); -vi.mock("./tools/sessions-helpers.js", () => ({ - resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), - resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", - resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", -})); +vi.mock("./tools/sessions-helpers.js", () => createDefaultSessionHelperMocks()); vi.mock("./agent-scope.js", () => ({ resolveAgentConfig: () => undefined, diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 2cfe77c973d..7eb356a29a7 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDefaultSessionHelperMocks, + identityDeliveryContext, +} from "./subagent-spawn.test-helpers.js"; import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js"; type TestAgentConfig = { @@ -76,14 +80,10 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })); vi.mock("../utils/delivery-context.js", () => ({ - normalizeDeliveryContext: (value: unknown) => value, + normalizeDeliveryContext: identityDeliveryContext, })); -vi.mock("./tools/sessions-helpers.js", () => ({ - resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), - resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", - resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", -})); +vi.mock("./tools/sessions-helpers.js", () => createDefaultSessionHelperMocks()); vi.mock("./agent-scope.js", () => ({ resolveAgentConfig: (cfg: TestConfig, agentId: string) => @@ -151,13 +151,9 @@ async function loadFreshSubagentSpawnWorkspaceModuleForTest() { getGlobalHookRunner: () => hoisted.hookRunner, })); vi.doMock("../utils/delivery-context.js", () => ({ - normalizeDeliveryContext: (value: unknown) => value, - })); - vi.doMock("./tools/sessions-helpers.js", () => ({ - resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), - resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", - resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + normalizeDeliveryContext: identityDeliveryContext, })); + vi.doMock("./tools/sessions-helpers.js", () => createDefaultSessionHelperMocks()); vi.doMock("./agent-scope.js", () => ({ resolveAgentConfig: (cfg: TestConfig, agentId: string) => cfg.agents?.list?.find((entry) => entry.id === agentId), diff --git a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts b/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts index 1d987c44d1a..ac6fec7691e 100644 --- a/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts +++ b/src/agents/test-helpers/pi-embedded-runner-e2e-fixtures.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; +import type { EmbeddedRunAttemptResult } from "../pi-embedded-runner/run/types.js"; export type EmbeddedPiRunnerTestWorkspace = { tempRoot: string; @@ -55,3 +57,87 @@ export function createEmbeddedPiRunnerOpenAiConfig(modelIds: string[]): OpenClaw export async function immediateEnqueue(task: () => Promise): Promise { return await task(); } + +export function createMockUsage(input: number, output: number) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; +} + +const baseUsage = createMockUsage(0, 0); + +export function buildEmbeddedRunnerAssistant( + overrides: Partial, +): AssistantMessage { + return { + role: "assistant", + content: [], + api: "openai-responses", + provider: "openai", + model: "mock-1", + usage: baseUsage, + stopReason: "stop", + timestamp: Date.now(), + ...overrides, + }; +} + +export function makeEmbeddedRunnerAttempt( + overrides: Partial, +): EmbeddedRunAttemptResult { + return { + aborted: false, + timedOut: false, + timedOutDuringCompaction: false, + promptError: null, + sessionIdUsed: "session:test", + systemPromptReport: undefined, + messagesSnapshot: [], + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + cloudCodeAssistFormatError: false, + ...overrides, + }; +} + +export function createResolvedEmbeddedRunnerModel( + provider: string, + modelId: string, + options?: { baseUrl?: string }, +) { + return { + model: { + id: modelId, + name: modelId, + api: "openai-responses", + provider, + baseUrl: options?.baseUrl ?? `https://example.com/${provider}`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + error: undefined, + authStorage: { + setRuntimeApiKey: () => undefined, + }, + modelRegistry: {}, + }; +} diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 79827ef7cb8..8b8ce68c0a5 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -206,6 +206,50 @@ export function normalizeToIsoDate(value: string): string | undefined { return undefined; } +export function parseIsoDateRange(params: { + rawDateAfter?: string; + rawDateBefore?: string; + invalidDateAfterMessage: string; + invalidDateBeforeMessage: string; + invalidDateRangeMessage: string; + docs?: string; +}): + | { dateAfter?: string; dateBefore?: string } + | { + error: "invalid_date" | "invalid_date_range"; + message: string; + docs: string; + } { + const docs = params.docs ?? "https://docs.openclaw.ai/tools/web"; + const dateAfter = params.rawDateAfter ? normalizeToIsoDate(params.rawDateAfter) : undefined; + if (params.rawDateAfter && !dateAfter) { + return { + error: "invalid_date", + message: params.invalidDateAfterMessage, + docs, + }; + } + + const dateBefore = params.rawDateBefore ? normalizeToIsoDate(params.rawDateBefore) : undefined; + if (params.rawDateBefore && !dateBefore) { + return { + error: "invalid_date", + message: params.invalidDateBeforeMessage, + docs, + }; + } + + if (dateAfter && dateBefore && dateAfter > dateBefore) { + return { + error: "invalid_date_range", + message: params.invalidDateRangeMessage, + docs, + }; + } + + return { dateAfter, dateBefore }; +} + export function normalizeFreshness( value: string | undefined, provider: "brave" | "perplexity", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 7a326f4d564..dc173e5c8ba 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,3 +1,8 @@ +import { + buildFeishuConversationId, + parseFeishuDirectConversationId, + parseFeishuTargetId, +} from "../../../../extensions/feishu/api.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -15,80 +20,6 @@ import { } from "../matrix-context.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; -type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; - -function buildFeishuConversationId(params: { - chatId: string; - scope: FeishuGroupSessionScope; - senderOpenId?: string; - topicId?: string; -}): string { - const chatId = normalizeConversationText(params.chatId) ?? "unknown"; - const senderOpenId = normalizeConversationText(params.senderOpenId); - const topicId = normalizeConversationText(params.topicId); - - switch (params.scope) { - case "group_sender": - return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; - case "group_topic": - return topicId ? `${chatId}:topic:${topicId}` : chatId; - case "group_topic_sender": - if (topicId && senderOpenId) { - return `${chatId}:topic:${topicId}:sender:${senderOpenId}`; - } - if (topicId) { - return `${chatId}:topic:${topicId}`; - } - return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; - case "group": - default: - return chatId; - } -} - -function parseFeishuTargetId(raw: unknown): string | undefined { - const target = normalizeConversationText(raw); - if (!target) { - return undefined; - } - const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim(); - if (!withoutProvider) { - return undefined; - } - const lowered = withoutProvider.toLowerCase(); - for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) { - if (lowered.startsWith(prefix)) { - return normalizeConversationText(withoutProvider.slice(prefix.length)); - } - } - return withoutProvider; -} - -function parseFeishuDirectConversationId(raw: unknown): string | undefined { - const target = normalizeConversationText(raw); - if (!target) { - return undefined; - } - const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim(); - if (!withoutProvider) { - return undefined; - } - const lowered = withoutProvider.toLowerCase(); - for (const prefix of ["user:", "dm:", "open_id:"]) { - if (lowered.startsWith(prefix)) { - return normalizeConversationText(withoutProvider.slice(prefix.length)); - } - } - const id = parseFeishuTargetId(target); - if (!id) { - return undefined; - } - if (id.startsWith("ou_") || id.startsWith("on_")) { - return id; - } - return undefined; -} - function resolveFeishuSenderScopedConversationId(params: { accountId: string; parentConversationId?: string; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 39950e05758..ccb00be71ee 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -109,6 +109,58 @@ function getPseudoPort(base: number): number { const runtime = createThrowingRuntime(); +function createJsonCaptureRuntime() { + let capturedJson = ""; + const runtimeWithCapture: RuntimeEnv = { + log: (...args: unknown[]) => { + const firstArg = args[0]; + capturedJson = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + }, + error: (...args: unknown[]) => { + const firstArg = args[0]; + const capturedError = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + throw new Error(capturedError); + }, + exit: (_code: number) => { + throw new Error("exit should not be reached after runtime.error"); + }, + }; + + return { + runtimeWithCapture, + readCapturedJson: () => capturedJson, + }; +} + +async function expectLocalJsonSetupFailure(stateDir: string, runtimeWithCapture: RuntimeEnv) { + await expect( + runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + json: true, + }, + runtimeWithCapture, + ), + ).rejects.toThrow("exit should not be reached after runtime.error"); +} + describe("onboard (non-interactive): gateway and remote auth", () => { let envSnapshot: ReturnType; let tempHome: string | undefined; @@ -427,31 +479,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { skippedReason: "systemd-user-unavailable", }); - let capturedJson = ""; - const runtimeWithCapture: RuntimeEnv = { - log: (...args: unknown[]) => { - const firstArg = args[0]; - capturedJson = - typeof firstArg === "string" - ? firstArg - : firstArg instanceof Error - ? firstArg.message - : (JSON.stringify(firstArg) ?? ""); - }, - error: (...args: unknown[]) => { - const firstArg = args[0]; - const capturedError = - typeof firstArg === "string" - ? firstArg - : firstArg instanceof Error - ? firstArg.message - : (JSON.stringify(firstArg) ?? ""); - throw new Error(capturedError); - }, - exit: (_code: number) => { - throw new Error("exit should not be reached after runtime.error"); - }, - }; + const { runtimeWithCapture, readCapturedJson } = createJsonCaptureRuntime(); const originalPlatform = process.platform; Object.defineProperty(process, "platform", { @@ -460,22 +488,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); try { - await expect( - runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace: path.join(stateDir, "openclaw"), - authChoice: "skip", - skipSkills: true, - skipHealth: false, - installDaemon: true, - gatewayBind: "loopback", - json: true, - }, - runtimeWithCapture, - ), - ).rejects.toThrow("exit should not be reached after runtime.error"); + await expectLocalJsonSetupFailure(stateDir, runtimeWithCapture); } finally { Object.defineProperty(process, "platform", { configurable: true, @@ -483,7 +496,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); } - const parsed = JSON.parse(capturedJson) as { + const parsed = JSON.parse(readCapturedJson()) as { ok: boolean; phase: string; daemonInstall?: { @@ -513,50 +526,10 @@ describe("onboard (non-interactive): gateway and remote auth", () => { detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", })); - let capturedJson = ""; - const runtimeWithCapture: RuntimeEnv = { - log: (...args: unknown[]) => { - const firstArg = args[0]; - capturedJson = - typeof firstArg === "string" - ? firstArg - : firstArg instanceof Error - ? firstArg.message - : (JSON.stringify(firstArg) ?? ""); - }, - error: (...args: unknown[]) => { - const firstArg = args[0]; - const capturedError = - typeof firstArg === "string" - ? firstArg - : firstArg instanceof Error - ? firstArg.message - : (JSON.stringify(firstArg) ?? ""); - throw new Error(capturedError); - }, - exit: (_code: number) => { - throw new Error("exit should not be reached after runtime.error"); - }, - }; + const { runtimeWithCapture, readCapturedJson } = createJsonCaptureRuntime(); + await expectLocalJsonSetupFailure(stateDir, runtimeWithCapture); - await expect( - runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace: path.join(stateDir, "openclaw"), - authChoice: "skip", - skipSkills: true, - skipHealth: false, - installDaemon: true, - gatewayBind: "loopback", - json: true, - }, - runtimeWithCapture, - ), - ).rejects.toThrow("exit should not be reached after runtime.error"); - - const parsed = JSON.parse(capturedJson) as { + const parsed = JSON.parse(readCapturedJson()) as { ok: boolean; phase: string; installDaemon: boolean; diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 359cce41612..f9316e0870f 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -1,43 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { loggingState } from "../logging/state.js"; import { applyStatusScanDefaults, - createStatusGatewayCallModuleMock, - createStatusGatewayProbeModuleMock, createStatusMemorySearchConfig, createStatusMemorySearchManager, - createStatusOsSummaryModuleMock, - createStatusPluginRegistryModuleMock, - createStatusPluginStatusModuleMock, - createStatusScanDepsRuntimeModuleMock, + createStatusScanSharedMocks, createStatusSummary, + loadStatusScanModuleForTest, withTemporaryEnv, } from "./status.scan.test-helpers.js"; -const mocks = vi.hoisted(() => ({ - resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-fast-json-missing-${process.pid}.json`), - hasPotentialConfiguredChannels: vi.fn(), - readBestEffortConfig: vi.fn(), - resolveCommandSecretRefsViaGateway: vi.fn(), - getUpdateCheckResult: vi.fn(), - getAgentLocalStatuses: vi.fn(), - getStatusSummary: vi.fn(), - getMemorySearchManager: vi.fn(), - buildGatewayConnectionDetails: vi.fn(), - probeGateway: vi.fn(), - resolveGatewayProbeAuthResolution: vi.fn(), - ensurePluginRegistryLoaded: vi.fn(), - buildPluginCompatibilityNotices: vi.fn(() => []), +const mocks = { + ...createStatusScanSharedMocks("status-fast-json"), getStatusCommandSecretTargetIds: vi.fn(() => []), resolveMemorySearchConfig: vi.fn(), -})); +}; let originalForceStderr: boolean; +let loggingStateRef: typeof import("../logging/state.js").loggingState; +let scanStatusJsonFast: typeof import("./status.scan.fast-json.js").scanStatusJsonFast; -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); - originalForceStderr = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = false; applyStatusScanDefaults(mocks, { sourceConfig: createStatusMemorySearchConfig(), resolvedConfig: createStatusMemorySearchConfig(), @@ -48,61 +31,14 @@ beforeEach(() => { mocks.resolveMemorySearchConfig.mockReturnValue({ store: { path: "/tmp/main.sqlite" }, }); + ({ scanStatusJsonFast } = await loadStatusScanModuleForTest(mocks, { fastJson: true })); + ({ loggingState: loggingStateRef } = await import("../logging/state.js")); + originalForceStderr = loggingStateRef.forceConsoleToStderr; + loggingStateRef.forceConsoleToStderr = false; }); -vi.mock("../channels/config-presence.js", () => ({ - hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, -})); - -vi.mock("../config/io.js", () => ({ - readBestEffortConfig: mocks.readBestEffortConfig, -})); - -vi.mock("../config/paths.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveConfigPath: mocks.resolveConfigPath, - }; -}); - -vi.mock("../cli/command-secret-gateway.js", () => ({ - resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, -})); - -vi.mock("../cli/command-secret-targets.js", () => ({ - getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, -})); - -vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult })); -vi.mock("./status.agent-local.js", () => ({ getAgentLocalStatuses: mocks.getAgentLocalStatuses })); -vi.mock("./status.summary.js", () => ({ getStatusSummary: mocks.getStatusSummary })); -vi.mock("../infra/os-summary.js", () => createStatusOsSummaryModuleMock()); -vi.mock("./status.scan.deps.runtime.js", () => createStatusScanDepsRuntimeModuleMock(mocks)); - -vi.mock("../agents/memory-search.js", () => ({ - resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, -})); - -vi.mock("../gateway/call.js", () => createStatusGatewayCallModuleMock(mocks)); - -vi.mock("../gateway/probe.js", () => ({ - probeGateway: mocks.probeGateway, -})); - -vi.mock("./status.gateway-probe.js", () => createStatusGatewayProbeModuleMock(mocks)); - -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(), -})); - -vi.mock("../cli/plugin-registry.js", () => createStatusPluginRegistryModuleMock(mocks)); -vi.mock("../plugins/status.js", () => createStatusPluginStatusModuleMock(mocks)); - -const { scanStatusJsonFast } = await import("./status.scan.fast-json.js"); - afterEach(() => { - loggingState.forceConsoleToStderr = originalForceStderr; + loggingStateRef.forceConsoleToStderr = originalForceStderr; }); describe("scanStatusJsonFast", () => { @@ -111,14 +47,14 @@ describe("scanStatusJsonFast", () => { let stderrDuringLoad = false; mocks.ensurePluginRegistryLoaded.mockImplementation(() => { - stderrDuringLoad = loggingState.forceConsoleToStderr; + stderrDuringLoad = loggingStateRef.forceConsoleToStderr; }); await scanStatusJsonFast({}, {} as never); expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalled(); expect(stderrDuringLoad).toBe(true); - expect(loggingState.forceConsoleToStderr).toBe(false); + expect(loggingStateRef.forceConsoleToStderr).toBe(false); }); it("skips plugin compatibility loading even when configured channels are present", async () => { diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index 113c317cf70..48639c783d4 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -72,6 +72,126 @@ export function createStatusPluginStatusModuleMock( }; } +export function createStatusUpdateModuleMock( + mocks: Pick, +) { + return { + getUpdateCheckResult: mocks.getUpdateCheckResult, + }; +} + +export function createStatusAgentLocalModuleMock( + mocks: Pick, +) { + return { + getAgentLocalStatuses: mocks.getAgentLocalStatuses, + }; +} + +export function createStatusSummaryModuleMock( + mocks: Pick, +) { + return { + getStatusSummary: mocks.getStatusSummary, + }; +} + +export function createStatusExecModuleMock() { + return { + runExec: vi.fn(), + }; +} + +type StatusScanModuleTestMocks = StatusScanSharedMocks & { + buildChannelsTable?: ReturnType; + callGateway?: ReturnType; + getStatusCommandSecretTargetIds?: ReturnType; + resolveMemorySearchConfig?: ReturnType; +}; + +export async function loadStatusScanModuleForTest( + mocks: StatusScanModuleTestMocks, + options: { + fastJson: true; + }, +): Promise; +export async function loadStatusScanModuleForTest( + mocks: StatusScanModuleTestMocks, + options?: { + fastJson?: false; + }, +): Promise; +export async function loadStatusScanModuleForTest( + mocks: StatusScanModuleTestMocks, + options: { + fastJson?: boolean; + } = {}, +) { + vi.resetModules(); + + vi.doMock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, + })); + + if (options.fastJson) { + vi.doMock("../config/io.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, + })); + vi.doMock("../cli/command-secret-targets.js", () => ({ + getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds, + })); + vi.doMock("../agents/memory-search.js", () => ({ + resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, + })); + } else { + vi.doMock("../cli/progress.js", () => ({ + withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })), + })); + vi.doMock("../config/config.js", () => ({ + readBestEffortConfig: mocks.readBestEffortConfig, + })); + vi.doMock("./status-all/channels.js", () => ({ + buildChannelsTable: mocks.buildChannelsTable, + })); + vi.doMock("./status.scan.runtime.js", () => ({ + statusScanRuntime: { + buildChannelsTable: mocks.buildChannelsTable, + collectChannelStatusIssues: vi.fn(() => []), + }, + })); + } + + vi.doMock("../config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveConfigPath: mocks.resolveConfigPath, + }; + }); + + vi.doMock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, + })); + vi.doMock("./status.update.js", () => createStatusUpdateModuleMock(mocks)); + vi.doMock("./status.agent-local.js", () => createStatusAgentLocalModuleMock(mocks)); + vi.doMock("./status.summary.js", () => createStatusSummaryModuleMock(mocks)); + vi.doMock("../infra/os-summary.js", () => createStatusOsSummaryModuleMock()); + vi.doMock("./status.scan.deps.runtime.js", () => createStatusScanDepsRuntimeModuleMock(mocks)); + vi.doMock("../gateway/call.js", () => createStatusGatewayCallModuleMock(mocks)); + vi.doMock("../gateway/probe.js", () => ({ + probeGateway: mocks.probeGateway, + })); + vi.doMock("./status.gateway-probe.js", () => createStatusGatewayProbeModuleMock(mocks)); + vi.doMock("../process/exec.js", () => createStatusExecModuleMock()); + vi.doMock("../cli/plugin-registry.js", () => createStatusPluginRegistryModuleMock(mocks)); + vi.doMock("../plugins/status.js", () => createStatusPluginStatusModuleMock(mocks)); + + if (options.fastJson) { + return await import("./status.scan.fast-json.js"); + } + return await import("./status.scan.js"); +} + export function createStatusScanConfig( overrides: T = {} as T, ): OpenClawConfig & T { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 382128126e4..4affcd2b6a1 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -1,108 +1,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { loggingState } from "../logging/state.js"; import { applyStatusScanDefaults, - createStatusGatewayCallModuleMock, - createStatusGatewayProbeModuleMock, createStatusMemorySearchConfig, createStatusMemorySearchManager, + createStatusScanSharedMocks, createStatusScanConfig, - createStatusScanDepsRuntimeModuleMock, - createStatusOsSummaryModuleMock, - createStatusPluginRegistryModuleMock, - createStatusPluginStatusModuleMock, createStatusSummary, + loadStatusScanModuleForTest, withTemporaryEnv, } from "./status.scan.test-helpers.js"; -const mocks = vi.hoisted(() => ({ - resolveConfigPath: vi.fn(() => `/tmp/openclaw-status-scan-missing-${process.pid}.json`), - hasPotentialConfiguredChannels: vi.fn(), - readBestEffortConfig: vi.fn(), - resolveCommandSecretRefsViaGateway: vi.fn(), - getUpdateCheckResult: vi.fn(), - getAgentLocalStatuses: vi.fn(), - getStatusSummary: vi.fn(), - getMemorySearchManager: vi.fn(), - buildGatewayConnectionDetails: vi.fn(), - probeGateway: vi.fn(), - resolveGatewayProbeAuthResolution: vi.fn(), - ensurePluginRegistryLoaded: vi.fn(), - buildPluginCompatibilityNotices: vi.fn(() => []), +const mocks = { + ...createStatusScanSharedMocks("status-scan"), buildChannelsTable: vi.fn(), callGateway: vi.fn(), -})); +}; let originalForceStderr: boolean; +let loggingStateRef: typeof import("../logging/state.js").loggingState; +let scanStatus: typeof import("./status.scan.js").scanStatus; -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); - originalForceStderr = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = false; configureScanStatus(); + ({ scanStatus } = await loadStatusScanModuleForTest(mocks)); + ({ loggingState: loggingStateRef } = await import("../logging/state.js")); + originalForceStderr = loggingStateRef.forceConsoleToStderr; + loggingStateRef.forceConsoleToStderr = false; }); afterEach(() => { - loggingState.forceConsoleToStderr = originalForceStderr; + loggingStateRef.forceConsoleToStderr = originalForceStderr; }); -vi.mock("../channels/config-presence.js", () => ({ - hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, -})); - -vi.mock("../cli/progress.js", () => ({ - withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })), -})); - -vi.mock("../config/config.js", () => ({ - readBestEffortConfig: mocks.readBestEffortConfig, -})); - -vi.mock("../config/paths.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveConfigPath: mocks.resolveConfigPath, - }; -}); - -vi.mock("../cli/command-secret-gateway.js", () => ({ - resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, -})); - -vi.mock("./status-all/channels.js", () => ({ - buildChannelsTable: mocks.buildChannelsTable, -})); - -vi.mock("./status.scan.runtime.js", () => ({ - statusScanRuntime: { - buildChannelsTable: mocks.buildChannelsTable, - collectChannelStatusIssues: vi.fn(() => []), - }, -})); - -vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult })); -vi.mock("./status.agent-local.js", () => ({ getAgentLocalStatuses: mocks.getAgentLocalStatuses })); -vi.mock("./status.summary.js", () => ({ getStatusSummary: mocks.getStatusSummary })); -vi.mock("../infra/os-summary.js", () => createStatusOsSummaryModuleMock()); -vi.mock("./status.scan.deps.runtime.js", () => createStatusScanDepsRuntimeModuleMock(mocks)); -vi.mock("../gateway/call.js", () => createStatusGatewayCallModuleMock(mocks)); - -vi.mock("../gateway/probe.js", () => ({ - probeGateway: mocks.probeGateway, -})); - -vi.mock("./status.gateway-probe.js", () => createStatusGatewayProbeModuleMock(mocks)); - -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(), -})); - -vi.mock("../cli/plugin-registry.js", () => createStatusPluginRegistryModuleMock(mocks)); -vi.mock("../plugins/status.js", () => createStatusPluginStatusModuleMock(mocks)); - -import { scanStatus } from "./status.scan.js"; - function configureScanStatus( options: { hasConfiguredChannels?: boolean; @@ -272,7 +202,7 @@ describe("scanStatus", () => { scope: "configured-channels", }); // Verify plugin logs were routed to stderr during loading and restored after - expect(loggingState.forceConsoleToStderr).toBe(false); + expect(loggingStateRef.forceConsoleToStderr).toBe(false); expect(mocks.probeGateway).toHaveBeenCalledWith( expect.objectContaining({ detailLevel: "presence" }), ); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 3e24d583071..09678ed495a 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -1,6 +1,6 @@ import type { Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; -import { bindAbortRelay } from "../../utils/fetch-timeout.js"; +import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js"; import { hasProxyEnvConfigured } from "./proxy-env.js"; import { closeDispatcher, @@ -125,40 +125,6 @@ function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestIni return { ...init, headers }; } -function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { - signal?: AbortSignal; - cleanup: () => void; -} { - const { timeoutMs, signal } = params; - if (!timeoutMs && !signal) { - return { signal: undefined, cleanup: () => {} }; - } - - if (!timeoutMs) { - return { signal, cleanup: () => {} }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); - const onAbort = bindAbortRelay(controller); - if (signal) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } - - const cleanup = () => { - clearTimeout(timeoutId); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - }; - - return { signal: controller.signal, cleanup }; -} - export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; if (!fetcher) { @@ -171,7 +137,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise(); - function normalizeThreadId(value?: string | number | null): string | undefined { if (value == null) { return undefined; @@ -122,69 +109,6 @@ function buildBaseSessionKey(params: { }); } -// Best-effort mpim detection: allowlist/config, then Slack API (if token available). -async function resolveSlackChannelType(params: { - cfg: OpenClawConfig; - accountId?: string | null; - channelId: string; -}): Promise<"channel" | "group" | "dm" | "unknown"> { - const channelId = params.channelId.trim(); - if (!channelId) { - return "unknown"; - } - const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`); - if (cached) { - return cached; - } - - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); - const channelIdLower = channelId.toLowerCase(); - if ( - groupChannels.includes(channelIdLower) || - groupChannels.includes(`slack:${channelIdLower}`) || - groupChannels.includes(`channel:${channelIdLower}`) || - groupChannels.includes(`group:${channelIdLower}`) || - groupChannels.includes(`mpim:${channelIdLower}`) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group"); - return "group"; - } - - const channelKeys = Object.keys(account.channels ?? {}); - if ( - channelKeys.some((key) => { - const normalized = key.trim().toLowerCase(); - return ( - normalized === channelIdLower || - normalized === `channel:${channelIdLower}` || - normalized.replace(/^#/, "") === channelIdLower - ); - }) - ) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel"); - return "channel"; - } - - const token = account.botToken?.trim() || account.userToken || ""; - if (!token) { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } - - try { - const client = createSlackWebClient(token); - const info = await client.conversations.info({ channel: channelId }); - const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; - const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type); - return type; - } catch { - SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); - return "unknown"; - } -} - async function resolveSlackSession( params: ResolveOutboundSessionRouteParams, ): Promise { @@ -386,65 +310,21 @@ function resolveWhatsAppSession( function resolveSignalSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { - const stripped = stripProviderPrefix(params.target, "signal"); - const lowered = stripped.toLowerCase(); - if (lowered.startsWith("group:")) { - const groupId = stripped.slice("group:".length).trim(); - if (!groupId) { - return null; - } - const peer: RoutePeer = { kind: "group", id: groupId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - channel: "signal", - accountId: params.accountId, - peer, - }); - return { - sessionKey: baseSessionKey, - baseSessionKey, - peer, - chatType: "group", - from: `group:${groupId}`, - to: `group:${groupId}`, - }; - } - - let recipient = stripped.trim(); - if (lowered.startsWith("username:")) { - recipient = stripped.slice("username:".length).trim(); - } else if (lowered.startsWith("u:")) { - recipient = stripped.slice("u:".length).trim(); - } - if (!recipient) { + const resolved = resolveSignalOutboundTarget(params.target); + if (!resolved) { return null; } - - const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") - ? recipient.slice("uuid:".length) - : recipient; - const sender = resolveSignalSender({ - sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, - sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, - }); - const peerId = sender ? resolveSignalPeerId(sender) : recipient; - const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; - const peer: RoutePeer = { kind: "direct", id: peerId }; const baseSessionKey = buildBaseSessionKey({ cfg: params.cfg, agentId: params.agentId, channel: "signal", accountId: params.accountId, - peer, + peer: resolved.peer, }); return { sessionKey: baseSessionKey, baseSessionKey, - peer, - chatType: "direct", - from: `signal:${displayRecipient}`, - to: `signal:${displayRecipient}`, + ...resolved, }; } diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index c3ccb5f5105..1a9709551ea 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -11,17 +11,20 @@ import { runGatewayUpdate } from "./update-runner.js"; type CommandResponse = { stdout?: string; stderr?: string; code?: number | null }; type CommandResult = { stdout: string; stderr: string; code: number | null }; +function toCommandResult(response?: CommandResponse): CommandResult { + return { + stdout: response?.stdout ?? "", + stderr: response?.stderr ?? "", + code: response?.code ?? 0, + }; +} + function createRunner(responses: Record) { const calls: string[] = []; const runner = async (argv: string[]) => { const key = argv.join(" "); calls.push(key); - const res = responses[key] ?? {}; - return { - stdout: res.stdout ?? "", - stderr: res.stderr ?? "", - code: res.code ?? 0, - }; + return toCommandResult(responses[key]); }; return { runner, calls }; } @@ -126,6 +129,11 @@ describe("runGatewayUpdate", () => { return uiIndexPath; } + async function setupGitPackageManagerFixture(packageManager = "pnpm@8.0.0") { + await setupGitCheckout({ packageManager }); + return await setupUiIndex(); + } + function buildStableTagResponses( stableTag: string, options?: { additionalTags?: string[] }, @@ -152,6 +160,36 @@ describe("runGatewayUpdate", () => { } satisfies Record; } + function createGitInstallRunner(params: { + stableTag: string; + installCommand: string; + buildCommand: string; + uiBuildCommand: string; + doctorCommand: string; + onCommand?: (key: string) => Promise | CommandResponse | undefined; + }) { + const calls: string[] = []; + const responses = { + ...buildStableTagResponses(params.stableTag), + [params.installCommand]: { stdout: "" }, + [params.buildCommand]: { stdout: "" }, + [params.uiBuildCommand]: { stdout: "" }, + [params.doctorCommand]: { stdout: "" }, + } satisfies Record; + + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + const override = await params.onCommand?.(key); + if (override) { + return toCommandResult(override); + } + return toCommandResult(responses[key]); + }; + + return { calls, runCommand }; + } + async function removeControlUiAssets() { await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true }); } @@ -337,61 +375,24 @@ describe("runGatewayUpdate", () => { }); it("falls back to npm when pnpm is unavailable for git installs", async () => { - await fs.mkdir(path.join(tempDir, ".git")); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), - "utf-8", - ); - const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); - await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); - await fs.writeFile(uiIndexPath, "", "utf-8"); - + await setupGitPackageManagerFixture(); const stableTag = "v1.0.1-1"; - const calls: string[] = []; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - calls.push(key); - if (key === "pnpm --version") { - throw new Error("spawn pnpm ENOENT"); - } - if (key === "npm --version") { - return { stdout: "10.0.0", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { - return { stdout: tempDir, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse HEAD`) { - return { stdout: "abc123", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { - return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm install --no-package-lock --legacy-peer-deps") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm run build") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "npm run ui:build") { - return { stdout: "", stderr: "", code: 0 }; - } - if ( - key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` - ) { - return { stdout: "", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + const { calls, runCommand } = createGitInstallRunner({ + stableTag, + installCommand: "npm install --no-package-lock --legacy-peer-deps", + buildCommand: "npm run build", + uiBuildCommand: "npm run ui:build", + doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, + onCommand: (key) => { + if (key === "pnpm --version") { + throw new Error("spawn pnpm ENOENT"); + } + if (key === "npm --version") { + return { stdout: "10.0.0" }; + } + return undefined; + }, + }); const result = await runGatewayUpdate({ cwd: tempDir, @@ -409,69 +410,32 @@ describe("runGatewayUpdate", () => { }); it("bootstraps pnpm via corepack when pnpm is missing", async () => { - await fs.mkdir(path.join(tempDir, ".git")); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), - "utf-8", - ); - const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); - await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); - await fs.writeFile(uiIndexPath, "", "utf-8"); - + await setupGitPackageManagerFixture(); const stableTag = "v1.0.1-1"; - const calls: string[] = []; let pnpmVersionChecks = 0; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - calls.push(key); - if (key === "pnpm --version") { - pnpmVersionChecks += 1; - if (pnpmVersionChecks === 1) { - throw new Error("spawn pnpm ENOENT"); + const { calls, runCommand } = createGitInstallRunner({ + stableTag, + installCommand: "pnpm install", + buildCommand: "pnpm build", + uiBuildCommand: "pnpm ui:build", + doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, + onCommand: (key) => { + if (key === "pnpm --version") { + pnpmVersionChecks += 1; + if (pnpmVersionChecks === 1) { + throw new Error("spawn pnpm ENOENT"); + } + return { stdout: "10.0.0" }; } - return { stdout: "10.0.0", stderr: "", code: 0 }; - } - if (key === "corepack --version") { - return { stdout: "0.30.0", stderr: "", code: 0 }; - } - if (key === "corepack enable") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { - return { stdout: tempDir, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} rev-parse HEAD`) { - return { stdout: "abc123", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} fetch --all --prune --tags`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { - return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; - } - if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm install") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm build") { - return { stdout: "", stderr: "", code: 0 }; - } - if (key === "pnpm ui:build") { - return { stdout: "", stderr: "", code: 0 }; - } - if ( - key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` - ) { - return { stdout: "", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + if (key === "corepack --version") { + return { stdout: "0.30.0" }; + } + if (key === "corepack enable") { + return { stdout: "" }; + } + return undefined; + }, + }); const result = await runGatewayUpdate({ cwd: tempDir, diff --git a/src/media/qr-image.ts b/src/media/qr-image.ts new file mode 100644 index 00000000000..e0a0a276acc --- /dev/null +++ b/src/media/qr-image.ts @@ -0,0 +1,54 @@ +import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; +import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; +import { encodePngRgba, fillPixel } from "./png-encode.ts"; + +type QRCodeConstructor = new ( + typeNumber: number, + errorCorrectLevel: unknown, +) => { + addData: (data: string) => void; + make: () => void; + getModuleCount: () => number; + isDark: (row: number, col: number) => boolean; +}; + +const QRCode = QRCodeModule as QRCodeConstructor; +const QRErrorCorrectLevel = QRErrorCorrectLevelModule; + +function createQrMatrix(input: string) { + const qr = new QRCode(-1, QRErrorCorrectLevel.L); + qr.addData(input); + qr.make(); + return qr; +} + +export async function renderQrPngBase64( + input: string, + opts: { scale?: number; marginModules?: number } = {}, +): Promise { + const { scale = 6, marginModules = 4 } = opts; + const qr = createQrMatrix(input); + const modules = qr.getModuleCount(); + const size = (modules + marginModules * 2) * scale; + + const buf = Buffer.alloc(size * size * 4, 255); + for (let row = 0; row < modules; row += 1) { + for (let col = 0; col < modules; col += 1) { + if (!qr.isDark(row, col)) { + continue; + } + const startX = (col + marginModules) * scale; + const startY = (row + marginModules) * scale; + for (let y = 0; y < scale; y += 1) { + const pixelY = startY + y; + for (let x = 0; x < scale; x += 1) { + const pixelX = startX + x; + fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); + } + } + } + } + + const png = encodePngRgba(buf, size, size); + return png.toString("base64"); +} diff --git a/src/plugin-sdk/github-copilot-token.ts b/src/plugin-sdk/github-copilot-token.ts new file mode 100644 index 00000000000..743daf1842e --- /dev/null +++ b/src/plugin-sdk/github-copilot-token.ts @@ -0,0 +1 @@ +export * from "../agents/github-copilot-token.js"; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index daae49943b7..060e5ff8307 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -40,5 +40,6 @@ export * from "../infra/system-message.ts"; export * from "../infra/tmp-openclaw-dir.js"; export * from "../infra/transport-ready.js"; export * from "../infra/wsl.ts"; +export * from "../utils/fetch-timeout.js"; export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forwarders.js"; export * from "./ssrf-policy.js"; diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index 62f98eb652a..07d954a4681 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -13,6 +13,7 @@ export * from "../media/local-roots.js"; export * from "../media/mime.js"; export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; +export * from "../media/qr-image.ts"; export * from "../media/store.js"; export * from "../media/temp-files.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index e9d86b95a47..5b13bcc02e8 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -133,6 +133,15 @@ export { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL, } from "../agents/vercel-ai-gateway.js"; +export { + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "../plugins/provider-model-definitions.js"; export function buildKilocodeModelDefinition(): ModelDefinitionConfig { return { diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 9ed067cbf23..b03aebff79b 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -16,6 +16,7 @@ export { MAX_SEARCH_COUNT, normalizeFreshness, normalizeToIsoDate, + parseIsoDateRange, readCachedSearchPayload, readConfiguredSecretString, readProviderEnvValue, diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index ce349fb9de5..8cc447e870a 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -33,6 +33,8 @@ export { getSandboxBackendManager, registerSandboxBackend, requireSandboxBackendFactory, + resolveWritableRenameTargets, + resolveWritableRenameTargetsForBridge, runSshSandboxCommand, shellEscape, uploadDirectoryToSshTarget, diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index bde1abc0856..cb8d4f67d75 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { collectBundledPluginMetadata, writeBundledPluginMetadataModule, @@ -10,20 +9,14 @@ import { BUNDLED_PLUGIN_METADATA, resolveBundledPluginGeneratedPath, } from "./bundled-plugin-metadata.js"; +import { + createGeneratedPluginTempRoot, + installGeneratedPluginTempRootCleanup, + pluginTestRepoRoot as repoRoot, + writeJson, +} from "./generated-plugin-test-helpers.js"; -const repoRoot = path.resolve(import.meta.dirname, "../.."); -const tempDirs: string[] = []; - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); +installGeneratedPluginTempRootCleanup(); describe("bundled plugin metadata", () => { it("matches the generated metadata snapshot", () => { @@ -38,8 +31,7 @@ describe("bundled plugin metadata", () => { }); it("prefers built generated paths when present and falls back to source paths", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-plugin-metadata-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-metadata-"); fs.mkdirSync(path.join(tempRoot, "plugin"), { recursive: true }); fs.writeFileSync(path.join(tempRoot, "plugin", "index.ts"), "export {};\n", "utf8"); @@ -60,8 +52,7 @@ describe("bundled plugin metadata", () => { }); it("supports check mode for stale generated artifacts", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-plugin-generated-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-generated-"); writeJson(path.join(tempRoot, "extensions", "alpha", "package.json"), { name: "@openclaw/alpha", diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index bf0d481834b..a6a28155a75 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -1,27 +1,19 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { afterEach } from "vitest"; import { collectBundledProviderAuthEnvVars, writeBundledProviderAuthEnvVarModule, } from "../../scripts/generate-bundled-provider-auth-env-vars.mjs"; import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; +import { + createGeneratedPluginTempRoot, + installGeneratedPluginTempRootCleanup, + pluginTestRepoRoot as repoRoot, + writeJson, +} from "./generated-plugin-test-helpers.js"; -const repoRoot = path.resolve(import.meta.dirname, "../.."); -const tempDirs: string[] = []; - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); +installGeneratedPluginTempRootCleanup(); describe("bundled provider auth env vars", () => { it("matches the generated manifest snapshot", () => { @@ -57,8 +49,7 @@ describe("bundled provider auth env vars", () => { }); it("supports check mode for stale generated artifacts", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-auth-env-vars-")); - tempDirs.push(tempRoot); + const tempRoot = createGeneratedPluginTempRoot("openclaw-provider-auth-env-vars-"); writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), { id: "alpha", diff --git a/src/plugins/generated-plugin-test-helpers.ts b/src/plugins/generated-plugin-test-helpers.ts new file mode 100644 index 00000000000..86c2ed18e5b --- /dev/null +++ b/src/plugins/generated-plugin-test-helpers.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach } from "vitest"; + +export const pluginTestRepoRoot = path.resolve(import.meta.dirname, "../.."); + +const tempDirs: string[] = []; + +export function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +export function createGeneratedPluginTempRoot(prefix: string): string { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(tempRoot); + return tempRoot; +} + +export function installGeneratedPluginTempRootCleanup() { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} diff --git a/src/utils/fetch-timeout.ts b/src/utils/fetch-timeout.ts index 150f4e119a9..d0ddc01d4b2 100644 --- a/src/utils/fetch-timeout.ts +++ b/src/utils/fetch-timeout.ts @@ -11,6 +11,40 @@ export function bindAbortRelay(controller: AbortController): () => void { return relayAbort.bind(controller); } +export function buildTimeoutAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): { + signal?: AbortSignal; + cleanup: () => void; +} { + const { timeoutMs, signal } = params; + if (!timeoutMs && !signal) { + return { signal: undefined, cleanup: () => {} }; + } + if (!timeoutMs) { + return { signal, cleanup: () => {} }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); + const onAbort = bindAbortRelay(controller); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener("abort", onAbort); + } + }, + }; +} + /** * Fetch wrapper that adds timeout support via AbortController. * @@ -27,11 +61,12 @@ export async function fetchWithTimeout( timeoutMs: number, fetchFn: typeof fetch = fetch, ): Promise { - const controller = new AbortController(); - const timer = setTimeout(controller.abort.bind(controller), Math.max(1, timeoutMs)); + const { signal, cleanup } = buildTimeoutAbortSignal({ + timeoutMs: Math.max(1, timeoutMs), + }); try { - return await fetchFn(url, { ...init, signal: controller.signal }); + return await fetchFn(url, { ...init, signal }); } finally { - clearTimeout(timer); + cleanup(); } } diff --git a/test/helpers/extensions/configured-binding-runtime.ts b/test/helpers/extensions/configured-binding-runtime.ts new file mode 100644 index 00000000000..e37206a0d83 --- /dev/null +++ b/test/helpers/extensions/configured-binding-runtime.ts @@ -0,0 +1,16 @@ +export async function createConfiguredBindingConversationRuntimeModuleMock( + params: { + ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown; + resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown; + }, + importOriginal: () => Promise, +) { + const actual = await importOriginal(); + return { + ...actual, + ensureConfiguredBindingRouteReady: (...args: unknown[]) => + params.ensureConfiguredBindingRouteReadyMock(...args), + resolveConfiguredBindingRoute: (...args: unknown[]) => + params.resolveConfiguredBindingRouteMock(...args), + }; +} diff --git a/test/helpers/extensions/provider-registration.ts b/test/helpers/extensions/provider-registration.ts new file mode 100644 index 00000000000..b03cbe54041 --- /dev/null +++ b/test/helpers/extensions/provider-registration.ts @@ -0,0 +1,63 @@ +import { createTestPluginApi } from "./plugin-api.js"; + +type RegisteredProviderCollections = { + providers: unknown[]; + speechProviders: unknown[]; + mediaProviders: unknown[]; + imageProviders: unknown[]; +}; + +type ProviderPluginModule = { + register(api: ReturnType): void; +}; + +export function registerProviderPlugin(params: { + plugin: ProviderPluginModule; + id: string; + name: string; +}): RegisteredProviderCollections { + const providers: unknown[] = []; + const speechProviders: unknown[] = []; + const mediaProviders: unknown[] = []; + const imageProviders: unknown[] = []; + + params.plugin.register( + createTestPluginApi({ + id: params.id, + name: params.name, + source: "test", + config: {}, + runtime: {} as never, + registerProvider: (provider) => { + providers.push(provider); + }, + registerSpeechProvider: (provider) => { + speechProviders.push(provider); + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProviders.push(provider); + }, + registerImageGenerationProvider: (provider) => { + imageProviders.push(provider); + }, + }), + ); + + return { providers, speechProviders, mediaProviders, imageProviders }; +} + +export function requireRegisteredProvider( + entries: unknown[], + id: string, + label = "provider", +): T { + const entry = entries.find( + (candidate) => + // oxlint-disable-next-line typescript/no-explicit-any + (candidate as any).id === id, + ); + if (!entry) { + throw new Error(`${label} ${id} was not registered`); + } + return entry as T; +} diff --git a/test/scripts/test-parallel.test.ts b/test/scripts/test-parallel.test.ts index 0f4a91c85a4..e0235080f59 100644 --- a/test/scripts/test-parallel.test.ts +++ b/test/scripts/test-parallel.test.ts @@ -1,3 +1,5 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseCompletedTestFileLines, @@ -108,3 +110,37 @@ describe("scripts/test-parallel memory trace parsing", () => { }); }); }); + +describe("scripts/test-parallel lane planning", () => { + it("keeps serial profile on split unit lanes instead of one giant unit worker", () => { + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const output = execFileSync("node", ["scripts/test-parallel.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + OPENCLAW_TEST_LIST_LANES: "1", + OPENCLAW_TEST_PROFILE: "serial", + }, + encoding: "utf8", + }); + + expect(output).toContain("unit-fast"); + expect(output).not.toContain("unit filters=all maxWorkers=1"); + }); + + it("recycles default local unit-fast runs into bounded batches", () => { + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const output = execFileSync("node", ["scripts/test-parallel.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + CI: "", + OPENCLAW_TEST_LIST_LANES: "1", + }, + encoding: "utf8", + }); + + expect(output).toContain("unit-fast-batch-"); + expect(output).not.toContain("unit-fast filters=all maxWorkers="); + }); +});