Download src/plugins/contracts/session-attachments.contract.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/contracts/session-attachments.contract.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/plugins/contracts/session-attachments.contract.test.ts
-
curl -L -o session-attachments.contract.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/plugins/contracts/session-attachments.contract.test.ts
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 < 2 & 3 > 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(); | |
| }); | |
| }); | |
| }); | |