mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-30 01:06:11 +00:00
refactor: dedupe test and runtime seams
This commit is contained in:
@@ -38,6 +38,53 @@ afterAll(async () => {
|
||||
await cleanupMockRuntimeFixtures();
|
||||
});
|
||||
|
||||
async function expectSessionEnsureFallback(params: {
|
||||
sessionKey: string;
|
||||
env?: Record<string, string>;
|
||||
expectNewAfterStatus: boolean;
|
||||
expectedRecordId?: string;
|
||||
}) {
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
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 () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string> {
|
||||
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";
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
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";
|
||||
|
||||
@@ -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<typeof import("./send.js")>();
|
||||
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<typeof import("./monitor/thread-bindings.js")>();
|
||||
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 () => {
|
||||
|
||||
109
extensions/discord/src/outbound-adapter.test-harness.ts
Normal file
109
extensions/discord/src/outbound-adapter.test-harness.ts
Normal file
@@ -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<typeof createDiscordOutboundHoisted>;
|
||||
|
||||
export async function createDiscordSendModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
importOriginal: () => Promise<DiscordSendModule>,
|
||||
) {
|
||||
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<DiscordThreadBindingsModule>,
|
||||
) {
|
||||
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<string, unknown>;
|
||||
}) {
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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<typeof import("./send.js")>();
|
||||
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<typeof import("./monitor/thread-bindings.js")>();
|
||||
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<string, unknown>;
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<string | number> | null | undefined,
|
||||
additions: Array<string | number>,
|
||||
): 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<string, unknown>;
|
||||
}): OpenClawConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const channelConfig = (params.cfg.channels?.discord as Record<string, unknown> | 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<string, Record<string, unknown>> | 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<string, unknown> | undefined) ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
[channel]: {
|
||||
...channelConfig,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function patchChannelConfigForAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: "discord";
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}): 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<string | number> } | undefined
|
||||
)?.allowFrom ?? []) as Array<string | number>),
|
||||
"*",
|
||||
]
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function noteChannelLookupFailure(params: {
|
||||
prompter: Pick<WizardPrompter, "note">;
|
||||
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<ChannelSetupWizard["allowFrom"]>["credentialInputKey"];
|
||||
helpTitle?: string;
|
||||
helpLines?: string[];
|
||||
message: string;
|
||||
placeholder: string;
|
||||
invalidWithoutCredentialNote: string;
|
||||
parseId: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseId"]>;
|
||||
resolveEntries: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>;
|
||||
}): NonNullable<ChannelSetupWizard["allowFrom"]> {
|
||||
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<TResolved>(params: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
helpTitle?: string;
|
||||
helpLines?: string[];
|
||||
skipAllowlistEntries?: boolean;
|
||||
currentPolicy: NonNullable<ChannelSetupWizard["groupAccess"]>["currentPolicy"];
|
||||
currentEntries: NonNullable<ChannelSetupWizard["groupAccess"]>["currentEntries"];
|
||||
updatePrompt: NonNullable<ChannelSetupWizard["groupAccess"]>["updatePrompt"];
|
||||
resolveAllowlist?: NonNullable<
|
||||
NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]
|
||||
>;
|
||||
fallbackResolved: (entries: string[]) => TResolved;
|
||||
applyAllowlist: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
resolved: TResolved;
|
||||
}) => OpenClawConfig;
|
||||
}): NonNullable<ChannelSetupWizard["groupAccess"]> {
|
||||
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<TGroupResolved>(params: {
|
||||
loadWizard: () => Promise<ChannelSetupWizard>;
|
||||
createBase: (handlers: {
|
||||
promptAllowFrom: NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>;
|
||||
resolveAllowFromEntries: NonNullable<
|
||||
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
|
||||
>;
|
||||
resolveGroupAllowlist: NonNullable<
|
||||
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["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<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
|
||||
>
|
||||
>;
|
||||
}
|
||||
return (await wizard.groupAccess.resolveAllowlist({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
prompter,
|
||||
})) as Awaited<
|
||||
ReturnType<NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>>
|
||||
>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveEntriesWithOptionalToken<TResult>(params: {
|
||||
token?: string | null;
|
||||
entries: string[];
|
||||
buildWithoutToken: (input: string) => TResult;
|
||||
resolveEntries: (params: { token: string; entries: string[] }) => Promise<TResult[]>;
|
||||
}): Promise<TResult[]> {
|
||||
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<Array<{ input: string; resolved: boolean; id?: string | null }>>;
|
||||
resolveToken: (accountId: string) => string | null | undefined;
|
||||
resolveExisting: (accountId: string, cfg: OpenClawConfig) => Array<string | number>;
|
||||
}): Promise<OpenClawConfig> {
|
||||
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),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,11 @@ async function promptDiscordAllowFrom(params: {
|
||||
}): Promise<OpenClawConfig> {
|
||||
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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<string>();
|
||||
const { signal, cleanup } = buildAbortSignal({
|
||||
const { signal, cleanup } = buildTimeoutAbortSignal({
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
@@ -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<RuntimeEnv>(),
|
||||
prompter,
|
||||
options: undefined,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: true,
|
||||
forceAllowFrom: true,
|
||||
configured: true,
|
||||
label: "Matrix",
|
||||
allowFromInput: "Alice",
|
||||
roomsAllowlistInput: "",
|
||||
});
|
||||
|
||||
expect(result).not.toBe("skip");
|
||||
|
||||
145
extensions/matrix/src/onboarding.test-harness.ts
Normal file
145
extensions/matrix/src/onboarding.test-harness.ts
Normal file
@@ -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>(): 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<T> = (message: string) => T;
|
||||
|
||||
export function createMatrixWizardPrompter(params: {
|
||||
notes?: string[];
|
||||
select?: Record<string, string>;
|
||||
text?: Record<string, string>;
|
||||
confirm?: Record<string, boolean>;
|
||||
onNote?: PromptHandler<void | Promise<void>>;
|
||||
onSelect?: PromptHandler<string | Promise<string>>;
|
||||
onText?: PromptHandler<string | Promise<string>>;
|
||||
onConfirm?: PromptHandler<boolean | Promise<boolean>>;
|
||||
}): WizardPrompter {
|
||||
const resolvePromptValue = async <T>(
|
||||
kind: string,
|
||||
message: string,
|
||||
values: Record<string, T> | undefined,
|
||||
fallback: PromptHandler<T | Promise<T>> | undefined,
|
||||
): Promise<T> => {
|
||||
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<string, string>;
|
||||
shouldPromptAccountIds?: boolean;
|
||||
forceAllowFrom?: boolean;
|
||||
configured?: boolean;
|
||||
}) {
|
||||
return await matrixOnboardingAdapter.configureInteractive!({
|
||||
cfg: params.cfg,
|
||||
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<RuntimeEnv>(),
|
||||
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<RuntimeEnv>(),
|
||||
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<RuntimeEnv>(),
|
||||
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<RuntimeEnv>(),
|
||||
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<RuntimeEnv>(),
|
||||
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");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
buildTimeoutAbortSignal,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<typeof buildResolvedNostrAccount>;
|
||||
cfg?: Parameters<typeof createStartAccountContext>[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: (
|
||||
|
||||
@@ -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<T = unknown>(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();
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -51,93 +54,80 @@ function createCommandContext(args: string): PluginCommandContext {
|
||||
};
|
||||
}
|
||||
|
||||
function createPhoneControlConfig(): Record<string, unknown> {
|
||||
return {
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: [],
|
||||
denyCommands: [...WRITE_COMMANDS],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withRegisteredPhoneControl(
|
||||
run: (params: {
|
||||
command: OpenClawPluginCommandDefinition;
|
||||
writeConfigFile: ReturnType<typeof vi.fn>;
|
||||
getConfig: () => Record<string, unknown>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), PHONE_CONTROL_STATE_PREFIX));
|
||||
try {
|
||||
let config = createPhoneControlConfig();
|
||||
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
|
||||
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<string, unknown> = {
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: [],
|
||||
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
|
||||
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<string, unknown> = {
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: [],
|
||||
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
|
||||
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<string, unknown> = {
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: [],
|
||||
denyCommands: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
57
extensions/signal/src/outbound-session.ts
Normal file
57
extensions/signal/src/outbound-session.ts
Normal file
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
69
extensions/slack/src/channel-type.ts
Normal file
69
extensions/slack/src/channel-type.ts
Normal file
@@ -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<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
|
||||
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;
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
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;
|
||||
|
||||
@@ -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<unknown>;
|
||||
listenerFactory: unknown;
|
||||
sleep: ReturnType<typeof vi.fn>;
|
||||
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<void>;
|
||||
body: string;
|
||||
@@ -270,6 +309,7 @@ export async function sendWebDirectInboundMessage(params: {
|
||||
to: string;
|
||||
spies: ReturnType<typeof createWebInboundDeliverySpies>;
|
||||
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,
|
||||
|
||||
@@ -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<unknown>;
|
||||
listenerFactory: unknown;
|
||||
sleep: ReturnType<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
reply: ReturnType<typeof vi.fn>;
|
||||
sendMedia: ReturnType<typeof vi.fn>;
|
||||
}): 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];
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
|
||||
type AsyncMock<TArgs extends unknown[] = unknown[], TResult = unknown> = {
|
||||
(...args: TArgs): Promise<TResult>;
|
||||
mockReset: () => AsyncMock<TArgs, TResult>;
|
||||
mockResolvedValue: (value: TResult) => AsyncMock<TArgs, TResult>;
|
||||
mockResolvedValueOnce: (value: TResult) => AsyncMock<TArgs, TResult>;
|
||||
};
|
||||
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<string, unknown> = {};
|
||||
|
||||
export function setAccessControlTestConfig(next: Record<string, unknown>): 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<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: async (
|
||||
params: Parameters<typeof actual.readStoreAllowFromForDmPolicy>[0],
|
||||
) =>
|
||||
await actual.readStoreAllowFromForDmPolicy({
|
||||
...params,
|
||||
readStore: async (provider, accountId) =>
|
||||
(await readAllowFromStoreMock(provider, accountId)) as string[],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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")');
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: async (
|
||||
params: Parameters<typeof actual.readStoreAllowFromForDmPolicy>[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<typeof import("./session.js")>("./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;
|
||||
|
||||
49
extensions/whatsapp/src/pairing-security.test-harness.ts
Normal file
49
extensions/whatsapp/src/pairing-security.test-harness.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type AsyncMock<TArgs extends unknown[] = unknown[], TResult = unknown> = {
|
||||
(...args: TArgs): Promise<TResult>;
|
||||
mockReset: () => AsyncMock<TArgs, TResult>;
|
||||
mockResolvedValue: (value: TResult) => AsyncMock<TArgs, TResult>;
|
||||
mockResolvedValueOnce: (value: TResult) => AsyncMock<TArgs, TResult>;
|
||||
};
|
||||
|
||||
export const loadConfigMock = vi.fn();
|
||||
export const readAllowFromStoreMock = vi.fn() as AsyncMock;
|
||||
export const upsertPairingRequestMock = vi.fn() as AsyncMock;
|
||||
|
||||
export function resetPairingSecurityMocks(config: Record<string, unknown>) {
|
||||
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<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: async (
|
||||
params: Parameters<typeof actual.readStoreAllowFromForDmPolicy>[0],
|
||||
) =>
|
||||
await actual.readStoreAllowFromForDmPolicy({
|
||||
...params,
|
||||
readStore: async (provider, accountId) =>
|
||||
(await readAllowFromStoreMock(provider, accountId)) as string[],
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -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<string> {
|
||||
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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"plugin-runtime",
|
||||
"security-runtime",
|
||||
"gateway-runtime",
|
||||
"github-copilot-token",
|
||||
"cli-runtime",
|
||||
"hook-runtime",
|
||||
"process-runtime",
|
||||
|
||||
@@ -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,
|
||||
|
||||
136
src/agents/bundle-mcp.test-harness.ts
Normal file
136
src/agents/bundle-mcp.test-harness.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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",
|
||||
);
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,45 @@ import {
|
||||
clearChutesModelCache,
|
||||
} from "./chutes-models.js";
|
||||
|
||||
async function withLiveChutesDiscovery<T>(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
run: () => Promise<T>,
|
||||
options?: { now?: string },
|
||||
): Promise<T> {
|
||||
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<string, string> }) => {
|
||||
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<string, string> }) => {
|
||||
@@ -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<string, string> }) => {
|
||||
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<string, string> }) => {
|
||||
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<string, string> }) => {
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<EmbeddedRunAttemptResult>>();
|
||||
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<typeof import("../plugins/provider-runtime.js")>();
|
||||
@@ -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>): 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>): 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",
|
||||
|
||||
@@ -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<ReturnType<typeof resolveImplicitProvidersForTest>>,
|
||||
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<ReturnType<typeof resolveImplicitProvidersForTest>>,
|
||||
) {
|
||||
expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL);
|
||||
expect(providers?.chutes?.apiKey).toBe(CHUTES_OAUTH_MARKER);
|
||||
}
|
||||
|
||||
async function withRealChutesDiscovery<T>(
|
||||
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<T>,
|
||||
) {
|
||||
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<string, string> } | 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,50 @@ const createMockConfig = () => ({
|
||||
|
||||
let mockConfig: Record<string, unknown> = createMockConfig();
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
function createScopedSessionStores() {
|
||||
return new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"/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<string, unknown>) => {
|
||||
if (storePath) {
|
||||
stores.set(storePath, store);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return stores;
|
||||
}
|
||||
|
||||
async function createSessionsModuleMock(
|
||||
importOriginal: () => Promise<typeof import("../config/sessions.js")>,
|
||||
) {
|
||||
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<typeof import("../gateway/session-utils.js")>();
|
||||
async function createGatewaySessionUtilsModuleMock(
|
||||
importOriginal: () => Promise<typeof import("../gateway/session-utils.js")>,
|
||||
) {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
|
||||
loadCombinedSessionStoreForGatewayMock(cfg),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
async function createConfigModuleMock(
|
||||
importOriginal: () => Promise<typeof import("../config/config.js")>,
|
||||
) {
|
||||
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<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
|
||||
updateSessionStore: async (
|
||||
storePath: string,
|
||||
mutator: (store: Record<string, unknown>) => Promise<void> | void,
|
||||
) => {
|
||||
const store = loadSessionStoreMock(storePath) as Record<string, unknown>;
|
||||
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<typeof import("../gateway/session-utils.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
|
||||
loadCombinedSessionStoreForGatewayMock(cfg),
|
||||
};
|
||||
});
|
||||
vi.doMock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
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<string, Record<string, unknown>>([
|
||||
[
|
||||
"/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<string, Record<string, unknown>>([
|
||||
[
|
||||
"/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<string, unknown>) => {
|
||||
// Keep map in sync for resolveSessionEntry fallbacks if needed.
|
||||
if (_storePath) {
|
||||
stores.set(_storePath, store);
|
||||
}
|
||||
},
|
||||
);
|
||||
installScopedSessionStores(true);
|
||||
|
||||
const tool = getSessionStatusTool("agent:support:main");
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -17,85 +13,35 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function writeExecutable(filePath: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
|
||||
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<void> {
|
||||
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([]);
|
||||
|
||||
@@ -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<EmbeddedRunAttemptResult>>();
|
||||
|
||||
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<typeof import("@mariozechner/pi-ai")>();
|
||||
@@ -113,25 +98,8 @@ const installRunEmbeddedMocks = () => {
|
||||
const actual = await importOriginal<typeof import("./pi-embedded-runner/model.js")>();
|
||||
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>): 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>): 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"),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
src/agents/sandbox/fs-bridge-rename-targets.ts
Normal file
32
src/agents/sandbox/fs-bridge-rename-targets.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function resolveWritableRenameTargets<T extends { containerPath: string }>(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<T extends { containerPath: string }>(
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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",
|
||||
|
||||
@@ -31,47 +31,59 @@ let fallbackRequesterResolution: {
|
||||
} | null = null;
|
||||
let chatHistoryMessages: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<typeof import("../config/config.js")>();
|
||||
async function createConfigModuleMock(
|
||||
importOriginal: () => Promise<typeof import("../config/config.js")>,
|
||||
) {
|
||||
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<typeof import("./pi-embedded.js")>();
|
||||
async function createPiEmbeddedModuleMock(
|
||||
importOriginal: () => Promise<typeof import("./pi-embedded.js")>,
|
||||
) {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
isEmbeddedPiRunActive: () => false,
|
||||
queueEmbeddedPiMessage: () => false,
|
||||
waitForEmbeddedPiRunEnd: async () => true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("./subagent-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./subagent-registry.js")>();
|
||||
async function createSubagentRegistryModuleMock(
|
||||
importOriginal: () => Promise<typeof import("./subagent-registry.js")>,
|
||||
) {
|
||||
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<typeof import("../config/config.js")>();
|
||||
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<typeof import("./pi-embedded.js")>();
|
||||
return {
|
||||
...actual,
|
||||
isEmbeddedPiRunActive: () => false,
|
||||
queueEmbeddedPiMessage: () => false,
|
||||
waitForEmbeddedPiRunEnd: async () => true,
|
||||
};
|
||||
});
|
||||
vi.doMock("./subagent-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./subagent-registry.js")>();
|
||||
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." }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<T>(task: () => Promise<T>): Promise<T> {
|
||||
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>,
|
||||
): 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>,
|
||||
): 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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof captureEnv>;
|
||||
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;
|
||||
|
||||
@@ -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<typeof import("../config/paths.js")>();
|
||||
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 () => {
|
||||
|
||||
@@ -72,6 +72,126 @@ export function createStatusPluginStatusModuleMock(
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatusUpdateModuleMock(
|
||||
mocks: Pick<StatusScanSharedMocks, "getUpdateCheckResult">,
|
||||
) {
|
||||
return {
|
||||
getUpdateCheckResult: mocks.getUpdateCheckResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatusAgentLocalModuleMock(
|
||||
mocks: Pick<StatusScanSharedMocks, "getAgentLocalStatuses">,
|
||||
) {
|
||||
return {
|
||||
getAgentLocalStatuses: mocks.getAgentLocalStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatusSummaryModuleMock(
|
||||
mocks: Pick<StatusScanSharedMocks, "getStatusSummary">,
|
||||
) {
|
||||
return {
|
||||
getStatusSummary: mocks.getStatusSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatusExecModuleMock() {
|
||||
return {
|
||||
runExec: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
type StatusScanModuleTestMocks = StatusScanSharedMocks & {
|
||||
buildChannelsTable?: ReturnType<typeof vi.fn>;
|
||||
callGateway?: ReturnType<typeof vi.fn>;
|
||||
getStatusCommandSecretTargetIds?: ReturnType<typeof vi.fn>;
|
||||
resolveMemorySearchConfig?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export async function loadStatusScanModuleForTest(
|
||||
mocks: StatusScanModuleTestMocks,
|
||||
options: {
|
||||
fastJson: true;
|
||||
},
|
||||
): Promise<typeof import("./status.scan.fast-json.js")>;
|
||||
export async function loadStatusScanModuleForTest(
|
||||
mocks: StatusScanModuleTestMocks,
|
||||
options?: {
|
||||
fastJson?: false;
|
||||
},
|
||||
): Promise<typeof import("./status.scan.js")>;
|
||||
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<typeof import("../config/paths.js")>();
|
||||
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<T extends object = OpenClawConfig>(
|
||||
overrides: T = {} as T,
|
||||
): OpenClawConfig & T {
|
||||
|
||||
@@ -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<typeof import("../config/paths.js")>();
|
||||
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" }),
|
||||
);
|
||||
|
||||
@@ -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<GuardedFetchResult> {
|
||||
const fetcher: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch;
|
||||
if (!fetcher) {
|
||||
@@ -171,7 +137,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
: DEFAULT_MAX_REDIRECTS;
|
||||
const mode = resolveGuardedFetchMode(params);
|
||||
|
||||
const { signal, cleanup } = buildAbortSignal({
|
||||
const { signal, cleanup } = buildTimeoutAbortSignal({
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { parseDiscordTarget } from "../../../extensions/discord/api.js";
|
||||
import { normalizeIMessageHandle, parseIMessageTarget } from "../../../extensions/imessage/api.js";
|
||||
import {
|
||||
looksLikeUuid,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "../../../extensions/signal/api.js";
|
||||
import {
|
||||
createSlackWebClient,
|
||||
normalizeAllowListLower,
|
||||
parseSlackTarget,
|
||||
resolveSlackAccount,
|
||||
} from "../../../extensions/slack/api.js";
|
||||
import { resolveSignalOutboundTarget } from "../../../extensions/signal/api.js";
|
||||
import { parseSlackTarget, resolveSlackChannelType } from "../../../extensions/slack/api.js";
|
||||
import {
|
||||
buildTelegramGroupPeerId,
|
||||
parseTelegramTarget,
|
||||
@@ -50,9 +40,6 @@ export type ResolveOutboundSessionRouteParams = {
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
// Cache Slack channel type lookups to avoid repeated API calls.
|
||||
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
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<OutboundSessionRoute | null> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, CommandResponse>) {
|
||||
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<string, CommandResponse>;
|
||||
}
|
||||
|
||||
function createGitInstallRunner(params: {
|
||||
stableTag: string;
|
||||
installCommand: string;
|
||||
buildCommand: string;
|
||||
uiBuildCommand: string;
|
||||
doctorCommand: string;
|
||||
onCommand?: (key: string) => Promise<CommandResponse | undefined> | CommandResponse | undefined;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
const responses = {
|
||||
...buildStableTagResponses(params.stableTag),
|
||||
[params.installCommand]: { stdout: "" },
|
||||
[params.buildCommand]: { stdout: "" },
|
||||
[params.uiBuildCommand]: { stdout: "" },
|
||||
[params.doctorCommand]: { stdout: "" },
|
||||
} satisfies Record<string, CommandResponse>;
|
||||
|
||||
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, "<html></html>", "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, "<html></html>", "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,
|
||||
|
||||
54
src/media/qr-image.ts
Normal file
54
src/media/qr-image.ts
Normal file
@@ -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<string> {
|
||||
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");
|
||||
}
|
||||
1
src/plugin-sdk/github-copilot-token.ts
Normal file
1
src/plugin-sdk/github-copilot-token.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../agents/github-copilot-token.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
MAX_SEARCH_COUNT,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
|
||||
@@ -33,6 +33,8 @@ export {
|
||||
getSandboxBackendManager,
|
||||
registerSandboxBackend,
|
||||
requireSandboxBackendFactory,
|
||||
resolveWritableRenameTargets,
|
||||
resolveWritableRenameTargetsForBridge,
|
||||
runSshSandboxCommand,
|
||||
shellEscape,
|
||||
uploadDirectoryToSshTarget,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
src/plugins/generated-plugin-test-helpers.ts
Normal file
27
src/plugins/generated-plugin-test-helpers.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
16
test/helpers/extensions/configured-binding-runtime.ts
Normal file
16
test/helpers/extensions/configured-binding-runtime.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export async function createConfiguredBindingConversationRuntimeModuleMock(
|
||||
params: {
|
||||
ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown;
|
||||
resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown;
|
||||
},
|
||||
importOriginal: () => Promise<typeof import("openclaw/plugin-sdk/conversation-runtime")>,
|
||||
) {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
params.ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
params.resolveConfiguredBindingRouteMock(...args),
|
||||
};
|
||||
}
|
||||
63
test/helpers/extensions/provider-registration.ts
Normal file
63
test/helpers/extensions/provider-registration.ts
Normal file
@@ -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<typeof createTestPluginApi>): 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<T = unknown>(
|
||||
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;
|
||||
}
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user