test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
const handleAction = bluebubblesMessageActions.handleAction!;
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
handleAction({ channel: "bluebubbles", ...ctx });
const blueBubblesConfig = (): OpenClawConfig => ({
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
});
const runReactAction = async (params: Record<string, unknown>) => {
return await callHandleAction({
action: "react",
params,
cfg: blueBubblesConfig(),
accountId: null,
});
};
beforeEach(() => {
vi.clearAllMocks();
@@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
it("sends reaction successfully with chatGuid", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
@@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
it("sends reaction removal successfully", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
},
cfg,
accountId: null,
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(

View File

@@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => {
setBlueBubblesRuntime(runtimeStub);
});
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
const largeBuffer = new Uint8Array(params.bufferBytes);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
}),
).rejects.toThrow("too large");
}
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
@@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => {
});
it("throws when attachment exceeds max bytes", async () => {
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
await expectAttachmentTooLarge({
bufferBytes: 10 * 1024 * 1024,
maxBytes: 5 * 1024 * 1024,
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
maxBytes: 5 * 1024 * 1024,
}),
).rejects.toThrow("too large");
});
it("uses default max bytes when not specified", async () => {
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("too large");
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
});
it("uses attachment mimeType as fallback when response has no content-type", async () => {

View File

@@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
});
describe("chat", () => {
function mockOkTextResponse() {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
}
async function expectCalledUrlIncludesPassword(params: {
password: string;
invoke: () => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke();
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(`password=${params.password}`);
}
async function expectCalledUrlUsesConfigCredentials(params: {
serverHost: string;
password: string;
invoke: (cfg: {
channels: { bluebubbles: { serverUrl: string; password: string } };
}) => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke({
channels: {
bluebubbles: {
serverUrl: `http://${params.serverHost}`,
password: params.password,
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(params.serverHost);
expect(calledUrl).toContain(`password=${params.password}`);
}
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty or whitespace", async () => {
for (const chatGuid of ["", " "]) {
@@ -73,18 +111,14 @@ describe("chat", () => {
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
@@ -119,25 +153,14 @@ describe("chat", () => {
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
markBlueBubblesChatRead("chat-123", {
cfg,
}),
});
await markBlueBubblesChatRead("chat-123", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
});
@@ -536,18 +559,14 @@ describe("chat", () => {
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
@@ -582,25 +601,14 @@ describe("chat", () => {
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg,
}),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
it("includes filename in multipart body", async () => {

View File

@@ -19,6 +19,27 @@ describe("reactions", () => {
});
describe("sendBlueBubblesReaction", () => {
async function expectRemovedReaction(emoji: string) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji,
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
}
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
@@ -208,45 +229,11 @@ describe("reactions", () => {
});
it("sends reaction removal with dash prefix", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
await expectRemovedReaction("love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "-love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
await expectRemovedReaction("-love");
});
it("uses custom partIndex when provided", async () => {

View File

@@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) {
});
}
function mockNewChatSendResponse(guid: string) {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid },
}),
),
});
}
describe("send", () => {
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
@@ -453,20 +470,7 @@ describe("send", () => {
});
it("strips markdown when creating a new chat", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-stripped" },
}),
),
});
mockNewChatSendResponse("new-msg-stripped");
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
serverUrl: "http://localhost:1234",
@@ -483,20 +487,7 @@ describe("send", () => {
});
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
mockNewChatSendResponse("new-msg-guid");
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",

View File

@@ -1,10 +1,6 @@
import type {
OpenClawConfig,
PluginRuntime,
ResolvedLineAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
@@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function resolveAccount(
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
cfg: OpenClawConfig,

View File

@@ -4,9 +4,9 @@ import type {
OpenClawConfig,
PluginRuntime,
ResolvedLineAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
@@ -33,20 +33,10 @@ function createRuntime() {
return { runtime, probeLineBot, monitorLineProvider };
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: RuntimeEnv;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedLineAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",

View File

@@ -5,6 +5,12 @@ import path from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
import {
createWindowsCmdShimFixture,
restorePlatformPathEnv,
setProcessPlatform,
snapshotPlatformPathEnv,
} from "./test-helpers.js";
const spawnState = vi.hoisted(() => ({
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
@@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
};
}
function setProcessPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
describe("lobster plugin tool", () => {
let tempDir = "";
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalPath = process.env.PATH;
const originalPathAlt = process.env.Path;
const originalPathExt = process.env.PATHEXT;
const originalPathExtAlt = process.env.Pathext;
const originalProcessState = snapshotPlatformPathEnv();
beforeAll(async () => {
({ createLobsterTool } = await import("./lobster-tool.js"));
@@ -79,29 +74,7 @@ describe("lobster plugin tool", () => {
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalPathAlt === undefined) {
delete process.env.Path;
} else {
process.env.Path = originalPathAlt;
}
if (originalPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = originalPathExt;
}
if (originalPathExtAlt === undefined) {
delete process.env.Pathext;
} else {
process.env.Pathext = originalPathExtAlt;
}
restorePlatformPathEnv(originalProcessState);
});
afterAll(async () => {
@@ -156,17 +129,6 @@ describe("lobster plugin tool", () => {
});
};
const createWindowsShimFixture = async (params: {
shimPath: string;
scriptPath: string;
scriptToken: string;
}) => {
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
};
it("runs lobster and returns parsed envelope in details", async () => {
spawnState.queue.push({
stdout: JSON.stringify({
@@ -281,10 +243,10 @@ describe("lobster plugin tool", () => {
setProcessPlatform("win32");
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
await createWindowsShimFixture({
await createWindowsCmdShimFixture({
shimPath,
scriptPath: shimScriptPath,
scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
process.env.PATHEXT = ".CMD;.EXE";
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;

View File

@@ -0,0 +1,56 @@
import fs from "node:fs/promises";
import path from "node:path";
type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext";
const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const;
export type PlatformPathEnvSnapshot = {
platformDescriptor: PropertyDescriptor | undefined;
env: Record<PathEnvKey, string | undefined>;
};
export function setProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot {
return {
platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
env: {
PATH: process.env.PATH,
Path: process.env.Path,
PATHEXT: process.env.PATHEXT,
Pathext: process.env.Pathext,
},
};
}
export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void {
if (snapshot.platformDescriptor) {
Object.defineProperty(process, "platform", snapshot.platformDescriptor);
}
for (const key of PATH_ENV_KEYS) {
const value = snapshot.env[key];
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
export async function createWindowsCmdShimFixture(params: {
shimPath: string;
scriptPath: string;
shimLine: string;
}): Promise<void> {
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8");
}

View File

@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createWindowsCmdShimFixture,
restorePlatformPathEnv,
setProcessPlatform,
snapshotPlatformPathEnv,
} from "./test-helpers.js";
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
function setProcessPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
describe("resolveWindowsLobsterSpawn", () => {
let tempDir = "";
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalPath = process.env.PATH;
const originalPathAlt = process.env.Path;
const originalPathExt = process.env.PATHEXT;
const originalPathExtAlt = process.env.Pathext;
const originalProcessState = snapshotPlatformPathEnv();
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
@@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => {
});
afterEach(async () => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalPathAlt === undefined) {
delete process.env.Path;
} else {
process.env.Path = originalPathAlt;
}
if (originalPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = originalPathExt;
}
if (originalPathExtAlt === undefined) {
delete process.env.Pathext;
} else {
process.env.Pathext = originalPathExtAlt;
}
restorePlatformPathEnv(originalProcessState);
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
@@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => {
it("unwraps cmd shim with %dp0% token", async () => {
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(path.dirname(shimPath), { recursive: true });
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(
await createWindowsCmdShimFixture({
shimPath,
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
"utf8",
);
scriptPath,
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
expect(target.command).toBe(process.execPath);
@@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => {
it("unwraps cmd shim with %~dp0% token", async () => {
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(path.dirname(shimPath), { recursive: true });
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(
await createWindowsCmdShimFixture({
shimPath,
`@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
"utf8",
);
scriptPath,
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
expect(target.command).toBe(process.execPath);

View File

@@ -1,9 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { downloadMSTeamsAttachments } from "./attachments/download.js";
import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js";
import { buildMSTeamsMediaPayload } from "./attachments/payload.js";
import { setMSTeamsRuntime } from "./runtime.js";
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
@@ -52,7 +48,58 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
type AttachmentsModule = typeof import("./attachments.js");
type DownloadAttachmentsParams = Parameters<AttachmentsModule["downloadMSTeamsAttachments"]>[0];
type DownloadGraphMediaParams = Parameters<AttachmentsModule["downloadMSTeamsGraphMedia"]>[0];
const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123";
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_ALLOW_HOSTS = ["x"];
const createOkFetchMock = (contentType: string, payload = "png") =>
vi.fn(async () => {
return new Response(Buffer.from(payload), {
status: 200,
headers: { "content-type": contentType },
});
});
const buildDownloadParams = (
attachments: DownloadAttachmentsParams["attachments"],
overrides: Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn"> = {},
): DownloadAttachmentsParams => {
return {
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
const buildDownloadGraphParams = (
fetchFn: typeof fetch,
overrides: Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
> = {},
): DownloadGraphMediaParams => {
return {
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: DEFAULT_MAX_BYTES,
fetchFn,
...overrides,
};
};
describe("msteams attachments", () => {
const load = async () => {
return await import("./attachments.js");
};
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
@@ -62,11 +109,13 @@ describe("msteams attachments", () => {
describe("buildMSTeamsAttachmentPlaceholder", () => {
it("returns empty string when no attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
});
it("returns image placeholder for image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "image/png", contentUrl: "https://x/img.png" },
@@ -81,6 +130,7 @@ describe("msteams attachments", () => {
});
it("treats Teams file.download.info image attachments as images", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -92,6 +142,7 @@ describe("msteams attachments", () => {
});
it("returns document placeholder for non-image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
@@ -106,6 +157,7 @@ describe("msteams attachments", () => {
});
it("counts inline images in text/html attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -127,20 +179,13 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
@@ -149,50 +194,38 @@ describe("msteams attachments", () => {
});
it("supports Teams file.download.info downloadUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("downloads non-image file attachments (PDF)", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("application/pdf", "pdf");
detectMimeMock.mockResolvedValueOnce("application/pdf");
saveMediaBufferMock.mockResolvedValueOnce({
path: "/tmp/saved.pdf",
contentType: "application/pdf",
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
@@ -201,48 +234,42 @@ describe("msteams attachments", () => {
});
it("downloads inline image URLs from html attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
});
it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsAttachments } = await load();
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsAttachments({
attachments: [
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${base64}" />`,
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
});
]),
);
expect(media).toHaveLength(1);
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("retries with auth when the first request is unauthorized", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
const hasAuth = Boolean(headers.get("Authorization"));
@@ -255,21 +282,20 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
allowHosts: ["x"],
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("skips auth retries when the host is not in auth allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
@@ -283,17 +309,17 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
],
maxBytes: 1024 * 1024,
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }],
{
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
},
),
);
expect(media).toHaveLength(0);
expect(fetchMock).toHaveBeenCalled();
@@ -301,13 +327,15 @@ describe("msteams attachments", () => {
});
it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], {
allowHosts: ["graph.microsoft.com"],
resolveFn: undefined,
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
@@ -316,6 +344,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsGraphMessageUrls", () => {
it("builds channel message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
conversationId: "19:thread@thread.tacv2",
@@ -326,6 +355,7 @@ describe("msteams attachments", () => {
});
it("builds channel reply urls when replyToId is present", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
messageId: "reply-id",
@@ -338,6 +368,7 @@ describe("msteams attachments", () => {
});
it("builds chat message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "groupChat",
conversationId: "19:chat@thread.v2",
@@ -349,6 +380,7 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsGraphMedia", () => {
it("downloads hostedContents images", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const base64 = Buffer.from("png").toString("base64");
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
@@ -371,12 +403,9 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
@@ -384,6 +413,7 @@ describe("msteams attachments", () => {
});
it("merges SharePoint reference attachments with hosted content", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const hostedBase64 = Buffer.from("png").toString("base64");
const shareUrl = "https://contoso.sharepoint.com/site/file";
const fetchMock = vi.fn(async (url: string) => {
@@ -440,17 +470,15 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(2);
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const shareUrl = "https://contoso.sharepoint.com/site/file";
const escapedUrl = "https://evil.example/internal.pdf";
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
@@ -515,13 +543,11 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch, {
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
}),
);
expect(media.media).toHaveLength(0);
const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
@@ -534,6 +560,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", async () => {
const { buildMSTeamsMediaPayload } = await load();
const payload = buildMSTeamsMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },

View File

@@ -49,6 +49,28 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
const createNoopAdapter = (): MSTeamsAdapter => ({
continueConversation: async () => {},
process: async () => {},
});
const createRecordedSendActivity = (
sink: string[],
failFirstWithStatusCode?: number,
): ((activity: unknown) => Promise<{ id: string }>) => {
let attempts = 0;
return async (activity: unknown) => {
const { text } = activity as { text?: string };
const content = text ?? "";
sink.push(content);
attempts += 1;
if (failFirstWithStatusCode !== undefined && attempts === 1) {
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
}
return { id: `id:${content}` };
};
};
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
@@ -117,17 +139,9 @@ describe("msteams messenger", () => {
it("sends thread messages via the provided context", async () => {
const sent: string[] = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
sent.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(sent),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -149,11 +163,7 @@ describe("msteams messenger", () => {
continueConversation: async (_appId, reference, logic) => {
seen.reference = reference;
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
seen.texts.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
sendActivity: createRecordedSendActivity(seen.texts),
});
},
process: async () => {},
@@ -192,10 +202,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -242,20 +249,9 @@ describe("msteams messenger", () => {
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("throttled"), { statusCode: 429 });
}
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(attempts, 429),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -280,10 +276,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
await expect(
sendMSTeamsMessages({
@@ -303,18 +296,7 @@ describe("msteams messenger", () => {
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("server error"), {
statusCode: 503,
});
}
return { id: `id:${text ?? ""}` };
},
});
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
},
process: async () => {},
};

View File

@@ -204,6 +204,23 @@ describe("nostr-profile-http", () => {
});
describe("PUT /api/channels/nostr/:accountId/profile", () => {
async function expectPrivatePictureRejected(pictureUrl: string) {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: pictureUrl,
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
}
it("validates profile and publishes", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
@@ -263,37 +280,11 @@ describe("nostr-profile-http", () => {
});
it("rejects private IP in picture URL (SSRF protection)", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: "https://127.0.0.1/evil.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
});
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
});
it("rejects non-https URLs", async () => {

View File

@@ -23,14 +23,14 @@ async function settleTimers<T>(promise: Promise<T>): Promise<T> {
return promise;
}
function mockSuccessResponse() {
function mockResponse(statusCode: number, body: string) {
const httpsRequest = vi.mocked(https.request);
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
res.statusCode = statusCode;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from('{"success":true}'));
res.emit("data", Buffer.from(body));
res.emit("end");
});
const req = new EventEmitter() as any;
@@ -41,22 +41,12 @@ function mockSuccessResponse() {
});
}
function mockSuccessResponse() {
mockResponse(200, '{"success":true}');
}
function mockFailureResponse(statusCode = 500) {
const httpsRequest = vi.mocked(https.request);
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = statusCode;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from("error"));
res.emit("end");
});
const req = new EventEmitter() as any;
req.write = vi.fn();
req.end = vi.fn();
req.destroy = vi.fn();
return req;
});
mockResponse(statusCode, "error");
}
describe("sendMessage", () => {

View File

@@ -80,6 +80,24 @@ describe("createWebhookHandler", () => {
};
});
async function expectForbiddenByPolicy(params: {
account: Partial<ResolvedSynologyChatAccount>;
bodyContains: string;
}) {
const handler = createWebhookHandler({
account: makeAccount(params.account),
deliver: vi.fn(),
log,
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain(params.bodyContains);
}
it("rejects non-POST methods with 405", async () => {
const handler = createWebhookHandler({
account: makeAccount(),
@@ -129,36 +147,20 @@ describe("createWebhookHandler", () => {
});
it("returns 403 for unauthorized user with allowlist policy", async () => {
const handler = createWebhookHandler({
account: makeAccount({
await expectForbiddenByPolicy({
account: {
dmPolicy: "allowlist",
allowedUserIds: ["456"],
}),
deliver: vi.fn(),
log,
},
bodyContains: "not authorized",
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain("not authorized");
});
it("returns 403 when DMs are disabled", async () => {
const handler = createWebhookHandler({
account: makeAccount({ dmPolicy: "disabled" }),
deliver: vi.fn(),
log,
await expectForbiddenByPolicy({
account: { dmPolicy: "disabled" },
bodyContains: "disabled",
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain("disabled");
});
it("returns 429 when rate limited", async () => {

View File

@@ -4,9 +4,9 @@ import type {
OpenClawConfig,
PluginRuntime,
ResolvedTelegramAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { telegramPlugin } from "./channel.js";
import { setTelegramRuntime } from "./runtime.js";
@@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig {
} as OpenClawConfig;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function createStartAccountCtx(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedTelegramAccount> {
const account = telegramPlugin.config.resolveAccount(
params.cfg,

View File

@@ -0,0 +1,12 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { vi } from "vitest";
export function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}

View File

@@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => {
channel: "testchannel",
};
function runAccessCheck(params: {
account?: Partial<TwitchAccountConfig>;
message?: Partial<TwitchChatMessage>;
}) {
return checkTwitchAccessControl({
message: {
...mockMessage,
...params.message,
},
account: {
...mockAccount,
...params.account,
},
botUsername: "testbot",
});
}
function expectSingleRoleAllowed(params: {
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
message: Partial<TwitchChatMessage>;
}) {
const result = runAccessCheck({
account: { allowedRoles: [params.role] },
message: {
message: "@testbot hello",
...params.message,
},
});
expect(result.allowed).toBe(true);
return result;
}
describe("when no restrictions are configured", () => {
it("allows messages that mention the bot (default requireMention)", () => {
const message: TwitchChatMessage = {
@@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => {
describe("allowedRoles", () => {
it("allows users with matching role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("role");
});
@@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => {
});
it("handles moderator role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
expect(result.allowed).toBe(true);
});
it("handles subscriber role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["subscriber"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isSub: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "subscriber",
message: { isSub: true },
});
expect(result.allowed).toBe(true);
});
it("handles owner role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "owner",
message: { isOwner: true },
});
expect(result.allowed).toBe(true);
});
it("handles vip role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["vip"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isVip: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "vip",
message: { isVip: true },
});
expect(result.allowed).toBe(true);
});
});
@@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => {
});
it("checks allowlist before allowedRoles", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = runAccessCheck({
account: {
allowFrom: ["123456"],
allowedRoles: ["owner"],
},
message: {
message: "@testbot hello",
isOwner: false,
},
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("allowlist");

View File

@@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: {
};
}
function createRejectingInboundContext(): {
ctx: CallManagerContext;
hangupCalls: HangupCallInput[];
} {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
return { ctx, hangupCalls };
}
describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const { ctx, hangupCalls } = createRejectingInboundContext();
const event = createInboundInitiatedEvent({
id: "evt-1",
providerCallId: "prov-1",
@@ -118,16 +125,7 @@ describe("processEvent (functional)", () => {
});
it("calls hangup only once for duplicate events for same rejected call", () => {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const { ctx, hangupCalls } = createRejectingInboundContext();
const event1 = createInboundInitiatedEvent({
id: "evt-init",
providerCallId: "prov-dup",

View File

@@ -1,16 +1,11 @@
import os from "node:os";
import path from "node:path";
import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk";
import type { PromptRequest } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
function createConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
describe("acp prompt cwd prefix", () => {
async function runPromptWithCwd(cwd: string) {
@@ -33,14 +28,14 @@ describe("acp prompt cwd prefix", () => {
}
return {};
});
const gateway = {
request: requestSpy,
} as unknown as GatewayClient;
const agent = new AcpGatewayAgent(createConnection(), gateway, {
sessionStore,
prefixCwd: true,
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
prefixCwd: true,
},
);
try {
await expect(

View File

@@ -1,5 +1,4 @@
import type {
AgentSideConnection,
LoadSessionRequest,
NewSessionRequest,
PromptRequest,
@@ -8,20 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
function createConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
function createGateway(
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
): GatewayClient {
return {
request,
} as unknown as GatewayClient;
}
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest {
return {
@@ -55,7 +41,7 @@ function createPromptRequest(
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(request), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest(params.sessionId));
@@ -74,7 +60,7 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
describe("acp session creation rate limit", () => {
it("rate limits excessive newSession bursts", async () => {
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
sessionStore,
sessionCreateRateLimit: {
maxRequests: 2,
@@ -93,7 +79,7 @@ describe("acp session creation rate limit", () => {
it("does not count loadSession refreshes for an existing session ID", async () => {
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
sessionStore,
sessionCreateRateLimit: {
maxRequests: 1,

View File

@@ -0,0 +1,17 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
export function createAcpConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
export function createAcpGateway(
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
): GatewayClient {
return {
request,
} as unknown as GatewayClient;
}

View File

@@ -46,28 +46,33 @@ function pollStatus(result: Awaited<ReturnType<ReturnType<typeof createProcessTo
return (result.details as { status?: string }).status;
}
test("process poll waits for completion when timeout is provided", async () => {
async function expectCompletedPollWithTimeout(params: {
sessionId: string;
callId: string;
timeout: number | string;
advanceMs: number;
assertUnresolvedAtMs?: number;
}) {
vi.useFakeTimers();
try {
const sessionId = "sess";
const { processTool, session } = createProcessSessionHarness(sessionId);
const { processTool, session } = createProcessSessionHarness(params.sessionId);
setTimeout(() => {
appendOutput(session, "stdout", "done\n");
markExited(session, 0, null, "completed");
}, 10);
const pollPromise = pollSession(processTool, "toolcall", sessionId, 2000);
const pollPromise = pollSession(processTool, params.callId, params.sessionId, params.timeout);
if (params.assertUnresolvedAtMs !== undefined) {
let resolved = false;
void pollPromise.finally(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(params.assertUnresolvedAtMs);
expect(resolved).toBe(false);
}
let resolved = false;
void pollPromise.finally(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(200);
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(params.advanceMs);
const poll = await pollPromise;
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
@@ -75,27 +80,25 @@ test("process poll waits for completion when timeout is provided", async () => {
} finally {
vi.useRealTimers();
}
}
test("process poll waits for completion when timeout is provided", async () => {
await expectCompletedPollWithTimeout({
sessionId: "sess",
callId: "toolcall",
timeout: 2000,
assertUnresolvedAtMs: 200,
advanceMs: 100,
});
});
test("process poll accepts string timeout values", async () => {
vi.useFakeTimers();
try {
const sessionId = "sess-2";
const { processTool, session } = createProcessSessionHarness(sessionId);
setTimeout(() => {
appendOutput(session, "stdout", "done\n");
markExited(session, 0, null, "completed");
}, 10);
const pollPromise = pollSession(processTool, "toolcall", sessionId, "2000");
await vi.advanceTimersByTimeAsync(350);
const poll = await pollPromise;
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("done");
} finally {
vi.useRealTimers();
}
await expectCompletedPollWithTimeout({
sessionId: "sess-2",
callId: "toolcall",
timeout: "2000",
advanceMs: 350,
});
});
test("process poll exposes adaptive retryInMs for repeated no-output polls", async () => {

View File

@@ -35,6 +35,18 @@ async function resolveBedrockProvider() {
});
}
async function expectBedrockAuthSource(params: {
env: Record<string, string | undefined>;
expectedSource: string;
}) {
await withEnvAsync(params.env, async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain(params.expectedSource);
});
}
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth-profiles.json", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
@@ -226,57 +238,39 @@ describe("getApiKeyForModel", () => {
});
it("prefers Bedrock bearer token over access keys and profile", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token",
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
},
);
expectedSource: "AWS_BEARER_TOKEN_BEDROCK",
});
});
it("prefers Bedrock access keys over profile", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
},
);
expectedSource: "AWS_ACCESS_KEY_ID",
});
});
it("uses Bedrock profile when access keys are missing", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_PROFILE");
},
);
expectedSource: "AWS_PROFILE",
});
});
it("accepts VOYAGE_API_KEY for voyage", async () => {

View File

@@ -107,6 +107,60 @@ function createOverrideFailureRun(params: {
});
}
function makeSingleProviderStore(params: {
provider: string;
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
}): AuthProfileStore {
const profileId = `${params.provider}:default`;
return {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider: params.provider,
key: "test-key",
},
},
usageStats: {
[profileId]: params.usageStat,
},
};
}
function createFallbackOnlyRun() {
return vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
});
}
async function expectSkippedUnavailableProvider(params: {
providerPrefix: string;
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
expectedReason: string;
}) {
const provider = `${params.providerPrefix}-${crypto.randomUUID()}`;
const cfg = makeProviderFallbackCfg(provider);
const store = makeSingleProviderStore({
provider,
usageStat: params.usageStat,
});
const run = createFallbackOnlyRun();
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe(params.expectedReason);
}
describe("runWithModelFallback", () => {
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
const cfg = makeCfg();
@@ -310,86 +364,26 @@ describe("runWithModelFallback", () => {
});
it("skips providers when all profiles are in cooldown", async () => {
const provider = `cooldown-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "test-key",
},
await expectSkippedUnavailableProvider({
providerPrefix: "cooldown-test",
usageStat: {
cooldownUntil: Date.now() + 5 * 60_000,
},
usageStats: {
[profileId]: {
cooldownUntil: Date.now() + 5 * 60_000,
},
},
};
const cfg = makeProviderFallbackCfg(provider);
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
expectedReason: "rate_limit",
});
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe("rate_limit");
});
it("propagates disabled reason when all profiles are unavailable", async () => {
const provider = `disabled-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;
const now = Date.now();
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "test-key",
},
await expectSkippedUnavailableProvider({
providerPrefix: "disabled-test",
usageStat: {
disabledUntil: now + 5 * 60_000,
disabledReason: "billing",
failureCounts: { rate_limit: 4 },
},
usageStats: {
[profileId]: {
disabledUntil: now + 5 * 60_000,
disabledReason: "billing",
failureCounts: { rate_limit: 4 },
},
},
};
const cfg = makeProviderFallbackCfg(provider);
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
expectedReason: "billing",
});
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe("billing");
});
it("does not skip when any profile is available", async () => {

View File

@@ -10,6 +10,7 @@ import {
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
@@ -34,11 +35,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(validated.config);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { api?: string; models?: Array<{ id: string; api?: string }> }>;
};
}>();
expect(parsed.providers.anthropic?.api).toBe("anthropic-messages");
expect(parsed.providers.anthropic?.models?.[0]?.api).toBe("anthropic-messages");
@@ -74,11 +73,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(cfg);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
};
}>();
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-VL-01");

View File

@@ -1,10 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
describe("models-config", () => {
installModelsConfigTestHooks();
@@ -47,11 +45,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(cfg);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { models: Array<{ id: string }> }>;
};
}>();
const ids = parsed.providers.google?.models?.map((model) => model.id);
expect(ids).toEqual(["gemini-3-pro-preview", "gemini-3-flash-preview"]);
});

View File

@@ -0,0 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
export async function readGeneratedModelsJson<T>(): Promise<T> {
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
return JSON.parse(raw) as T;
}

View File

@@ -280,107 +280,105 @@ describe("parseNdjsonStream", () => {
});
});
async function withMockNdjsonFetch(
lines: string[],
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
): Promise<void> {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = lines.join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await run(fetchMock);
} finally {
globalThis.fetch = originalFetch;
}
}
async function createOllamaTestStream(params: {
baseUrl: string;
options?: { maxTokens?: number; signal?: AbortSignal };
}) {
const streamFn = createOllamaStreamFn(params.baseUrl);
return streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
(params.options ?? {}) as unknown as Parameters<typeof streamFn>[2],
);
}
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
const events: T[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe("createOllamaStreamFn", () => {
it("normalizes /v1 baseUrl and maps maxTokens + signal", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = [
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
].join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
],
async (fetchMock) => {
const signal = new AbortController().signal;
const stream = await createOllamaTestStream({
baseUrl: "http://ollama-host:11434/v1/",
options: { maxTokens: 123, signal },
});
try {
const streamFn = createOllamaStreamFn("http://ollama-host:11434/v1/");
const signal = new AbortController().signal;
const stream = await streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
{
maxTokens: 123,
signal,
} as unknown as Parameters<typeof streamFn>[2],
);
const events = await collectStreamEvents(stream);
expect(events.at(-1)?.type).toBe("done");
const events = [];
for await (const event of stream) {
events.push(event);
}
expect(events.at(-1)?.type).toBe("done");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("http://ollama-host:11434/api/chat");
expect(requestInit.signal).toBe(signal);
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("http://ollama-host:11434/api/chat");
expect(requestInit.signal).toBe(signal);
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as {
options: { num_ctx?: number; num_predict?: number };
};
expect(requestBody.options.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
} finally {
globalThis.fetch = originalFetch;
}
const requestBody = JSON.parse(requestInit.body) as {
options: { num_ctx?: number; num_predict?: number };
};
expect(requestBody.options.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
},
);
});
it("accumulates reasoning chunks when content is empty", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = [
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":" output"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":2}',
].join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
],
async () => {
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
const events = await collectStreamEvents(stream);
try {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const stream = await streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
{} as unknown as Parameters<typeof streamFn>[2],
);
const doneEvent = events.at(-1);
if (!doneEvent || doneEvent.type !== "done") {
throw new Error("Expected done event");
}
const events = [];
for await (const event of stream) {
events.push(event);
}
const doneEvent = events.at(-1);
if (!doneEvent || doneEvent.type !== "done") {
throw new Error("Expected done event");
}
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
} finally {
globalThis.fetch = originalFetch;
}
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
},
);
});
});

View File

@@ -43,17 +43,13 @@ vi.mock("../usage.js", () => ({
normalizeUsage: vi.fn((usage?: unknown) =>
usage && typeof usage === "object" ? usage : undefined,
),
derivePromptTokens: vi.fn(
(usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => {
if (!usage) {
return undefined;
}
const input = usage.input ?? 0;
const cacheRead = usage.cacheRead ?? 0;
const cacheWrite = usage.cacheWrite ?? 0;
const sum = input + cacheRead + cacheWrite;
return sum > 0 ? sum : undefined;
},
derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) =>
usage
? (() => {
const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
return sum > 0 ? sum : undefined;
})()
: undefined,
),
hasNonzeroUsage: vi.fn(() => false),
}));

View File

@@ -78,6 +78,21 @@ async function emitPngMediaToolResult(
});
}
async function emitUntrustedToolMediaResult(
ctx: EmbeddedPiSubscribeContext,
mediaPathOrUrl: string,
) {
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: `MEDIA:${mediaPathOrUrl}` }],
},
});
}
describe("handleToolExecutionEnd media emission", () => {
it("does not warn for read tool when path is provided via file_path alias", async () => {
const ctx = createMockContext();
@@ -107,15 +122,7 @@ describe("handleToolExecutionEnd media emission", () => {
const onToolResult = vi.fn();
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }],
},
});
await emitUntrustedToolMediaResult(ctx, "/tmp/secret.png");
expect(onToolResult).not.toHaveBeenCalled();
});
@@ -124,15 +131,7 @@ describe("handleToolExecutionEnd media emission", () => {
const onToolResult = vi.fn();
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }],
},
});
await emitUntrustedToolMediaResult(ctx, "https://example.com/file.png");
expect(onToolResult).toHaveBeenCalledWith({
mediaUrls: ["https://example.com/file.png"],

View File

@@ -41,6 +41,24 @@ describe("subscribeEmbeddedPiSession", () => {
return { emit, subscription };
}
function createWriteFailureHarness(params: {
runId: string;
path: string;
content: string;
}): ReturnType<typeof createToolErrorHarness> {
const harness = createToolErrorHarness(params.runId);
emitToolRun({
emit: harness.emit,
toolName: "write",
toolCallId: "w1",
args: { path: params.path, content: params.content },
isError: true,
result: { error: "disk full" },
});
expect(harness.subscription.getLastToolError()?.toolName).toBe("write");
return harness;
}
function emitToolRun(params: {
emit: (evt: unknown) => void;
toolName: string;
@@ -389,17 +407,11 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("keeps unresolved mutating failure when an unrelated tool succeeds", () => {
const { emit, subscription } = createToolErrorHarness("run-tools-1");
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
isError: true,
result: { error: "disk full" },
const { emit, subscription } = createWriteFailureHarness({
runId: "run-tools-1",
path: "/tmp/demo.txt",
content: "next",
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
emitToolRun({
emit,
@@ -414,17 +426,11 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("clears unresolved mutating failure when the same action succeeds", () => {
const { emit, subscription } = createToolErrorHarness("run-tools-2");
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
isError: true,
result: { error: "disk full" },
const { emit, subscription } = createWriteFailureHarness({
runId: "run-tools-2",
path: "/tmp/demo.txt",
content: "next",
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
emitToolRun({
emit,

View File

@@ -113,6 +113,34 @@ describe("Agent-specific tool filtering", () => {
};
}
function createExecHostDefaultsConfig(
agents: Array<{ id: string; execHost?: "gateway" | "sandbox" }>,
): OpenClawConfig {
return {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: agents.map((agent) => ({
id: agent.id,
...(agent.execHost
? {
tools: {
exec: {
host: agent.execHost,
},
},
}
: {}),
})),
},
};
}
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg = createMainAgentConfig({
tools: {
@@ -646,30 +674,10 @@ describe("Agent-specific tool filtering", () => {
});
it("should apply agent-specific exec host defaults over global defaults", async () => {
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: [
{
id: "main",
tools: {
exec: {
host: "gateway",
},
},
},
{
id: "helper",
},
],
},
};
const cfg = createExecHostDefaultsConfig([
{ id: "main", execHost: "gateway" },
{ id: "helper" },
]);
const mainTools = createOpenClawCodingTools({
config: cfg,
@@ -716,27 +724,7 @@ describe("Agent-specific tool filtering", () => {
});
it("applies explicit agentId exec defaults when sessionKey is opaque", async () => {
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: [
{
id: "main",
tools: {
exec: {
host: "gateway",
},
},
},
],
},
};
const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]);
const tools = createOpenClawCodingTools({
config: cfg,

View File

@@ -18,13 +18,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
vi.mock("../shared/config-eval.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../shared/config-eval.js")>();

View File

@@ -5,10 +5,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import { createTempHomeEnv } from "../test-utils/temp-home.js";
import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js";
import { installSkill } from "./skills-install.js";
const runCommandWithTimeoutMock = vi.fn();
const scanDirectoryWithSummaryMock = vi.fn();
const fetchWithSsrFGuardMock = vi.fn();
import {
fetchWithSsrFGuardMock,
runCommandWithTimeoutMock,
scanDirectoryWithSummaryMock,
} from "./skills-install.test-mocks.js";
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
@@ -18,13 +19,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
async function fileExists(filePath: string): Promise<boolean> {
try {
@@ -77,8 +75,8 @@ function mockTarExtractionFlow(params: {
verboseListOutput: string;
extract: "ok" | "reject";
}) {
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
const cmd = (argv[0] ?? []) as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return runCommandResult({ stdout: params.listOutput });
}

View File

@@ -12,13 +12,10 @@ vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
async function writeInstallableSkill(workspaceDir: string, name: string): Promise<string> {
const skillDir = path.join(workspaceDir, "skills", name);

View File

@@ -1,31 +1,24 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
let fixtureRoot = "";
let fixtureCount = 0;
async function createCaseDir(prefix: string): Promise<string> {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
const fixtureSuite = createFixtureSuite("openclaw-skills-prompt-suite-");
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-prompt-suite-"));
await fixtureSuite.setup();
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
await fixtureSuite.cleanup();
});
describe("buildWorkspaceSkillsPrompt", () => {
it("prefers workspace skills over managed skills", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const managedSkillDir = path.join(managedDir, "demo-skill");
@@ -62,7 +55,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).not.toContain(path.join(bundledSkillDir, "SKILL.md"));
});
it("gates by bins, config, and always", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const skillsDir = path.join(workspaceDir, "skills");
const binDir = path.join(workspaceDir, "bin");
@@ -130,7 +123,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(gatedPrompt).not.toContain("config-skill");
});
it("uses skillKey for config lookups", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const skillDir = path.join(workspaceDir, "skills", "alias-skill");
await writeSkill({
dir: skillDir,

View File

@@ -59,106 +59,92 @@ vi.mock("./subagent-registry.js", () => ({
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
type AnnounceFlowParams = Parameters<typeof runSubagentAnnounceFlow>[0];
const defaultSessionConfig = {
mainKey: "main",
scope: "per-sender",
} as const;
const baseAnnounceFlowParams = {
childSessionKey: "agent:main:subagent:worker",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" as const },
} satisfies Omit<AnnounceFlowParams, "childRunId">;
function setConfiguredAnnounceTimeout(timeoutMs: number): void {
configOverride = {
session: defaultSessionConfig,
agents: {
defaults: {
subagents: {
announceTimeoutMs: timeoutMs,
},
},
},
};
}
async function runAnnounceFlowForTest(
childRunId: string,
overrides: Partial<AnnounceFlowParams> = {},
): Promise<void> {
await runSubagentAnnounceFlow({
...baseAnnounceFlowParams,
childRunId,
...overrides,
});
}
function findGatewayCall(predicate: (call: GatewayCall) => boolean): GatewayCall | undefined {
return gatewayCalls.find(predicate);
}
describe("subagent announce timeout config", () => {
beforeEach(() => {
gatewayCalls.length = 0;
sessionStore = {};
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: defaultSessionConfig,
};
});
it("uses 60s timeout by default for direct announce agent call", async () => {
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-default-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
});
await runAnnounceFlowForTest("run-default-timeout");
const directAgentCall = gatewayCalls.find(
const directAgentCall = findGatewayCall(
(call) => call.method === "agent" && call.expectFinal === true,
);
expect(directAgentCall?.timeoutMs).toBe(60_000);
});
it("honors configured announce timeout for direct announce agent call", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: {
announceTimeoutMs: 90_000,
},
},
},
};
setConfiguredAnnounceTimeout(90_000);
await runAnnounceFlowForTest("run-config-timeout-agent");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-config-timeout-agent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
});
const directAgentCall = gatewayCalls.find(
const directAgentCall = findGatewayCall(
(call) => call.method === "agent" && call.expectFinal === true,
);
expect(directAgentCall?.timeoutMs).toBe(90_000);
});
it("honors configured announce timeout for completion direct send call", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: {
announceTimeoutMs: 90_000,
},
},
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-config-timeout-send",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
setConfiguredAnnounceTimeout(90_000);
await runAnnounceFlowForTest("run-config-timeout-send", {
requesterOrigin: {
channel: "discord",
to: "12345",
},
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
expectsCompletionMessage: true,
});
const sendCall = gatewayCalls.find((call) => call.method === "send");
const sendCall = findGatewayCall((call) => call.method === "send");
expect(sendCall?.timeoutMs).toBe(90_000);
});
});

View File

@@ -58,6 +58,7 @@ vi.mock("./subagent-registry.store.js", () => ({
describe("subagent registry steer restarts", () => {
let mod: typeof import("./subagent-registry.js");
type RegisterSubagentRunInput = Parameters<typeof mod.registerSubagentRun>[0];
beforeAll(async () => {
mod = await import("./subagent-registry.js");
@@ -90,6 +91,42 @@ describe("subagent registry steer restarts", () => {
}
};
const createDeferredAnnounceResolver = (): ((value: boolean) => void) => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
);
return (value: boolean) => {
resolveAnnounce(value);
};
};
const registerCompletionModeRun = (
runId: string,
childSessionKey: string,
task: string,
options: Partial<Pick<RegisterSubagentRunInput, "spawnMode">> = {},
): void => {
mod.registerSubagentRun({
runId,
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task,
cleanup: "keep",
expectsCompletionMessage: true,
...options,
});
};
afterEach(async () => {
announceSpy.mockClear();
announceSpy.mockResolvedValue(true);
@@ -159,29 +196,13 @@ describe("subagent registry steer restarts", () => {
it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => {
await withPendingAgentWait(async () => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
const resolveAnnounce = createDeferredAnnounceResolver();
registerCompletionModeRun(
"run-completion-delayed",
"agent:main:subagent:completion-delayed",
"completion-mode task",
);
mod.registerSubagentRun({
runId: "run-completion-delayed",
childSessionKey: "agent:main:subagent:completion-delayed",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task: "completion-mode task",
cleanup: "keep",
expectsCompletionMessage: true,
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-completion-delayed",
@@ -211,30 +232,14 @@ describe("subagent registry steer restarts", () => {
it("does not emit subagent_ended on completion for persistent session-mode runs", async () => {
await withPendingAgentWait(async () => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
const resolveAnnounce = createDeferredAnnounceResolver();
registerCompletionModeRun(
"run-persistent-session",
"agent:main:subagent:persistent-session",
"persistent session task",
{ spawnMode: "session" },
);
mod.registerSubagentRun({
runId: "run-persistent-session",
childSessionKey: "agent:main:subagent:persistent-session",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task: "persistent session task",
cleanup: "keep",
expectsCompletionMessage: true,
spawnMode: "session",
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-persistent-session",

View File

@@ -32,53 +32,46 @@ async function runReplyToCurrentCase(home: string, text: string) {
return Array.isArray(res) ? res[0] : res;
}
async function expectThinkStatusForReasoningModel(params: {
reasoning: boolean;
expectedLevel: "low" | "off";
}): Promise<void> {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: params.reasoning,
},
]);
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
);
const text = replyText(res);
expect(text).toContain(`Current thinking level: ${params.expectedLevel}`);
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("defaults /think to low for reasoning-capable models when no default set", async () => {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
);
const text = replyText(res);
expect(text).toContain("Current thinking level: low");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
await expectThinkStatusForReasoningModel({
reasoning: true,
expectedLevel: "low",
});
});
it("shows off when /think has no argument and model lacks reasoning", async () => {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: false,
},
]);
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
);
const text = replyText(res);
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
await expectThinkStatusForReasoningModel({
reasoning: false,
expectedLevel: "off",
});
});
it("persists /reasoning off on discord even when model defaults reasoning on", async () => {

View File

@@ -5,46 +5,19 @@ import {
installDirectiveBehaviorE2EHooks,
loadModelCatalog,
makeWhatsAppDirectiveConfig,
replyText,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js";
import { getReplyFromConfig } from "./reply.js";
async function runModelDirective(
home: string,
body: string,
options: {
defaults?: Record<string, unknown>;
extra?: Record<string, unknown>;
} = {},
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(
home,
{
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
...options.defaults,
},
{ session: { store: sessionStorePath(home) }, ...options.extra },
),
);
return replyText(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("aliases /model list to /models", async () => {
await withTempHome(async (home) => {
const text = await runModelDirective(home, "/model list");
const text = await runModelDirectiveText(home, "/model list");
expect(text).toContain("Providers:");
expect(text).toContain("- anthropic");
expect(text).toContain("- openai");
@@ -56,7 +29,7 @@ describe("directive behavior", () => {
it("shows current model when catalog is unavailable", async () => {
await withTempHome(async (home) => {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
const text = await runModelDirective(home, "/model");
const text = await runModelDirectiveText(home, "/model");
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
@@ -71,7 +44,7 @@ describe("directive behavior", () => {
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "grok-4", name: "Grok 4", provider: "xai" },
]);
const text = await runModelDirective(home, "/model list", {
const text = await runModelDirectiveText(home, "/model list", {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
@@ -101,7 +74,7 @@ describe("directive behavior", () => {
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
]);
const text = await runModelDirective(home, "/models minimax", {
const text = await runModelDirectiveText(home, "/models minimax", {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},
@@ -129,7 +102,7 @@ describe("directive behavior", () => {
});
it("does not repeat missing auth labels on /model list", async () => {
await withTempHome(async (home) => {
const text = await runModelDirective(home, "/model list", {
const text = await runModelDirectiveText(home, "/model list", {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},

View File

@@ -0,0 +1,39 @@
import {
makeWhatsAppDirectiveConfig,
replyText,
sessionStorePath,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
export async function runModelDirectiveText(
home: string,
body: string,
options: {
defaults?: Record<string, unknown>;
extra?: Record<string, unknown>;
includeSessionStore?: boolean;
} = {},
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(
home,
{
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
...options.defaults,
},
{
...(options.includeSessionStore === false
? {}
: { session: { store: sessionStorePath(home) } }),
...options.extra,
},
),
);
return replyText(res);
}

View File

@@ -5,12 +5,12 @@ import {
installDirectiveBehaviorE2EHooks,
makeEmbeddedTextResult,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js";
import { getReplyFromConfig } from "./reply.js";
function makeRunConfig(home: string, storePath: string) {
@@ -69,24 +69,6 @@ async function runInFlightVerboseToggleCase(params: {
return { res };
}
async function runModelDirectiveAndGetText(
home: string,
body: string,
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: body, From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, {
model: { primary: "anthropic/claude-opus-4-5" },
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}),
);
return replyText(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
@@ -119,7 +101,7 @@ describe("directive behavior", () => {
});
it("shows summary on /model", async () => {
await withTempHome(async (home) => {
const text = await runModelDirectiveAndGetText(home, "/model");
const text = await runModelDirectiveText(home, "/model", { includeSessionStore: false });
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
@@ -130,7 +112,9 @@ describe("directive behavior", () => {
});
it("lists allowlisted models on /model status", async () => {
await withTempHome(async (home) => {
const text = await runModelDirectiveAndGetText(home, "/model status");
const text = await runModelDirectiveText(home, "/model status", {
includeSessionStore: false,
});
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");

View File

@@ -1,21 +1,19 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
installTriggerHandlingReplyHarness,
makeCfg,
runGreetingPromptForBareNewOrReset,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js"));
installTriggerHandlingReplyHarness((loader) => {
getReplyFromConfig = loader;
});
installTriggerHandlingE2eTestHooks();
async function expectResetBlockedForNonOwner(params: {
home: string;
commandAuthorized: boolean;
@@ -68,6 +66,7 @@ describe("trigger handling", () => {
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
});
});
it("injects group activation context into the system prompt", async () => {
await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({
@@ -112,16 +111,19 @@ describe("trigger handling", () => {
expect(extra).toContain("Activation: always-on");
});
});
it("runs a greeting prompt for a bare /reset", async () => {
await withTempHome(async (home) => {
await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig });
});
});
it("runs a greeting prompt for a bare /new", async () => {
await withTempHome(async (home) => {
await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig });
});
});
it("does not reset for unauthorized /reset", async () => {
await withTempHome(async (home) => {
await expectResetBlockedForNonOwner({
@@ -131,6 +133,7 @@ describe("trigger handling", () => {
});
});
});
it("blocks /reset for non-owner senders", async () => {
await withTempHome(async (home) => {
await expectResetBlockedForNonOwner({

View File

@@ -222,6 +222,17 @@ export async function loadGetReplyFromConfig() {
return (await import("./reply.js")).getReplyFromConfig;
}
export function installTriggerHandlingReplyHarness(
setGetReplyFromConfig: (
getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig,
) => void,
): void {
beforeAll(async () => {
setGetReplyFromConfig(await loadGetReplyFromConfig());
});
installTriggerHandlingE2eTestHooks();
}
export function requireSessionStorePath(cfg: { session?: { store?: string } }): string {
const storePath = cfg.session?.store;
if (!storePath) {

View File

@@ -144,6 +144,17 @@ describe("chrome extension relay server", () => {
envSnapshot.restore();
});
async function startRelayWithExtension() {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
return { port, ext };
}
it("advertises CDP WS only when extension is connected", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
@@ -269,14 +280,7 @@ describe("chrome extension relay server", () => {
it(
"tracks attached page targets and exposes them via CDP + /json/list",
async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
const { port, ext } = await startRelayWithExtension();
// Simulate a tab attach coming from the extension.
ext.send(
@@ -391,14 +395,7 @@ describe("chrome extension relay server", () => {
);
it("rebroadcasts attach when a session id is reused for a new target", async () => {
const port = await getFreePort();
cdpUrl = `http://127.0.0.1:${port}`;
await ensureChromeExtensionRelayServer({ cdpUrl });
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext);
const { port, ext } = await startRelayWithExtension();
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),

View File

@@ -74,6 +74,27 @@ function createSequentialPageLister<T>(responses: T[]) {
});
}
type JsonListEntry = {
id: string;
title: string;
url: string;
webSocketDebuggerUrl: string;
type: "page";
};
function createJsonListFetchMock(entries: JsonListEntry[]) {
return vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => entries,
} as unknown as Response;
});
}
describe("browser server-context remote profile tab operations", () => {
it("uses Playwright tab operations when available", async () => {
const listPagesViaPlaywright = vi.fn(async () => [
@@ -238,24 +259,15 @@ describe("browser server-context remote profile tab operations", () => {
it("falls back to /json/list when Playwright is not available", async () => {
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => [
{
id: "T1",
title: "Tab 1",
url: "https://example.com",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
type: "page",
},
],
} as unknown as Response;
});
const fetchMock = createJsonListFetchMock([
{
id: "T1",
title: "Tab 1",
url: "https://example.com",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
type: "page",
},
]);
const { remote } = createRemoteRouteHarness(fetchMock);
@@ -271,24 +283,15 @@ describe("browser server-context tab selection state", () => {
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
return {
ok: true,
json: async () => [
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
],
} as unknown as Response;
});
const fetchMock = createJsonListFetchMock([
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
]);
global.fetch = withFetchPreconnect(fetchMock);

View File

@@ -148,18 +148,4 @@ const baseRegistry = createTestRegistry([
{ pluginId: "slack", source: "test", plugin: createStubPlugin("slack") },
]);
const createMSTeamsPlugin = (): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
});
const createMSTeamsPlugin = (): ChannelPlugin => createStubPlugin("msteams");

View File

@@ -536,6 +536,22 @@ const FINAL_BACKLOG_TARGET_KEYS = [
] as const;
describe("config help copy quality", () => {
function expectOperationalGuidance(
keys: readonly string[],
guidancePattern: RegExp,
minLength = 80,
) {
for (const key of keys) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(minLength);
expect(
guidancePattern.test(help),
`help should include operational guidance for ${key}`,
).toBe(true);
}
}
it("keeps root section labels and help complete", () => {
for (const key of ROOT_SECTIONS) {
expect(FIELD_LABELS[key], `missing root label for ${key}`).toBeDefined();
@@ -550,57 +566,31 @@ describe("config help copy quality", () => {
});
it("covers the target confusing fields with non-trivial explanations", () => {
for (const key of TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i.test(help),
`help should include operational guidance for ${key}`,
).toBe(true);
}
expectOperationalGuidance(
TARGET_KEYS,
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i,
);
});
it("covers tools/hooks help keys with non-trivial operational guidance", () => {
for (const key of TOOLS_HOOKS_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
expectOperationalGuidance(
TOOLS_HOOKS_TARGET_KEYS,
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
);
});
it("covers channels/agents help keys with non-trivial operational guidance", () => {
for (const key of CHANNELS_AGENTS_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
expectOperationalGuidance(
CHANNELS_AGENTS_TARGET_KEYS,
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
);
});
it("covers final backlog help keys with non-trivial operational guidance", () => {
for (const key of FINAL_BACKLOG_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
expectOperationalGuidance(
FINAL_BACKLOG_TARGET_KEYS,
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i,
);
});
it("documents option behavior for enum-style fields", () => {

View File

@@ -1,28 +1,21 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createFixtureSuite } from "../../test-utils/fixture-suite.js";
import { capEntryCount, pruneStaleEntries, rotateSessionFile } from "./store.js";
import type { SessionEntry } from "./types.js";
const DAY_MS = 24 * 60 * 60 * 1000;
let fixtureRoot = "";
let fixtureCount = 0;
async function createCaseDir(prefix: string): Promise<string> {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
const fixtureSuite = createFixtureSuite("openclaw-pruning-suite-");
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pruning-suite-"));
await fixtureSuite.setup();
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
await fixtureSuite.cleanup();
});
function makeEntry(updatedAt: number): SessionEntry {
@@ -82,7 +75,7 @@ describe("rotateSessionFile", () => {
let storePath: string;
beforeEach(async () => {
testDir = await createCaseDir("rotate");
testDir = await fixtureSuite.createCaseDir("rotate");
storePath = path.join(testDir, "sessions.json");
});

View File

@@ -12,6 +12,63 @@ import {
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
async function createTelegramDeliveryFixture(home: string): Promise<{
storePath: string;
deps: CliDeps;
}> {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
return { storePath, deps };
}
function mockEmbeddedAgentPayloads(payloads: Array<{ text: string; mediaUrl?: string }>) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads,
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
}
async function runTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
cfg?: ReturnType<typeof makeCfg>;
signal?: AbortSignal;
}) {
return runCronIsolatedAgentTurn({
cfg: params.cfg ?? makeCfg(params.home, params.storePath),
deps: params.deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
signal: params.signal,
lane: "cron",
});
}
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
setupIsolatedAgentTurnMocks({ fast: true });
@@ -19,45 +76,17 @@ describe("runCronIsolatedAgentTurn", () => {
it("handles media heartbeat delivery and announce cleanup modes", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
const { storePath, deps } = await createTelegramDeliveryFixture(home);
// Media should still be delivered even if text is just HEARTBEAT_OK.
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
]);
const mediaRes = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
const mediaRes = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(mediaRes.status).toBe("ok");
@@ -66,13 +95,7 @@ describe("runCronIsolatedAgentTurn", () => {
vi.mocked(runSubagentAnnounceFlow).mockClear();
vi.mocked(deps.sendMessageTelegram).mockClear();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]);
const cfg = makeCfg(home, storePath);
cfg.agents = {
@@ -136,47 +159,19 @@ describe("runCronIsolatedAgentTurn", () => {
it("skips structured outbound delivery when timeout abort is already set", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
const { storePath, deps } = await createTelegramDeliveryFixture(home);
const controller = new AbortController();
controller.abort("cron: job execution timed out");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
signal: controller.signal,
lane: "cron",
});
expect(res.status).toBe("error");

View File

@@ -1,6 +1,8 @@
import { vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob } from "./isolated-agent.test-harness.js";
export function createCliDeps(overrides: Partial<CliDeps> = {}): CliDeps {
return {
@@ -27,3 +29,29 @@ export function mockAgentPayloads(
...extra,
});
}
export async function runTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
delivery: {
mode: "announce";
channel: string;
to?: string;
bestEffort?: boolean;
};
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
return runCronIsolatedAgentTurn({
cfg: makeCfg(params.home, params.storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps: params.deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: params.delivery,
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
}

View File

@@ -1,14 +1,12 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
} from "./isolated-agent.test-harness.js";
createCliDeps,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
import { withTempCronHome, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
describe("runCronIsolatedAgentTurn forum topic delivery", () => {
@@ -22,18 +20,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
const deps = createCliDeps();
mockAgentPayloads([{ text: "forum message" }]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
});
expect(res.status).toBe("ok");
@@ -56,18 +47,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
const deps = createCliDeps();
mockAgentPayloads([{ text: "plain message" }]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");

View File

@@ -3,7 +3,11 @@ import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
import {
createCliDeps,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import {
makeCfg,
@@ -13,32 +17,22 @@ import {
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
async function runTelegramAnnounceTurn(params: {
async function runExplicitTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
delivery: {
mode: "announce";
channel: string;
to?: string;
bestEffort?: boolean;
};
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
return runCronIsolatedAgentTurn({
cfg: makeCfg(params.home, params.storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps: params.deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: params.delivery,
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
return runTelegramAnnounceTurn({
...params,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
}
function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>): void {
expect(result.status).toBe("ok");
expect(result.delivered).toBe(true);
}
async function expectBestEffortTelegramNotDelivered(
payload: Record<string, unknown>,
): Promise<void> {
@@ -75,15 +69,13 @@ async function expectExplicitTelegramTargetAnnounce(params: {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = createCliDeps();
mockAgentPayloads(params.payloads);
const res = await runTelegramAnnounceTurn({
const res = await runExplicitTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expectDeliveredOk(res);
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
| {
@@ -210,15 +202,13 @@ describe("runCronIsolatedAgentTurn", () => {
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
});
const res = await runTelegramAnnounceTurn({
const res = await runExplicitTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expectDeliveredOk(res);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});

View File

@@ -11,25 +11,24 @@ export async function withTempCronHome<T>(fn: (home: string) => Promise<T>): Pro
export async function writeSessionStore(
home: string,
session: { lastProvider: string; lastTo: string; lastChannel?: string },
): Promise<string> {
return writeSessionStoreEntries(home, {
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
...session,
},
});
}
export async function writeSessionStoreEntries(
home: string,
entries: Record<string, Record<string, unknown>>,
): Promise<string> {
const dir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(dir, { recursive: true });
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
...session,
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
return storePath;
}

View File

@@ -6,7 +6,13 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js";
import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
writeSessionStoreEntries,
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
const withTempHome = withTempCronHome;
@@ -44,33 +50,6 @@ function expectEmbeddedProviderModel(expected: { provider: string; model: string
expect(call?.model).toBe(expected.model);
}
async function writeSessionStore(
home: string,
entries: Record<string, Record<string, unknown>> = {},
) {
const dir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(dir, { recursive: true });
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
...entries,
},
null,
2,
),
"utf-8",
);
return storePath;
}
async function readSessionEntry(storePath: string, key: string) {
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, { sessionId?: string; label?: string }>;
@@ -98,7 +77,17 @@ type RunCronTurnOptions = {
};
async function runCronTurn(home: string, options: RunCronTurnOptions = {}) {
const storePath = options.storePath ?? (await writeSessionStore(home, options.storeEntries));
const storePath =
options.storePath ??
(await writeSessionStoreEntries(home, {
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
...options.storeEntries,
}));
const deps = options.deps ?? makeDeps();
if (options.mockTexts === null) {
vi.mocked(runEmbeddedPiAgent).mockClear();
@@ -468,7 +457,7 @@ describe("runCronIsolatedAgentTurn", () => {
it("starts a fresh session id for each cron run", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = makeDeps();
const first = (
@@ -502,7 +491,7 @@ describe("runCronIsolatedAgentTurn", () => {
it("preserves an existing cron session label", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
store["agent:main:cron:job-1"] = {

View File

@@ -1,26 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ChannelId } from "../channels/plugins/types.js";
import { CronService, type CronServiceDeps } from "./service.js";
import {
createCronStoreHarness,
createNoopLogger,
withCronServiceForTest,
} from "./service.test-harness.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-"));
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-delivery-" });
type DeliveryMode = "none" | "announce";
@@ -40,27 +28,15 @@ async function withCronService(
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
cronEnabled: true,
storePath: store.storePath,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob:
params.runIsolatedAgentJob ??
(vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never),
});
await cron.start();
try {
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
} finally {
cron.stop();
await store.cleanup();
}
await withCronServiceForTest(
{
makeStorePath,
logger: noopLogger,
cronEnabled: true,
runIsolatedAgentJob: params.runIsolatedAgentJob,
},
run,
);
}
async function addIsolatedAgentTurnJob(

View File

@@ -3,25 +3,35 @@ import { createMockCronStateForJobs } from "./service.test-harness.js";
import { recomputeNextRunsForMaintenance } from "./service/jobs.js";
import type { CronJob } from "./types.js";
function createCronSystemEventJob(now: number, overrides: Partial<CronJob> = {}): CronJob {
const { state, ...jobOverrides } = overrides;
return {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
...jobOverrides,
state: state ? { ...state } : {},
};
}
describe("issue #13992 regression - cron jobs skip execution", () => {
it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => {
const now = Date.now();
const pastDue = now - 60_000; // 1 minute ago
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
const job = createCronSystemEventJob(now, {
createdAtMs: now - 3600_000,
updatedAtMs: now - 3600_000,
state: {
nextRunAtMs: pastDue, // This is in the past and should NOT be recomputed
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -33,20 +43,11 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
it("should compute missing nextRunAtMs during maintenance", () => {
const now = Date.now();
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
const job = createCronSystemEventJob(now, {
state: {
// nextRunAtMs is missing
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -60,20 +61,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
const now = Date.now();
const futureTime = now + 3600_000;
const job: CronJob = {
id: "test-job",
name: "test job",
const job = createCronSystemEventJob(now, {
enabled: false, // Disabled
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
state: {
nextRunAtMs: futureTime,
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -87,21 +80,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
const stuckTime = now - 3 * 60 * 60_000; // 3 hours ago (> 2 hour threshold)
const futureTime = now + 3600_000;
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
const job = createCronSystemEventJob(now, {
state: {
nextRunAtMs: futureTime,
runningAtMs: stuckTime, // Stuck running marker
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
import {
createCronStoreHarness,
createNoopLogger,
withCronServiceForTest,
} from "./service.test-harness.js";
import type { CronJob } from "./types.js";
const noopLogger = createNoopLogger();
@@ -30,25 +34,15 @@ async function withCronService(
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
storePath: store.storePath,
cronEnabled,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
await cron.start();
try {
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
} finally {
cron.stop();
await store.cleanup();
}
await withCronServiceForTest(
{
makeStorePath,
logger: noopLogger,
cronEnabled,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
},
run,
);
}
describe("CronService", () => {

View File

@@ -1,28 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
import { loadCronStore } from "./store.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-"));
return {
dir,
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-migrate-" });
async function writeLegacyStore(storePath: string, legacyJob: Record<string, unknown>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { CronEvent } from "./service.js";
import type { CronEvent, CronServiceDeps } from "./service.js";
import { CronService } from "./service.js";
import { createCronServiceState, type CronServiceState } from "./service/state.js";
import type { CronJob } from "./types.js";
@@ -140,6 +140,42 @@ export function createStartedCronServiceWithFinishedBarrier(params: {
return { cron, enqueueSystemEvent, requestHeartbeatNow, finished };
}
export async function withCronServiceForTest(
params: {
makeStorePath: () => Promise<{ storePath: string; cleanup: () => Promise<void> }>;
logger: ReturnType<typeof createNoopLogger>;
cronEnabled: boolean;
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"];
},
run: (context: {
cron: CronService;
enqueueSystemEvent: ReturnType<typeof vi.fn>;
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
): Promise<void> {
const store = await params.makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
cronEnabled: params.cronEnabled,
storePath: store.storePath,
log: params.logger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob:
params.runIsolatedAgentJob ??
(vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never),
});
await cron.start();
try {
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
} finally {
cron.stop();
await store.cleanup();
}
}
export function createRunningCronServiceState(params: {
storePath: string;
log: ReturnType<typeof createNoopLogger>;

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
const loadConfig = vi.fn();
const resolveGatewayPort = vi.fn();
const pickPrimaryTailnetIPv4 = vi.fn();
const pickPrimaryLanIPv4 = vi.fn();
import {
loadConfigMock as loadConfig,
pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4,
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
resolveGatewayPortMock as resolveGatewayPort,
} from "./gateway-connection.test-mocks.js";
let lastClientOptions: {
url?: string;
@@ -19,27 +20,6 @@ let startMode: StartMode = "hello";
let closeCode = 1006;
let closeReason = "";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
resolveGatewayPort,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4,
}));
vi.mock("./net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./net.js")>();
return {
...actual,
pickPrimaryLanIPv4,
};
});
vi.mock("./client.js", () => ({
describeGatewayCloseCode: (code: number) => {
if (code === 1000) {

View File

@@ -0,0 +1,27 @@
import { vi } from "vitest";
export const loadConfigMock = vi.fn();
export const resolveGatewayPortMock = vi.fn();
export const pickPrimaryTailnetIPv4Mock = vi.fn();
export const pickPrimaryLanIPv4Mock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: loadConfigMock,
resolveGatewayPort: resolveGatewayPortMock,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: pickPrimaryTailnetIPv4Mock,
}));
vi.mock("./net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./net.js")>();
return {
...actual,
pickPrimaryLanIPv4: pickPrimaryLanIPv4Mock,
};
});

View File

@@ -17,6 +17,8 @@ vi.mock("./hooks.js", async (importOriginal) => {
import { createHooksRequestHandler } from "./server-http.js";
type HooksHandlerDeps = Parameters<typeof createHooksRequestHandler>[0];
function createHooksConfig(): HooksConfigResolved {
return {
basePath: "/hooks",
@@ -66,6 +68,30 @@ function createResponse(): {
return { res, end, setHeader };
}
function createHandler(params?: {
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
}) {
return createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook:
params?.dispatchWakeHook ??
((() => {
return;
}) as HooksHandlerDeps["dispatchWakeHook"]),
dispatchAgentHook:
params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]),
});
}
describe("createHooksRequestHandler timeout status mapping", () => {
beforeEach(() => {
readJsonBodyMock.mockClear();
@@ -75,19 +101,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" });
const dispatchWakeHook = vi.fn();
const dispatchAgentHook = vi.fn(() => "run-1");
const handler = createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook,
dispatchAgentHook,
});
const handler = createHandler({ dispatchWakeHook, dispatchAgentHook });
const req = createRequest();
const { res, end } = createResponse();
@@ -101,19 +115,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
});
test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => {
const handler = createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook: vi.fn(),
dispatchAgentHook: vi.fn(() => "run-1"),
});
const handler = createHandler();
for (let i = 0; i < 20; i++) {
const req = createRequest({

View File

@@ -124,6 +124,40 @@ function createChatContext(): Pick<
};
}
type ChatContext = ReturnType<typeof createChatContext>;
async function runNonStreamingChatSend(params: {
context: ChatContext;
respond: ReturnType<typeof vi.fn>;
idempotencyKey: string;
message?: string;
}) {
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: params.message ?? "hello",
idempotencyKey: params.idempotencyKey,
},
respond: params.respond as unknown as Parameters<
(typeof chatHandlers)["chat.send"]
>[0]["respond"],
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: params.context as GatewayRequestContext,
});
await vi.waitFor(() => {
expect(
(params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length,
).toBe(1);
});
const chatCall = (params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
return chatCall?.[1];
}
describe("chat directive tag stripping for non-streaming final payloads", () => {
it("chat.inject keeps message defined when directive tag is the only content", async () => {
createTranscriptFixture("openclaw-chat-inject-directive-only-");
@@ -160,33 +194,20 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
const respond = vi.fn();
const context = createChatContext();
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-directive-only",
},
const payload = await runNonStreamingChatSend({
context,
respond,
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: context as GatewayRequestContext,
idempotencyKey: "idem-directive-only",
});
await vi.waitFor(() => {
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
expect(chatCall?.[1]).toEqual(
expect(payload).toEqual(
expect.objectContaining({
runId: "idem-directive-only",
state: "final",
message: expect.any(Object),
}),
);
expect(extractFirstTextBlock(chatCall?.[1])).toBe("");
expect(extractFirstTextBlock(payload)).toBe("");
});
it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => {
@@ -218,25 +239,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
const respond = vi.fn();
const context = createChatContext();
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-untrusted-context",
},
const payload = await runNonStreamingChatSend({
context,
respond,
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: context as GatewayRequestContext,
idempotencyKey: "idem-untrusted-context",
});
await vi.waitFor(() => {
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
expect(extractFirstTextBlock(chatCall?.[1])).toBe("hello");
expect(extractFirstTextBlock(payload)).toBe("hello");
});
});

View File

@@ -13,24 +13,32 @@ import {
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
let startedServer: Awaited<ReturnType<typeof startServerWithClient>> | null = null;
function requireWs(): Awaited<ReturnType<typeof startServerWithClient>>["ws"] {
if (!startedServer) {
throw new Error("gateway test server not started");
}
return startedServer.ws;
}
beforeAll(async () => {
const started = await startServerWithClient(undefined, { controlUiEnabled: true });
server = started.server;
ws = started.ws;
await connectOk(ws);
startedServer = await startServerWithClient(undefined, { controlUiEnabled: true });
await connectOk(requireWs());
});
afterAll(async () => {
ws.close();
await server.close();
if (!startedServer) {
return;
}
startedServer.ws.close();
await startedServer.server.close();
startedServer = null;
});
describe("gateway config methods", () => {
it("rejects config.patch when raw is not an object", async () => {
const res = await rpcReq<{ ok?: boolean }>(ws, "config.patch", {
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", {
raw: "[]",
});
expect(res.ok).toBe(false);
@@ -78,7 +86,7 @@ describe("gateway server sessions", () => {
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
}>(requireWs(), "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
@@ -91,7 +99,7 @@ describe("gateway server sessions", () => {
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
}>(requireWs(), "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
@@ -119,13 +127,13 @@ describe("gateway server sessions", () => {
},
});
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
const resolved = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.resolve", {
key: "main",
});
expect(resolved.ok).toBe(true);
expect(resolved.payload?.key).toBe("agent:ops:work");
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
const patched = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.patch", {
key: "main",
thinkingLevel: "medium",
});

View File

@@ -85,6 +85,28 @@ async function waitForCondition(check: () => boolean, timeoutMs = 2000) {
}
}
async function cleanupCronTestRun(params: {
ws: { close: () => void };
server: { close: () => Promise<void> };
dir: string;
prevSkipCron: string | undefined;
clearSessionConfig?: boolean;
}) {
params.ws.close();
await params.server.close();
await rmTempDir(params.dir);
testState.cronStorePath = undefined;
if (params.clearSessionConfig) {
testState.sessionConfig = undefined;
}
testState.cronEnabled = undefined;
if (params.prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
return;
}
process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron;
}
describe("gateway server cron", () => {
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
@@ -352,17 +374,13 @@ describe("gateway server cron", () => {
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
expect(disabled?.enabled).toBe(false);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({
ws,
server,
dir,
prevSkipCron,
clearSessionConfig: true,
});
}
});
@@ -466,16 +484,7 @@ describe("gateway server cron", () => {
const runs = autoEntries?.entries ?? [];
expect(runs.at(-1)?.jobId).toBe(autoJobId);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
}
}, 45_000);
@@ -650,16 +659,7 @@ describe("gateway server cron", () => {
await yieldToEventLoop();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
}
}, 60_000);
});

View File

@@ -51,6 +51,43 @@ function createForwarder(params: {
return { deliver, forwarder };
}
function makeSessionCfg(options: { discordExecApprovalsEnabled?: boolean } = {}): OpenClawConfig {
return {
...(options.discordExecApprovalsEnabled
? {
channels: {
discord: {
execApprovals: {
enabled: true,
approvers: ["123"],
},
},
},
}
: {}),
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
}
async function expectDiscordSessionTargetRequest(params: {
cfg: OpenClawConfig;
expectedAccepted: boolean;
expectedDeliveryCount: number;
}) {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({
cfg: params.cfg,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
});
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(params.expectedAccepted);
if (params.expectedDeliveryCount === 0) {
expect(deliver).not.toHaveBeenCalled();
return;
}
expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount);
}
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
@@ -124,66 +161,27 @@ describe("exec approval forwarder", () => {
});
it("returns false when all targets are skipped", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
discord: {
execApprovals: {
enabled: true,
approvers: ["123"],
},
},
},
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
await expectDiscordSessionTargetRequest({
cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }),
expectedAccepted: false,
expectedDeliveryCount: 0,
});
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false);
expect(deliver).not.toHaveBeenCalled();
});
it("forwards to discord when discord exec approvals handler is disabled", async () => {
vi.useFakeTimers();
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
await expectDiscordSessionTargetRequest({
cfg: makeSessionCfg(),
expectedAccepted: true,
expectedDeliveryCount: 1,
});
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
});
it("skips discord forwarding when discord exec approvals handler is enabled", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
discord: {
execApprovals: {
enabled: true,
approvers: ["123"],
},
},
},
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
await expectDiscordSessionTargetRequest({
cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }),
expectedAccepted: false,
expectedDeliveryCount: 0,
});
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false);
expect(deliver).not.toHaveBeenCalled();
});
it("can forward resolved notices without pending cache when request payload is present", async () => {

View File

@@ -115,6 +115,28 @@ function createEaccesProcStatSpy() {
});
}
function createPortProbeConnectionSpy(result: "connect" | "refused") {
return vi.spyOn(net, "createConnection").mockImplementation(() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = vi.fn();
setImmediate(() => {
if (result === "connect") {
socket.emit("connect");
return;
}
socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }));
});
return socket;
});
}
async function writeRecentLockFile(env: NodeJS.ProcessEnv, startTime = 111) {
await writeLockFile(env, {
startTime,
createdAt: new Date().toISOString(),
});
}
describe("gateway lock", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
@@ -214,18 +236,8 @@ describe("gateway lock", () => {
it("treats lock as stale when owner pid is alive but configured port is free", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeLockFile(env, {
startTime: 111,
createdAt: new Date().toISOString(),
});
const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = vi.fn();
setImmediate(() => {
socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }));
});
return socket;
});
await writeRecentLockFile(env);
const connectSpy = createPortProbeConnectionSpy("refused");
const lock = await acquireForTest(env, {
timeoutMs: 80,
@@ -242,18 +254,8 @@ describe("gateway lock", () => {
it("keeps lock when configured port is busy and owner pid is alive", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeLockFile(env, {
startTime: 111,
createdAt: new Date().toISOString(),
});
const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = vi.fn();
setImmediate(() => {
socket.emit("connect");
});
return socket;
});
await writeRecentLockFile(env);
const connectSpy = createPortProbeConnectionSpy("connect");
try {
const pending = acquireForTest(env, {
timeoutMs: 20,

View File

@@ -5,6 +5,40 @@ import {
resolveExecApprovalDecision,
} from "./exec-policy.js";
type EvaluatePolicyParams = Parameters<typeof evaluateSystemRunPolicy>[0];
type EvaluatePolicyDecision = ReturnType<typeof evaluateSystemRunPolicy>;
const buildPolicyParams = (overrides: Partial<EvaluatePolicyParams>): EvaluatePolicyParams => {
return {
security: "allowlist",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
...overrides,
};
};
const expectDeniedDecision = (decision: EvaluatePolicyDecision) => {
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
return decision;
};
const expectAllowedDecision = (decision: EvaluatePolicyDecision) => {
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
return decision;
};
describe("resolveExecApprovalDecision", () => {
it("accepts known approval decisions", () => {
expect(resolveExecApprovalDecision("allow-once")).toBe("allow-once");
@@ -42,143 +76,68 @@ describe("formatSystemRunAllowlistMissMessage", () => {
describe("evaluateSystemRunPolicy", () => {
it("denies when security mode is deny", () => {
const decision = evaluateSystemRunPolicy({
security: "deny",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("security=deny");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(buildPolicyParams({ security: "deny" })),
);
expect(denied.eventReason).toBe("security=deny");
expect(denied.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
});
it("requires approval when ask policy requires it", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "always",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("approval-required");
expect(decision.requiresAsk).toBe(true);
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(buildPolicyParams({ ask: "always" })),
);
expect(denied.eventReason).toBe("approval-required");
expect(denied.requiresAsk).toBe(true);
});
it("allows allowlist miss when explicit approval is provided", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: "allow-once",
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.approvedByAsk).toBe(true);
const allowed = expectAllowedDecision(
evaluateSystemRunPolicy(
buildPolicyParams({
ask: "on-miss",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: "allow-once",
}),
),
);
expect(allowed.approvedByAsk).toBe(true);
});
it("denies allowlist misses without approval", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("allowlist-miss");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(buildPolicyParams({ analysisOk: false, allowlistSatisfied: false })),
);
expect(denied.eventReason).toBe("allowlist-miss");
expect(denied.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("treats shell wrappers as allowlist misses", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: true,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.shellWrapperBlocked).toBe(true);
expect(decision.errorMessage).toContain("shell wrappers like sh/bash/zsh -c");
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(buildPolicyParams({ shellWrapperInvocation: true })),
);
expect(denied.shellWrapperBlocked).toBe(true);
expect(denied.errorMessage).toContain("shell wrappers like sh/bash/zsh -c");
});
it("keeps Windows-specific guidance for cmd.exe wrappers", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: true,
cmdInvocation: true,
shellWrapperInvocation: true,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.shellWrapperBlocked).toBe(true);
expect(decision.windowsShellWrapperBlocked).toBe(true);
expect(decision.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
const denied = expectDeniedDecision(
evaluateSystemRunPolicy(
buildPolicyParams({ isWindows: true, cmdInvocation: true, shellWrapperInvocation: true }),
),
);
expect(denied.shellWrapperBlocked).toBe(true);
expect(denied.windowsShellWrapperBlocked).toBe(true);
expect(denied.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
});
it("allows execution when policy checks pass", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
shellWrapperInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.requiresAsk).toBe(false);
expect(decision.analysisOk).toBe(true);
expect(decision.allowlistSatisfied).toBe(true);
const allowed = expectAllowedDecision(
evaluateSystemRunPolicy(buildPolicyParams({ ask: "on-miss" })),
);
expect(allowed.requiresAsk).toBe(false);
expect(allowed.analysisOk).toBe(true);
expect(allowed.allowlistSatisfied).toBe(true);
});
});

View File

@@ -67,6 +67,25 @@ describe("monitorSlackProvider tool results", () => {
return (replyMock.mock.calls[0]?.[0] ?? {}) as { WasMentioned?: boolean };
}
function setRequireMentionChannelConfig(mentionPatterns?: string[]) {
slackTestState.config = {
...(mentionPatterns
? {
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns },
},
}
: {}),
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
}
async function runDirectMessageEvent(ts: string, extraEvent: Record<string, unknown> = {}) {
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({ ts, ...extraEvent }),
@@ -325,18 +344,7 @@ describe("monitorSlackProvider tool results", () => {
});
async function expectMentionPatternMessageAccepted(text: string): Promise<void> {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
setRequireMentionChannelConfig(["\\bopenclaw\\b"]);
replyMock.mockResolvedValue({ text: "hi" });
await runSlackMessageOnce(monitorSlackProvider, {
@@ -359,14 +367,7 @@ describe("monitorSlackProvider tool results", () => {
});
it("treats replies to bot threads as implicit mentions", async () => {
slackTestState.config = {
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
setRequireMentionChannelConfig();
replyMock.mockResolvedValue({ text: "hi" });
await runSlackMessageOnce(monitorSlackProvider, {

View File

@@ -1,20 +1,9 @@
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { installSlackBlockTestMocks } from "./blocks.test-helpers.js";
// --- Module mocks (must precede dynamic import) ---
vi.mock("../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("./accounts.js", () => ({
resolveSlackAccount: () => ({
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
}),
}));
installSlackBlockTestMocks();
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn(async () => ({

View File

@@ -0,0 +1,29 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export function createFixtureSuite(rootPrefix: string) {
let fixtureRoot = "";
let fixtureCount = 0;
return {
async setup(): Promise<void> {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), rootPrefix));
},
async cleanup(): Promise<void> {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
},
async createCaseDir(prefix: string): Promise<string> {
if (!fixtureRoot) {
throw new Error("Fixture suite not initialized");
}
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
},
};
}

View File

@@ -1,34 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
loadConfigMock as loadConfig,
pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4,
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
resolveGatewayPortMock as resolveGatewayPort,
} from "../gateway/gateway-connection.test-mocks.js";
import { captureEnv, withEnv } from "../test-utils/env.js";
const loadConfig = vi.fn();
const resolveGatewayPort = vi.fn();
const pickPrimaryTailnetIPv4 = vi.fn();
const pickPrimaryLanIPv4 = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
resolveGatewayPort,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4,
}));
vi.mock("../gateway/net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/net.js")>();
return {
...actual,
pickPrimaryLanIPv4,
// Allow all URLs in tests - security validation is tested separately
isSecureWebSocketUrl: () => true,
};
});
const { resolveGatewayConnection } = await import("./gateway-chat.js");
describe("resolveGatewayConnection", () => {

View File

@@ -26,6 +26,23 @@ describe("web monitor inbox", () => {
});
}
async function runSingleUpsertAndCapture(upsert: unknown) {
const onMessage = vi.fn();
const listener = await openMonitor(onMessage);
const sock = getSock();
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
return { onMessage, listener };
}
function expectSingleGroupMessage(
onMessage: ReturnType<typeof vi.fn>,
expected: Record<string, unknown>,
) {
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(expect.objectContaining(expected));
}
it("captures media path for image messages", async () => {
const onMessage = vi.fn();
const listener = await openMonitor(onMessage);
@@ -203,10 +220,7 @@ describe("web monitor inbox", () => {
});
it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => {
const onMessage = vi.fn();
const listener = await openMonitor(onMessage);
const sock = getSock();
const upsert = {
const { onMessage, listener } = await runSingleUpsertAndCapture({
type: "notify",
messages: [
{
@@ -228,22 +242,14 @@ describe("web monitor inbox", () => {
},
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
chatType: "group",
conversationId: "424242@g.us",
body: "oh hey @Clawd UK !",
mentionedJids: ["123@s.whatsapp.net"],
senderE164: "+888",
}),
);
});
expectSingleGroupMessage(onMessage, {
chatType: "group",
conversationId: "424242@g.us",
body: "oh hey @Clawd UK !",
mentionedJids: ["123@s.whatsapp.net"],
senderE164: "+888",
});
await listener.close();
});
@@ -262,10 +268,7 @@ describe("web monitor inbox", () => {
},
});
const onMessage = vi.fn();
const listener = await openMonitor(onMessage);
const sock = getSock();
const upsert = {
const { onMessage, listener } = await runSingleUpsertAndCapture({
type: "notify",
messages: [
{
@@ -283,24 +286,16 @@ describe("web monitor inbox", () => {
},
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
chatType: "group",
from: "55555@g.us",
senderE164: "+777",
senderJid: "777@s.whatsapp.net",
mentionedJids: ["123@s.whatsapp.net"],
selfE164: "+123",
selfJid: "123@s.whatsapp.net",
}),
);
});
expectSingleGroupMessage(onMessage, {
chatType: "group",
from: "55555@g.us",
senderE164: "+777",
senderJid: "777@s.whatsapp.net",
mentionedJids: ["123@s.whatsapp.net"],
selfE164: "+123",
selfJid: "123@s.whatsapp.net",
});
await listener.close();
});
});