openclaw / src /plugins /contracts /session-attachments.contract.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4c76b0d verified
Raw History Blame Contribute Delete
20.8 kB
// Session attachment contract tests cover plugin session attachment metadata and storage.
import * as fs from "node:fs/promises";
import path from "node:path";
import { FILE_TYPE_SNIFF_MAX_BYTES } from "@openclaw/media-core/mime";
import { registerTestPlugin } from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js";
import { sendPluginSessionAttachment } from "../host-hook-attachments.js";
import { clearPluginLoaderCache } from "../loader.test-fixtures.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../runtime.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
const workflowMocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
sendMessage: vi.fn(),
}));
const MAIN_SESSION_KEY = "agent:main:main";
const DEFAULT_TELEGRAM_ROUTE = {
channel: "telegram",
to: "12345",
} as const;
type SessionAttachmentRequest = Parameters<typeof sendPluginSessionAttachment>[0];
type TestSessionEntry = {
sessionId?: string;
updatedAt?: number;
delivery?: SessionEntry["delivery"];
};
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: workflowMocks.getChannelPlugin,
}));
vi.mock("../../infra/outbound/message.js", () => ({
sendMessage: workflowMocks.sendMessage,
}));
function createSilentPluginLogger() {
return {
info() {},
warn() {},
error() {},
debug() {},
};
}
async function withSessionStore(
run: (params: { stateDir: string; storePath: string; filePath: string }) => Promise<void>,
) {
const stateDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-session-attachments-"),
);
const storePath = path.join(stateDir, "sessions.json");
const filePath = path.join(stateDir, "x.txt");
await fs.writeFile(filePath, "x", "utf8");
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
await withTempConfig({
cfg: { session: { store: storePath } },
run: async () => await run({ stateDir, storePath, filePath }),
});
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(stateDir, { recursive: true, force: true });
}
}
async function writeSessionEntry(
storePath: string,
entry: TestSessionEntry = {
delivery: normalizeSessionDeliveryState({ context: DEFAULT_TELEGRAM_ROUTE }),
},
key = MAIN_SESSION_KEY,
) {
await replaceSessionEntry({ storePath, sessionKey: key }, {
sessionId: "session-id",
updatedAt: Date.now(),
...entry,
} as unknown as SessionEntry);
}
function mockSuccessfulAttachmentDelivery(messageId = "attachment-1") {
workflowMocks.sendMessage.mockImplementation(async (params: Record<string, unknown>) => ({
channel: params.channel,
to: params.to,
via: "direct" as const,
mediaUrl: null,
result: { channel: params.channel, messageId },
}));
}
async function sendBundledSessionAttachment(
params: Omit<SessionAttachmentRequest, "origin" | "sessionKey"> &
Partial<Pick<SessionAttachmentRequest, "sessionKey">>,
) {
return await sendPluginSessionAttachment({
origin: "bundled",
sessionKey: MAIN_SESSION_KEY,
...params,
});
}
function expectTelegramAttachmentResult(result: unknown, count: number) {
const response = result as { ok?: unknown; channel?: unknown; count?: unknown };
expect(response.ok).toBe(true);
expect(response.channel).toBe("telegram");
expect(response.count).toBe(count);
}
function requireFirstSendMessageParams() {
const params = workflowMocks.sendMessage.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
if (!params) {
throw new Error("expected sendMessage call");
}
return params;
}
describe("plugin session attachments", () => {
afterEach(() => {
workflowMocks.getChannelPlugin.mockReset();
workflowMocks.sendMessage.mockReset();
resetPluginRuntimeStateForTest();
clearPluginLoaderCache();
delete (globalThis as { proofAttachmentApi?: OpenClawPluginApi }).proofAttachmentApi;
delete (globalThis as { proofAttachmentLog?: unknown[] }).proofAttachmentLog;
});
it("sends validated files through the session delivery route with portable hints", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {
delivery: normalizeSessionDeliveryState({
context: { ...DEFAULT_TELEGRAM_ROUTE, accountId: "default", threadId: 42 },
}),
});
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: filePath }],
channelHints: { silent: true, parseMode: "HTML", threadId: "attachment-thread" },
});
expect(result).toEqual({
ok: true,
channel: "telegram",
deliveredTo: "12345",
count: 1,
});
expect(workflowMocks.sendMessage).toHaveBeenCalledTimes(1);
const sendParams = requireFirstSendMessageParams();
expect(sendParams.to).toBe("12345");
expect(sendParams.channel).toBe("telegram");
expect(sendParams.accountId).toBe("default");
expect(sendParams.threadId).toBe("attachment-thread");
expect(sendParams.mediaUrls).toEqual([filePath]);
expect(sendParams.bestEffort).toBe(false);
expect(sendParams.silent).toBe(true);
expect(sendParams.parseMode).toBe("HTML");
});
});
it("does not use best-effort mode for attachment batches", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const first = path.join(stateDir, "first.txt");
const second = path.join(stateDir, "second.txt");
await fs.writeFile(first, "1", "utf8");
await fs.writeFile(second, "2", "utf8");
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: first }, { path: second }],
});
expectTelegramAttachmentResult(result, 2);
const sendParams = requireFirstSendMessageParams();
expect(sendParams.mediaUrls).toEqual([first, second]);
expect(sendParams.bestEffort).toBe(false);
});
});
it("keeps shipped Telegram hints compatible when escaping plain captions", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: filePath }],
text: "1 < 2 & 3 > 2",
captionFormat: "plain",
channelHints: { telegram: { parseMode: "HTML" } },
});
expectTelegramAttachmentResult(result, 1);
const sendParams = requireFirstSendMessageParams();
expect(sendParams.content).toBe("1 &lt; 2 &amp; 3 &gt; 2");
expect(sendParams.parseMode).toBe("HTML");
});
});
it("keeps shipped Slack thread hints compatible", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {
delivery: normalizeSessionDeliveryState({ context: { channel: "slack", to: "C123" } }),
});
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: filePath }],
channelHints: { slack: { threadTs: "171234.567" } },
});
expect(result).toMatchObject({ ok: true, channel: "slack", deliveredTo: "C123" });
expect(requireFirstSendMessageParams().threadId).toBe("171234.567");
});
});
it("resolves relative attachment paths against the session agent workspace", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const workspaceDir = path.join(stateDir, "workspace");
const relativeFilePath = "./report.txt";
const absoluteFilePath = path.join(workspaceDir, "report.txt");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(absoluteFilePath, "workspace report", "utf8");
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: relativeFilePath }],
config: {
session: { store: storePath },
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
},
});
expectTelegramAttachmentResult(result, 1);
expect(requireFirstSendMessageParams().mediaUrls).toEqual([absoluteFilePath]);
});
});
it("reports attachment delivery as failed when no delivery result is returned", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.sendMessage.mockResolvedValue({
channel: "telegram",
to: "12345",
via: "direct",
mediaUrl: null,
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery failed: no delivery result returned",
});
});
});
it("rejects external plugins and sessions without delivery routes", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {});
await expect(
sendPluginSessionAttachment({
origin: "workspace",
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "session attachments are restricted to bundled plugins",
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "session has no active delivery route: agent:main:main",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("rejects malformed or oversized attachment inputs before delivery", async () => {
await withSessionStore(async ({ storePath, stateDir, filePath }) => {
await writeSessionEntry(storePath);
await expect(
sendBundledSessionAttachment({
files: Array.from({ length: 11 }, () => ({ path: path.join(stateDir, "missing.txt") })),
}),
).resolves.toEqual({
ok: false,
error: "at most 10 attachment files are allowed",
});
await expect(
sendBundledSessionAttachment({
files: [null as never],
}),
).resolves.toEqual({
ok: false,
error: "attachment file entry must be an object",
});
const first = path.join(stateDir, "first.txt");
const second = path.join(stateDir, "second.txt");
await fs.writeFile(first, "123", "utf8");
await fs.writeFile(second, "456", "utf8");
await expect(
sendBundledSessionAttachment({
files: [{ path: first }, { path: second }],
maxBytes: 5,
}),
).resolves.toEqual({
ok: false,
error: "attachment files exceed 5 bytes total",
});
const symlinkPath = path.join(stateDir, "linked.txt");
await fs.symlink(first, symlinkPath);
await expect(
sendBundledSessionAttachment({
files: [{ path: symlinkPath }],
}),
).resolves.toEqual({
ok: false,
error: `attachment file symlinks are not allowed: ${symlinkPath}`,
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
channelHints: { forceDocumentMime: "application/pdf" },
}),
).resolves.toEqual({
ok: false,
error: `attachment file MIME mismatch for ${filePath}: expected application/pdf, got unknown`,
});
const fakePdfPath = path.join(stateDir, "fake.pdf");
await fs.writeFile(fakePdfPath, "not a pdf", "utf8");
await expect(
sendBundledSessionAttachment({
files: [{ path: fakePdfPath }],
channelHints: { forceDocumentMime: "application/pdf" },
}),
).resolves.toEqual({
ok: false,
error: `attachment file MIME mismatch for ${fakePdfPath}: expected application/pdf, got unknown`,
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("validates force-document MIME using only the configured sniff window", async () => {
await withSessionStore(async ({ storePath, stateDir }) => {
const pdfPath = path.join(stateDir, "large.pdf");
await fs.writeFile(
pdfPath,
Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(FILE_TYPE_SNIFF_MAX_BYTES + 32)]),
);
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
files: [{ path: pdfPath }],
forceDocument: false,
channelHints: { forceDocumentMime: "application/pdf" },
});
expectTelegramAttachmentResult(result, 1);
const sendParams = requireFirstSendMessageParams();
expect(sendParams.mediaUrls).toEqual([pdfPath]);
expect(sendParams.forceDocument).toBe(true);
});
});
it("rejects gateway-mode channels before attempting host-local attachment delivery", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.getChannelPlugin.mockReturnValue(
createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "gateway" },
}),
);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "gateway" },
}),
},
]),
);
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error:
"session attachments require direct outbound delivery for channel telegram; " +
"channel uses gateway delivery",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("rejects unloaded bundled gateway-mode channels before attachment delivery", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath, {
delivery: normalizeSessionDeliveryState({
context: { channel: "whatsapp", to: "+15551234567" },
}),
});
setActivePluginRegistry(createEmptyPluginRegistry());
workflowMocks.getChannelPlugin.mockReturnValue(
createOutboundTestPlugin({
id: "whatsapp",
outbound: { deliveryMode: "gateway" },
}),
);
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error:
"session attachments require direct outbound delivery for channel whatsapp; " +
"channel uses gateway delivery",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("returns structured errors when channel delivery lookup fails", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
await writeSessionEntry(storePath);
workflowMocks.getChannelPlugin.mockImplementation(() => {
throw new Error("channel registry unavailable");
});
await expect(
sendBundledSessionAttachment({
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery setup failed: channel registry unavailable",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
it("uses replacement runtime config when captured async session hooks resume", async () => {
await withSessionStore(async ({ stateDir, storePath, filePath }) => {
await writeSessionEntry(storePath);
mockSuccessfulAttachmentDelivery();
const staleStorePath = path.join(stateDir, "stale-sessions.json");
await writeSessionEntry(staleStorePath);
const registrationConfig = { session: { store: staleStorePath } };
const liveConfig = { session: { store: storePath } };
let runtimeConfig = registrationConfig;
const registry = createPluginRegistry({
logger: createSilentPluginLogger(),
runtime: {
config: {
current: () => runtimeConfig,
},
} as unknown as PluginRuntime,
});
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config: registrationConfig,
record: createPluginRecord({
id: "live-config-attachment-plugin",
name: "Live Config Attachment Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
const attachment = capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
});
const injection = capturedApi?.enqueueNextTurnInjection({
sessionKey: MAIN_SESSION_KEY,
text: "resume with current config",
idempotencyKey: "live-config-injection",
});
runtimeConfig = liveConfig;
const result = await attachment;
expectTelegramAttachmentResult(result, 1);
expect(workflowMocks.sendMessage).toHaveBeenCalledTimes(1);
expect(requireFirstSendMessageParams().cfg).toBe(liveConfig);
await expect(injection).resolves.toEqual({
enqueued: true,
id: "live-config-injection",
sessionKey: MAIN_SESSION_KEY,
});
expect(
loadSessionEntry({ storePath, sessionKey: MAIN_SESSION_KEY })?.pluginNextTurnInjections?.[
"live-config-attachment-plugin"
],
).toEqual([
expect.objectContaining({
id: "live-config-injection",
text: "resume with current config",
}),
]);
expect(
loadSessionEntry({ storePath: staleStorePath, sessionKey: MAIN_SESSION_KEY })
?.pluginNextTurnInjections,
).toBeUndefined();
});
});
it("returns structured errors when the captured API cannot read the live runtime config", async () => {
await withSessionStore(async ({ stateDir, storePath, filePath }) => {
await writeSessionEntry(storePath);
const registrationConfig = { session: { store: path.join(stateDir, "stale-sessions.json") } };
const registry = createPluginRegistry({
logger: createSilentPluginLogger(),
runtime: {
config: {
current: () => {
throw new Error("config runtime unavailable");
},
},
} as unknown as PluginRuntime,
});
let capturedApi: OpenClawPluginApi | undefined;
registerTestPlugin({
registry,
config: registrationConfig,
record: createPluginRecord({
id: "attachment-runtime-error-plugin",
name: "Attachment Runtime Error Plugin",
origin: "bundled",
}),
register(api) {
capturedApi = api;
},
});
setActivePluginRegistry(registry.registry);
await expect(
capturedApi?.sendSessionAttachment({
sessionKey: MAIN_SESSION_KEY,
files: [{ path: filePath }],
}),
).resolves.toEqual({
ok: false,
error: "attachment delivery setup failed: config runtime unavailable",
});
expect(workflowMocks.sendMessage).not.toHaveBeenCalled();
});
});
});