refactor: dedupe test and runtime seams

This commit is contained in:
Peter Steinberger
2026-03-24 23:32:41 +00:00
parent 369119b6b5
commit 6f6468027a
88 changed files with 2601 additions and 3811 deletions

View File

@@ -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 () => {

View File

@@ -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",
});
});
});

View File

@@ -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",

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 () => {

View 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(),
}),
});
}

View File

@@ -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,
});

View File

@@ -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: [

View File

@@ -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),
),
},
});
}
}

View File

@@ -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({

View File

@@ -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",
});
});
});

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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" });

View File

@@ -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,
});

View File

@@ -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");

View 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,
});
}

View File

@@ -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");

View File

@@ -1,6 +1,7 @@
export * from "openclaw/plugin-sdk/matrix";
export {
assertHttpUrlTargetsPrivateNetwork,
buildTimeoutAbortSignal,
closeDispatcher,
createPinnedDispatcher,
resolvePinnedHostnameWithPolicy,

View File

@@ -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,
});
}

View File

@@ -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({

View File

@@ -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: (

View File

@@ -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();

View File

@@ -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?.({

View File

@@ -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,

View File

@@ -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 });
}
});
});
});

View File

@@ -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";

View File

@@ -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,
};
}

View 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}`,
};
}

View File

@@ -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";

View 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";
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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];

View File

@@ -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[],
}),
};
});

View File

@@ -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")');

View File

@@ -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;

View 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[],
}),
};
});

View File

@@ -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";

View File

@@ -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"

View File

@@ -35,6 +35,7 @@
"plugin-runtime",
"security-runtime",
"gateway-runtime",
"github-copilot-token",
"cli-runtime",
"hook-runtime",
"process-runtime",

View File

@@ -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,

View 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",
);
`,
);
}

View File

@@ -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();
}
});
});
});

View File

@@ -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(

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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");

View File

@@ -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([]);

View File

@@ -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"),
}),
);

View File

@@ -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,

View 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,
});
}

View File

@@ -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",

View File

@@ -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." }],

View File

@@ -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);

View File

@@ -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")),

View File

@@ -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,

View File

@@ -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),

View File

@@ -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: {},
};
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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" }),
);

View File

@@ -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,
});

View File

@@ -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,
};
}

View File

@@ -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
View 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");
}

View File

@@ -0,0 +1 @@
export * from "../agents/github-copilot-token.js";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -16,6 +16,7 @@ export {
MAX_SEARCH_COUNT,
normalizeFreshness,
normalizeToIsoDate,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,

View File

@@ -33,6 +33,8 @@ export {
getSandboxBackendManager,
registerSandboxBackend,
requireSandboxBackendFactory,
resolveWritableRenameTargets,
resolveWritableRenameTargetsForBridge,
runSshSandboxCommand,
shellEscape,
uploadDirectoryToSshTarget,

View File

@@ -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",

View File

@@ -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",

View 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 });
}
});
}

View File

@@ -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();
}
}

View 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),
};
}

View 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;
}

View File

@@ -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=");
});
});