// Managed image attachment tests cover storage, HTTP serving, cleanup, and // operator authorization for generated image artifacts attached to gateway replies. import fs from "node:fs/promises"; import http from "node:http"; import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { maxBytesForKind } from "@openclaw/media-core/constants"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import { createNoisyPngBuffer as createNoisyPngFixtureBuffer, createSolidPngBuffer, } from "../../test/helpers/image-fixtures.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { resolveExistingAgentSessionStoreTargetsReadOnlyResult } from "../config/sessions/targets-read-availability.js"; import { createPinnedLookup } from "../infra/net/ssrf.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { readImageProbeFromHeader } from "../media/image-ops.js"; import { setMediaStoreNetworkDepsForTest } from "../media/store.test-support.js"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withEnvAsync } from "../test-utils/env.js"; import { attachManagedImageRecordToMessage, insertManagedImageRecord, MANAGED_OUTGOING_ORIGINALS_SUBDIR, readManagedImageRecord, } from "./managed-image-record-store.js"; import { makeMockHttpResponse } from "./test-http-response.js"; type PlaybackTranscodeResolution = Awaited< ReturnType<(typeof import("../media/playback-transcode.js"))["resolvePlaybackTranscode"]> >; type PlaybackModeForSourceResolver = ( ...args: Parameters< (typeof import("../media/playback-transcode.js"))["resolvePlaybackModeForSource"] > ) => ReturnType<(typeof import("../media/playback-transcode.js"))["resolvePlaybackModeForSource"]>; const authorizeGatewayHttpRequestOrReplyMock = vi.fn(); const resolveOpenAiCompatibleHttpOperatorScopesMock = vi.fn(); const resolveOpenAiCompatibleHttpSenderIsOwnerMock = vi.fn(); const loadSessionEntryMock = vi.fn(); const readSessionMessagesMock = vi.fn(); const resolveSessionHistoryTranscriptPathMock = vi.fn(); const getRuntimeConfigMock = vi.fn(() => ({})); const probePlaybackMediaFileDescriptorMock = vi.fn(async () => ({ durationMs: 1000 })); const resolvePlaybackModeForSourceMock = vi.fn(); const resolvePlaybackTranscodeMock = vi.fn( async (): Promise => ({ kind: "passthrough" }), ); const tempDirs = useAutoCleanupTempDirTracker(afterEach); vi.mock("../config/config.js", () => ({ getRuntimeConfig: getRuntimeConfigMock, })); vi.mock("./http-utils.js", () => ({ authorizeGatewayHttpRequestOrReply: authorizeGatewayHttpRequestOrReplyMock, resolveOpenAiCompatibleHttpOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopesMock, resolveOpenAiCompatibleHttpSenderIsOwner: resolveOpenAiCompatibleHttpSenderIsOwnerMock, })); vi.mock("./session-utils.js", () => ({ loadSessionEntry: loadSessionEntryMock, loadGatewaySessionEntryReadOnly: loadSessionEntryMock, resolveSessionHistoryTranscriptPathAsync: resolveSessionHistoryTranscriptPathMock, })); vi.mock("./session-transcript-readers.js", () => ({ readSessionMessagesAsync: readSessionMessagesMock, readSessionMessagesWithSourceAsync: async (...args: unknown[]) => ({ messages: await readSessionMessagesMock(...args), transcriptPath: await resolveSessionHistoryTranscriptPathMock(...args), }), })); vi.mock("../media/media-probe.js", () => ({ probePlaybackMediaFileDescriptor: probePlaybackMediaFileDescriptorMock, })); vi.mock("../media/playback-transcode.js", async (importOriginal) => { const actual = await importOriginal(); const testApi = (globalThis as Record)[ Symbol.for("openclaw.playbackTranscodeTestApi") ] as { PLAYBACK_TRANSCODE_POLICY: Record<"audio" | "video", unknown>; resolvePlaybackMode(mimeType: string, policy: unknown): "native" | "transcode" | undefined; }; resolvePlaybackModeForSourceMock.mockImplementation(async (params) => testApi.resolvePlaybackMode(params.mimeType, testApi.PLAYBACK_TRANSCODE_POLICY[params.kind]), ); return { ...actual, resolvePlaybackModeForSource: resolvePlaybackModeForSourceMock, resolvePlaybackTranscode: resolvePlaybackTranscodeMock, }; }); const { DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS, MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX, MANAGED_OUTGOING_MEDIA_ARTIFACT_ID_PREFIX, attachManagedOutgoingMediaToMessage: attachManagedOutgoingImagesToMessage, cleanupManagedOutgoingMediaRecords: cleanupManagedOutgoingImageRecords, createManagedOutgoingMediaBlocks: createManagedOutgoingImageBlocks, handleManagedOutgoingMediaHttpRequest: handleManagedOutgoingImageHttpRequest, resolveManagedOutgoingMediaArtifactDownload: resolveManagedOutgoingImageArtifactDownload, resolveManagedImageAttachmentLimits, } = await import("./managed-image-attachments.js"); async function replaceTestSessionEntry( scope: { agentId: string; env: NodeJS.ProcessEnv; sessionKey: string; storePath?: string; }, entry: { sessionId: string; updatedAt: number }, ): Promise { const { replaceSessionEntry } = await import("../config/sessions/session-accessor.js"); await replaceSessionEntry(scope, entry); } type RequestResult = { statusCode: number; headers: http.IncomingHttpHeaders; body: Buffer; }; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WnXcZ0AAAAASUVORK5CYII="; async function createPngDataUrl(width: number, height: number): Promise { const buffer = createSolidPngBuffer(width, height, { r: 24, g: 64, b: 128 }); return `data:image/png;base64,${buffer.toString("base64")}`; } async function createNoisyPngBuffer(width: number, height: number): Promise { return createNoisyPngFixtureBuffer(width, height); } function requireAttachmentIdFromUrl(url: unknown): string { expect(url).toBeTypeOf("string"); const attachmentId = String(url).split("/").at(-2); if (!attachmentId) { throw new Error(`expected attachment id in URL ${String(url)}`); } return attachmentId; } async function expectPathMissing(targetPath: string): Promise { try { await fs.access(targetPath); } catch (error) { expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); return; } throw new Error(`expected ${targetPath} to be missing`); } type ManagedImageBlock = { type?: string; artifactId?: string; alt?: string; mimeType?: string; sizeBytes?: number; url?: string; openUrl?: string; fileName?: string; playback?: "native" | "transcode"; }; function requireBlock(blocks: unknown[], index = 0): ManagedImageBlock { const block = blocks[index]; if (!block) { throw new Error(`expected block ${index}`); } return block as ManagedImageBlock; } function requireManagedOriginalPath(stateDir: string, attachmentId: string): string { const record = readManagedImageRecord(attachmentId, stateDir); if (!record) { throw new Error(`expected managed image record ${attachmentId}`); } return path.join(stateDir, "media", record.original.mediaSubdir, record.original.mediaId); } function prepareAgentSessionStore(stateDir: string, agentId: string): void { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; openOpenClawAgentDatabase({ agentId, env }); closeOpenClawAgentDatabasesForTest(); } async function prepareManagedSessionStore(stateDir: string): Promise { closeOpenClawAgentDatabasesForTest(); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const storePath = path.join(stateDir, "sessions.sqlite"); await replaceTestSessionEntry( { agentId: "main", env, sessionKey: "agent:main:main", storePath, }, { sessionId: "sess-1", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); const { loadExactSessionEntryReadOnlyResult } = await import("../config/sessions/session-accessor.sqlite-entry-availability.js"); expect( loadExactSessionEntryReadOnlyResult({ agentId: "main", env, sessionKey: "agent:main:main", storePath, }), ).toMatchObject({ found: true, value: { sessionKey: "agent:main:main" } }); getRuntimeConfigMock.mockReturnValue({ session: { store: storePath } }); } async function createFixture( stateDir: string, options?: { sessionKey?: string; agentId?: string; attachmentId?: string; filename?: string; contentType?: string; body?: Buffer; messageId?: string | null; createdAt?: string; }, ) { const attachmentId = options?.attachmentId ?? "11111111-1111-4111-8111-111111111111"; const sessionKey = options?.sessionKey ?? "agent:main:main"; const filename = options?.filename ?? `${attachmentId}-cat-full.png`; const originalPath = path.join(stateDir, "media", MANAGED_OUTGOING_ORIGINALS_SUBDIR, filename); await fs.mkdir(path.dirname(originalPath), { recursive: true }); const body = options?.body ?? Buffer.from("original-image"); await fs.writeFile(originalPath, body); insertManagedImageRecord( { attachmentId, sessionKey, ...(options?.agentId ? { agentId: options.agentId } : {}), messageId: options?.messageId === undefined ? "msg-1" : options.messageId, createdAt: options?.createdAt ?? new Date().toISOString(), alt: "Cat", original: { mediaRoot: path.join(stateDir, "media"), mediaId: filename, mediaSubdir: MANAGED_OUTGOING_ORIGINALS_SUBDIR, contentType: options?.contentType ?? "image/png", width: options?.contentType?.startsWith("image/") === false ? null : 1024, height: options?.contentType?.startsWith("image/") === false ? null : 768, sizeBytes: body.byteLength, filename: options?.filename ?? "cat.png", }, }, stateDir, ); return { attachmentId, sessionKey, originalPath }; } async function requestManagedImage(params: { stateDir: string; pathName: string; method?: string; scopes?: string[]; denyAuth?: boolean; authResponse?: Record; headers?: http.ClientRequestArgs["headers"]; transcriptMessages?: Record[]; sessionEntry?: { sessionId: string; sessionFile?: string }; resolvedTranscriptPath?: string | null; onReadTranscriptMessages?: () => Promise | void; }) { authorizeGatewayHttpRequestOrReplyMock.mockImplementation(async ({ res }) => { if (params.denyAuth) { res.statusCode = 401; res.end(); return null; } return { ok: true, ...params.authResponse }; }); resolveOpenAiCompatibleHttpOperatorScopesMock.mockReturnValue(params.scopes ?? ["operator.read"]); resolveOpenAiCompatibleHttpSenderIsOwnerMock.mockImplementation((_req, requestAuth) => { if (requestAuth.authMethod === "token" || requestAuth.authMethod === "password") { return true; } return ( requestAuth.trustDeclaredOperatorScopes === true && (params.scopes ?? ["operator.read"]).includes("operator.admin") ); }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(params.stateDir, "sessions.sqlite"), entry: params.sessionEntry ?? { sessionId: "sess-1", sessionFile: "session.jsonl" }, }); resolveSessionHistoryTranscriptPathMock.mockResolvedValue( params.resolvedTranscriptPath ?? params.sessionEntry?.sessionFile ?? "session.jsonl", ); readSessionMessagesMock.mockImplementation(async () => { await params.onReadTranscriptMessages?.(); return ( params.transcriptMessages ?? [ { role: "assistant", content: [ { type: "image", url: params.pathName, openUrl: params.pathName, }, ], __openclaw: { id: "msg-1" }, }, ] ); }); const auth = { mode: "test" } as never; const server = http.createServer((req, res) => { void (async () => { const handled = await handleManagedOutgoingImageHttpRequest(req, res, { auth, trustedProxies: ["127.0.0.1/32"], allowRealIpFallback: false, stateDir: params.stateDir, }); if (!handled) { res.statusCode = 404; res.end("unhandled"); } })(); }); await new Promise((resolve) => { server.listen(0, "127.0.0.1", resolve); }); const address = server.address() as AddressInfo; try { const result = await new Promise((resolve, reject) => { const req = http.request( { host: "127.0.0.1", port: address.port, path: params.pathName, method: params.method ?? "GET", headers: params.headers, }, (res) => { void (async () => { const chunks: Buffer[] = []; for await (const chunk of res) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks), }); })(); }, ); req.on("error", reject); req.end(); }); return { result, auth }; } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } } describe("resolveManagedImageAttachmentLimits", () => { it("keeps the existing public limit shape", () => { expect(resolveManagedImageAttachmentLimits()).toEqual(DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS); }); }); describe("handleManagedOutgoingImageHttpRequest", () => { let stateDir: string; beforeEach(async () => { stateDir = tempDirs.make("managed-images-"); vi.clearAllMocks(); await prepareManagedSessionStore(stateDir); }); afterEach(async () => { closeOpenClawStateDatabaseForTest(); setMediaStoreNetworkDepsForTest(); await fs.rm(stateDir, { recursive: true, force: true }); }); it("serves full images for authorized chat-history readers", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); expect( resolveExistingAgentSessionStoreTargetsReadOnlyResult(getRuntimeConfigMock(), "main", { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, }), ).toMatchObject({ available: true, targets: [{ storePath: path.join(stateDir, "sessions.sqlite") }], }); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "token" }, }); expect(resolveSessionHistoryTranscriptPathMock).toHaveBeenCalled(); expect(readSessionMessagesMock).toHaveBeenCalled(); expect(result.statusCode).toBe(200); expect(result.headers["content-type"]).toBe("image/png"); expect(result.headers["content-disposition"]).toContain("inline"); expect(result.body.toString("utf-8")).toBe("original-image"); }); it("serves Unicode media filenames with an encoded content disposition", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "音声.mp3", contentType: "audio/mpeg", body: Buffer.from([0xff, 0xfb, 0x90, 0x00]), }); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const { result } = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, transcriptMessages: [ { role: "assistant", content: [{ type: "audio", url: pathName, openUrl: pathName }], __openclaw: { id: "msg-1" }, }, ], }); expect(result.statusCode).toBe(200); expect(result.headers["content-disposition"]).toContain("filename*=UTF-8''"); expect(result.headers["content-disposition"]).toContain("%E9%9F%B3%E5%A3%B0.mp3"); }); it("serves a byte range from the validated managed image", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "token" }, headers: { range: "bytes=9-13" }, }); expect(result.statusCode).toBe(206); expect(result.headers["accept-ranges"]).toBe("bytes"); expect(result.headers["content-range"]).toBe("bytes 9-13/14"); expect(result.headers["content-length"]).toBe("5"); expect(result.headers.etag).toMatch(/^"[A-Za-z0-9_-]+"$/); expect(result.body.toString("utf8")).toBe("image"); }); it("resumes managed media only for an exact If-Range HTTP-date", async () => { const { attachmentId, sessionKey, originalPath } = await createFixture(stateDir); const modified = new Date("2025-07-08T18:40:00.789Z"); await fs.utimes(originalPath, modified, modified); const lastModified = (await fs.stat(originalPath)).mtime.toUTCString(); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const request = { stateDir, pathName, authResponse: { authMethod: "token" } }; const initial = await requestManagedImage({ ...request, method: "HEAD" }); expect(initial.result.statusCode).toBe(200); expect(initial.result.headers["last-modified"]).toBe(lastModified); expect(initial.result.body).toHaveLength(0); const partial = await requestManagedImage({ ...request, headers: { range: "bytes=9-13", "if-range": lastModified }, }); expect(partial.result.statusCode).toBe(206); expect(partial.result.headers["last-modified"]).toBe(lastModified); expect(partial.result.body.toString("utf8")).toBe("image"); const future = await requestManagedImage({ ...request, headers: { range: "bytes=9-13", "if-range": new Date(Date.parse(lastModified) + 1000).toUTCString(), }, }); expect(future.result.statusCode).toBe(200); expect(future.result.headers["last-modified"]).toBe(lastModified); expect(future.result.body.toString("utf8")).toBe("original-image"); }); it("bounds future managed-media validators to the actual HTTP response date", async () => { const nowMs = Math.floor(Date.now() / 1000) * 1000; const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs); try { const { attachmentId, sessionKey, originalPath } = await createFixture(stateDir); const future = new Date(nowMs + 60_000); await fs.utimes(originalPath, future, future); const futureLastModified = (await fs.stat(originalPath)).mtime.toUTCString(); const expectedLastModified = new Date(nowMs).toUTCString(); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const request = { stateDir, pathName, authResponse: { authMethod: "token" } }; const initial = await requestManagedImage({ ...request, method: "HEAD" }); expect(initial.result.statusCode).toBe(200); expect(initial.result.headers["last-modified"]).toBe(expectedLastModified); expect(Date.parse(initial.result.headers.date ?? "")).toBeGreaterThanOrEqual( Date.parse(expectedLastModified), ); const partial = await requestManagedImage({ ...request, headers: { range: "bytes=0-4", "if-range": expectedLastModified }, }); expect(partial.result.statusCode).toBe(206); expect(partial.result.headers["last-modified"]).toBe(expectedLastModified); const stale = await requestManagedImage({ ...request, headers: { range: "bytes=0-4", "if-range": futureLastModified }, }); expect(stale.result.statusCode).toBe(200); const unchanged = await requestManagedImage({ ...request, headers: { "if-none-match": String(initial.result.headers.etag) }, }); expect(unchanged.result.statusCode).toBe(304); expect(unchanged.result.headers["last-modified"]).toBe(expectedLastModified); const unsatisfiable = await requestManagedImage({ ...request, headers: { range: "bytes=999-" }, }); expect(unsatisfiable.result.statusCode).toBe(416); expect(unsatisfiable.result.headers["last-modified"]).toBe(expectedLastModified); } finally { dateNow.mockRestore(); } }); it.each(["GET", "HEAD"])( "revalidates managed media ETags before ranges for %s", async (method) => { const { attachmentId, sessionKey } = await createFixture(stateDir); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const initial = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, }); const etag = initial.result.headers.etag; expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/); const { result } = await requestManagedImage({ stateDir, pathName, method, authResponse: { authMethod: "token" }, headers: { "if-none-match": `W/${String(etag)}`, range: "bytes=0-3", "if-range": '"stale"', }, }); expect(result.statusCode).toBe(304); expect(result.headers.etag).toBe(etag); expect(result.headers["content-length"]).toBeUndefined(); expect(result.headers["content-range"]).toBeUndefined(); expect(result.body).toHaveLength(0); }, ); it.each(["GET", "HEAD"] as const)( "revalidates managed media with If-Modified-Since before ranges for %s", async (method) => { const { attachmentId, sessionKey } = await createFixture(stateDir); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const request = { stateDir, pathName, authResponse: { authMethod: "token" } }; const initial = await requestManagedImage({ ...request, method: "HEAD" }); const lastModified = initial.result.headers["last-modified"]; expect(lastModified).toEqual(expect.any(String)); const unchanged = await requestManagedImage({ ...request, method, headers: { "if-modified-since": String(lastModified), range: "bytes=0-3", "if-range": '"stale"', }, }); expect(unchanged.result.statusCode).toBe(304); expect(unchanged.result.headers["last-modified"]).toBe(lastModified); expect(unchanged.result.headers["content-length"]).toBeUndefined(); expect(unchanged.result.headers["content-range"]).toBeUndefined(); expect(unchanged.result.body).toHaveLength(0); }, ); it.each(["GET", "HEAD"] as const)( "ignores duplicate managed-media dates discarded by normalized Node headers for %s", async (method) => { const { attachmentId, sessionKey } = await createFixture(stateDir); const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const request = { stateDir, pathName, authResponse: { authMethod: "token" } }; const initial = await requestManagedImage({ ...request, method: "HEAD" }); const lastModified = String(initial.result.headers["last-modified"]); const duplicate = await requestManagedImage({ ...request, method, headers: [ "Host", "127.0.0.1", "If-Modified-Since", lastModified, "iF-mOdIfIeD-sInCe", "not-an-http-date", ], }); expect(duplicate.result.statusCode).toBe(200); expect(duplicate.result.headers["last-modified"]).toBe(lastModified); }, ); it("serves a ticketed byte range from managed audio", async () => { const body = Buffer.from("original-audio"); const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "voice.mp3", contentType: "audio/mpeg", body, }); const canonicalPath = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-1", sessionFile: "session.jsonl" }, }); resolveSessionHistoryTranscriptPathMock.mockResolvedValue("session.jsonl"); readSessionMessagesMock.mockResolvedValue([ { role: "assistant", content: [{ type: "audio", url: canonicalPath, openUrl: canonicalPath }], __openclaw: { id: "msg-1" }, }, ]); const download = await resolveManagedOutgoingImageArtifactDownload({ sessionKey, artifactId: `${MANAGED_OUTGOING_MEDIA_ARTIFACT_ID_PREFIX}${attachmentId}`, stateDir, }); const { result } = await requestManagedImage({ stateDir, pathName: download?.url ?? "", denyAuth: true, headers: { range: "bytes=9-13" }, transcriptMessages: [ { role: "assistant", content: [{ type: "audio", url: canonicalPath, openUrl: canonicalPath }], __openclaw: { id: "msg-1" }, }, ], }); expect(download?.type).toBe("audio"); expect(result.statusCode).toBe(206); expect(result.headers["content-type"]).toBe("audio/mpeg"); expect(result.headers["content-range"]).toBe(`bytes 9-13/${body.byteLength}`); expect(result.body.toString("utf8")).toBe("audio"); }); it("returns 202 while a managed playback transcode is preparing", async () => { resolvePlaybackTranscodeMock.mockResolvedValueOnce({ kind: "preparing" }); const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "voice.caf", contentType: "audio/x-caf", body: Buffer.from("caff-original"), }); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full?playback=1`, authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(202); expect(result.headers["content-type"]).toContain("application/json"); expect(JSON.parse(result.body.toString("utf8"))).toEqual({ status: "preparing" }); }); it("falls back to original managed bytes when playback transcode fails", async () => { resolvePlaybackTranscodeMock.mockResolvedValueOnce({ kind: "fallback" }); const body = Buffer.from("caff-original"); const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "voice.caf", contentType: "audio/x-caf", body, }); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full?playback=1`, authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); expect(result.headers["content-type"]).toBe("audio/x-caf"); expect(result.body).toEqual(body); }); it("closes the opened managed-media descriptor when playback resolution rejects", async () => { const { attachmentId, sessionKey, originalPath } = await createFixture(stateDir, { filename: "voice.caf", contentType: "audio/x-caf", body: Buffer.from("caff-original"), }); authorizeGatewayHttpRequestOrReplyMock.mockResolvedValue({ ok: true, authMethod: "token" }); resolveOpenAiCompatibleHttpOperatorScopesMock.mockReturnValue(["operator.read"]); resolveOpenAiCompatibleHttpSenderIsOwnerMock.mockReturnValue(true); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-1", sessionFile: "session.jsonl" }, }); resolveSessionHistoryTranscriptPathMock.mockResolvedValue("session.jsonl"); readSessionMessagesMock.mockResolvedValue([ { role: "assistant", content: [ { type: "audio", url: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, }, ], __openclaw: { id: "msg-1" }, }, ]); resolvePlaybackTranscodeMock.mockRejectedValueOnce(new Error("playback inspection failed")); const originalOpen = fs.open; let closeOpenedHandle: MockInstance<() => Promise> | undefined; const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { const handle = await originalOpen(...args); if (String(args[0]) === originalPath) { closeOpenedHandle = vi.spyOn(handle, "close"); } return handle; }); const { res } = makeMockHttpResponse(); try { await expect( handleManagedOutgoingImageHttpRequest( { url: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full?playback=1`, method: "GET", headers: {}, } as http.IncomingMessage, res, { auth: { mode: "test" } as never, stateDir }, ), ).rejects.toThrow("playback inspection failed"); expect(closeOpenedHandle).toHaveBeenCalledOnce(); } finally { openSpy.mockRestore(); } }); it("passes native managed playback bytes through unchanged", async () => { const body = Buffer.from("ID3-native-audio"); const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "voice.mp3", contentType: "audio/mpeg", body, }); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full?playback=1`, authResponse: { authMethod: "token" }, }); expect(resolvePlaybackTranscodeMock).toHaveBeenCalledWith( expect.objectContaining({ mimeType: "audio/mpeg", kind: "audio" }), ); expect(result.statusCode).toBe(200); expect(result.body).toEqual(body); }); it("serves byte ranges from a cached managed playback transcode", async () => { const transcodedPath = path.join(stateDir, "cached-voice.m4a"); const transcoded = Buffer.from("normalized-audio"); await fs.writeFile(transcodedPath, transcoded); resolvePlaybackTranscodeMock.mockResolvedValueOnce({ kind: "transcoded", path: transcodedPath, contentType: "audio/mp4", extension: ".m4a", }); const { attachmentId, sessionKey } = await createFixture(stateDir, { filename: "voice.caf", contentType: "audio/x-caf", body: Buffer.from("caff-original"), }); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full?playback=1`, authResponse: { authMethod: "token" }, headers: { range: "bytes=11-15" }, }); expect(result.statusCode).toBe(206); expect(result.headers["content-type"]).toBe("audio/mp4"); expect(result.headers["content-disposition"]).toContain('filename="voice.m4a"'); expect(result.headers["content-range"]).toBe(`bytes 11-15/${transcoded.byteLength}`); expect(result.body.toString("utf8")).toBe("audio"); }); it("advertises byte ranges without a body for HEAD", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, method: "HEAD", authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); expect(result.headers["accept-ranges"]).toBe("bytes"); expect(result.headers["content-length"]).toBe("14"); expect(result.headers.etag).toMatch(/^"[A-Za-z0-9_-]+"$/); expect(result.body).toHaveLength(0); }); it("serves an empty managed image without a body", async () => { const { attachmentId, sessionKey, originalPath } = await createFixture(stateDir); await fs.writeFile(originalPath, Buffer.alloc(0)); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); expect(result.headers["content-length"]).toBe("0"); expect(result.body).toHaveLength(0); }); it("serves an exact transcript image through a short-lived artifact ticket", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const canonicalPath = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-1", sessionFile: "session.jsonl" }, }); resolveSessionHistoryTranscriptPathMock.mockResolvedValue("session.jsonl"); readSessionMessagesMock.mockResolvedValue([ { role: "assistant", content: [{ type: "image", url: canonicalPath, openUrl: canonicalPath }], __openclaw: { id: "msg-1" }, }, ]); const download = await resolveManagedOutgoingImageArtifactDownload({ sessionKey, artifactId: `${MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX}${attachmentId}`, stateDir, }); expect(download?.url).toContain("mediaTicket="); vi.clearAllMocks(); const { result } = await requestManagedImage({ stateDir, pathName: download?.url ?? "", denyAuth: true, }); expect(result.statusCode).toBe(200); expect(result.body.toString("utf-8")).toBe("original-image"); expect(authorizeGatewayHttpRequestOrReplyMock).not.toHaveBeenCalled(); const wrongAttachmentId = "22222222-2222-4222-8222-222222222222"; const wrong = await requestManagedImage({ stateDir, pathName: (download?.url ?? "").replace(attachmentId, wrongAttachmentId), denyAuth: true, }); expect(wrong.result.statusCode).toBe(401); expect(authorizeGatewayHttpRequestOrReplyMock).toHaveBeenCalledTimes(1); }); it("serves a bounded thumbnail through the full-image artifact ticket", async () => { const source = createSolidPngBuffer(640, 320, { r: 24, g: 64, b: 128 }); const { attachmentId, sessionKey } = await createFixture(stateDir, { body: source }); const canonicalPath = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const transcriptMessages = [ { role: "assistant", content: [{ type: "image", url: canonicalPath, openUrl: canonicalPath }], __openclaw: { id: "msg-1" }, }, ]; loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "sessions.sqlite"), entry: { sessionId: "sess-1", sessionFile: "session.jsonl" }, }); resolveSessionHistoryTranscriptPathMock.mockResolvedValue("session.jsonl"); readSessionMessagesMock.mockResolvedValue(transcriptMessages); const download = await resolveManagedOutgoingImageArtifactDownload({ sessionKey, artifactId: `${MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX}${attachmentId}`, stateDir, }); const thumbnailUrl = download?.url.replace(/\/full(?=\?)/u, "/thumbnail") ?? ""; vi.clearAllMocks(); const { result } = await requestManagedImage({ stateDir, pathName: thumbnailUrl, denyAuth: true, transcriptMessages, }); expect(result.statusCode).toBe(200); expect(result.headers["content-type"]).toBe("image/png"); expect(result.headers["content-disposition"]).toContain("cat-thumbnail.png"); expect(readImageProbeFromHeader(result.body)).toMatchObject({ width: 300, height: 150 }); expect(authorizeGatewayHttpRequestOrReplyMock).not.toHaveBeenCalled(); }); it("keeps serving and deleting an original after the configured media root changes", async () => { const fixture = await createFixture(stateDir); const externalConfigDir = tempDirs.make("managed-image-moved-config-"); const isolatedHome = tempDirs.make("managed-image-moved-home-"); try { await withEnvAsync( { OPENCLAW_CONFIG_PATH: path.join(externalConfigDir, "config.json"), OPENCLAW_HOME: isolatedHome, OPENCLAW_STATE_DIR: undefined, }, async () => { const pathName = `/api/chat/media/outgoing/${encodeURIComponent(fixture.sessionKey)}/${fixture.attachmentId}/full`; const { result } = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); expect(result.body.toString("utf8")).toBe("original-image"); await cleanupManagedOutgoingImageRecords({ stateDir, sessionKey: fixture.sessionKey, forceDeleteSessionRecords: true, }); await expectPathMissing(fixture.originalPath); }, ); } finally { await fs.rm(externalConfigDir, { recursive: true, force: true }); await fs.rm(isolatedHome, { recursive: true, force: true }); } }); it("does not read retired record JSON at runtime", async () => { const attachmentId = "11111111-1111-4111-8111-111111111111"; const sessionKey = "agent:main:main"; const originalPath = path.join( stateDir, "media", MANAGED_OUTGOING_ORIGINALS_SUBDIR, "legacy.png", ); const recordPath = path.join(stateDir, "media", "outgoing", "records", `${attachmentId}.json`); await fs.mkdir(path.dirname(originalPath), { recursive: true }); await fs.mkdir(path.dirname(recordPath), { recursive: true }); await fs.writeFile(originalPath, "legacy-image"); await fs.writeFile( recordPath, JSON.stringify({ attachmentId, sessionKey, messageId: "msg-1", createdAt: new Date().toISOString(), alt: "Legacy", original: { path: originalPath, contentType: "image/png", width: 1, height: 1, sizeBytes: 12, filename: "legacy.png", }, }), ); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(404); await expect(fs.access(recordPath)).resolves.toBeUndefined(); }); it("rejects unauthenticated requests before serving bytes", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, denyAuth: true, }); expect(result.statusCode).toBe(401); expect(result.body.byteLength).toBe(0); }); it("rejects non-owner trusted-proxy requests with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, headers: { "x-openclaw-requester-session-key": sessionKey }, }); expect(result.statusCode).toBe(403); }); it("rejects device-token access with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "device-token" }, headers: { "x-openclaw-requester-session-key": sessionKey }, }); expect(result.statusCode).toBe(403); }); it("serves owner trusted-proxy requests with admin scope", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, scopes: ["operator.admin"], }); expect(result.statusCode).toBe(200); expect(result.body.toString("utf-8")).toBe("original-image"); }); it("rejects non-GET methods", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, method: "POST", headers: { "x-openclaw-requester-session-key": sessionKey }, }); expect(result.statusCode).toBe(405); }); it("rejects malformed encoded session keys", async () => { const { attachmentId } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/%E0%A4%A/${attachmentId}/full`, authResponse: { authMethod: "device-token" }, }); expect(result.statusCode).toBe(404); }); it("reuses the session attachment index across requests until the transcript changes", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const sessionFile = path.join(stateDir, "sessions", "sess-main.jsonl"); await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile(sessionFile, '{"message":{}}\n', "utf-8"); const transcriptMessages = [ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, }, ], }, ]; const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const first = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); const second = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); expect(first.result.statusCode).toBe(200); expect(second.result.statusCode).toBe(200); expect(readSessionMessagesMock).toHaveBeenCalledTimes(1); await fs.writeFile(sessionFile, '{"message":{}}\n{"message":{"content":"updated"}}\n', "utf-8"); const third = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); expect(third.result.statusCode).toBe(200); expect(readSessionMessagesMock).toHaveBeenCalledTimes(2); }); it("reuses the session attachment index for archive-backed requests", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const archiveFile = path.join( stateDir, "sessions", "sess-main.jsonl.reset.2026-02-16T22-26-34.000Z", ); await fs.mkdir(path.dirname(archiveFile), { recursive: true }); await fs.writeFile(archiveFile, '{"message":{}}\n', "utf-8"); const transcriptMessages = [ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, }, ], }, ]; const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const first = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main" }, resolvedTranscriptPath: archiveFile, transcriptMessages, }); const second = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main" }, resolvedTranscriptPath: archiveFile, transcriptMessages, }); expect(first.result.statusCode).toBe(200); expect(second.result.statusCode).toBe(200); expect(readSessionMessagesMock).toHaveBeenCalledTimes(1); }); it("does not cache a session attachment index when the transcript changes during the read", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const sessionFile = path.join(stateDir, "sessions", "sess-main.jsonl"); await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile(sessionFile, '{"message":{}}\n', "utf-8"); const transcriptMessages = [ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, }, ], }, ]; let mutatedTranscript = false; const pathName = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; const first = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, onReadTranscriptMessages: async () => { if (!mutatedTranscript) { mutatedTranscript = true; await fs.appendFile(sessionFile, '{"message":{"content":"updated"}}\n', "utf-8"); } }, }); const second = await requestManagedImage({ stateDir, pathName, authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); expect(first.result.statusCode).toBe(200); expect(second.result.statusCode).toBe(200); expect(readSessionMessagesMock).toHaveBeenCalledTimes(2); }); }); describe("createManagedOutgoingImageBlocks", () => { let stateDir: string; beforeEach(async () => { stateDir = tempDirs.make("managed-image-blocks-"); vi.clearAllMocks(); await prepareManagedSessionStore(stateDir); }); afterEach(async () => { closeOpenClawStateDatabaseForTest(); setMediaStoreNetworkDepsForTest(); await fs.rm(stateDir, { recursive: true, force: true }); }); it("creates inline/open blocks that both point at the full image", async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:image/png;base64,${TINY_PNG_BASE64}`], stateDir, messageId: "msg-1", }); expect(blocks).toHaveLength(1); const block = requireBlock(blocks); expect(block.type).toBe("image"); expect(block.alt).toBe("Generated image 1"); expect(block.mimeType).toBe("image/png"); expect(block.url).toBe(block.openUrl); expect(String(block.url)).toMatch(/\/full$/); const attachmentId = requireAttachmentIdFromUrl(block.url); expect(block.artifactId).toBe(`${MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX}${attachmentId}`); expect(block.sizeBytes).toBe(Buffer.from(TINY_PNG_BASE64, "base64").byteLength); const record = readManagedImageRecord(attachmentId, stateDir); expect(record?.original.mediaSubdir).toBe(MANAGED_OUTGOING_ORIGINALS_SUBDIR); expect(record?.original.mediaId).toMatch(/\.png$/); }); it.each([ { kind: "audio" as const, contentType: "audio/mpeg", fileName: "theme.mp3" }, { kind: "video" as const, contentType: "video/mp4", fileName: "clip.mp4" }, ])( "creates managed $kind blocks with media artifact ids", async ({ kind, contentType, fileName }) => { const sourcePath = path.join(stateDir, "workspace", fileName); await fs.mkdir(path.dirname(sourcePath), { recursive: true }); await fs.writeFile( sourcePath, kind === "audio" ? Buffer.from([0xff, 0xfb, 0x90, 0x00]) : Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32]), ); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [sourcePath], stateDir, localRoots: [path.join(stateDir, "workspace")], allowLocalNonImage: true, }); expect(blocks).toHaveLength(1); const block = requireBlock(blocks); expect(block).toMatchObject({ type: kind, mimeType: contentType, fileName, playback: "native", }); const attachmentId = requireAttachmentIdFromUrl(block.url); expect(block.artifactId).toBe(`${MANAGED_OUTGOING_MEDIA_ARTIFACT_ID_PREFIX}${attachmentId}`); expect(readManagedImageRecord(attachmentId, stateDir)?.original).toMatchObject({ contentType, width: null, height: null, }); }, ); it("marks exotic managed media metadata for playback transcoding", async () => { const sourcePath = path.join(stateDir, "workspace", "voice.caf"); await fs.mkdir(path.dirname(sourcePath), { recursive: true }); await fs.writeFile(sourcePath, Buffer.concat([Buffer.from("caff"), Buffer.alloc(16)])); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [sourcePath], stateDir, localRoots: [path.dirname(sourcePath)], allowLocalNonImage: true, }); expect(requireBlock(blocks)).toMatchObject({ type: "audio", mimeType: "audio/x-caf", playback: "transcode", }); }); it.each(["audio", "video"] as const)("caps managed %s data URLs by media kind", async (kind) => { const maxBytes = maxBytesForKind(kind); const contentType = kind === "audio" ? "audio/mpeg" : "video/mp4"; const oversized = Buffer.alloc(maxBytes + 1).toString("base64"); await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:${contentType};base64,${oversized}`], stateDir, }), ).rejects.toThrow(new RegExp(`Managed ${kind} attachment.*16 MiB byte limit`)); }); it("requires explicit reply trust for local audio", async () => { const sourcePath = path.join(stateDir, "workspace", "voice.mp3"); await fs.mkdir(path.dirname(sourcePath), { recursive: true }); await fs.writeFile(sourcePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [sourcePath], stateDir, localRoots: [path.join(stateDir, "workspace")], }), ).rejects.toThrow(/Managed audio attachment.*could not be prepared/u); }); it("rejects oversized image data urls before decoding the payload", async () => { const oversizedDataUrl = "data:image/png;base64,AAAAAA=="; await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [oversizedDataUrl], stateDir, limits: { ...DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS, maxBytes: 3, }, }), ).rejects.toThrow(/Generated image 1.*byte limit/); await expectPathMissing(path.join(stateDir, "media", "outgoing", "records")); }); it("rejects semicolon-heavy malformed data urls without backtracking", async () => { const semicolons = ";".repeat(64); const malformed = `data:image/png${semicolons}`; await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [malformed], stateDir, }), ).rejects.toThrow("Invalid image data URL"); }); it("rejects malformed data urls with a later ;base64, marker after a payload comma", async () => { await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: ["data:image/png;base64,garbage;base64,iVBORw0KGgo="], stateDir, }), ).rejects.toThrow("Invalid image data URL"); }); it("requires the base64 marker to be adjacent to the payload comma", async () => { await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:image/png;base64\n,${TINY_PNG_BASE64}`], stateDir, }), ).rejects.toThrow("Invalid image data URL"); }); it("preserves parameterized image data urls", async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:image/png;charset=utf-8;base64,${TINY_PNG_BASE64}`], stateDir, }); expect(requireBlock(blocks).mimeType).toBe("image/png"); }); it("rejects data urls without a media type", async () => { await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:;base64,${TINY_PNG_BASE64}`], stateDir, }), ).rejects.toThrow("Invalid image data URL"); }); it.each([ { name: "local paths", sourceForPath: (sourcePath: string) => sourcePath }, { name: "file URLs", sourceForPath: (sourcePath: string) => { const sourceUrl = pathToFileURL(sourcePath); sourceUrl.searchParams.set("sig", "secret"); sourceUrl.hash = "preview"; return sourceUrl.href; }, }, ])( "rewrites $name into managed display blocks without leaking the source path", async ({ sourceForPath }) => { const sourcePath = path.join(stateDir, "workspace", "fixtures", "dot.png"); await fs.mkdir(path.dirname(sourcePath), { recursive: true }); await fs.writeFile(sourcePath, Buffer.from(TINY_PNG_BASE64, "base64")); await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { const blocks = await createManagedOutgoingImageBlocks({ stateDir, sessionKey: "agent:main:main", mediaUrls: [sourceForPath(sourcePath)], localRoots: [path.join(stateDir, "workspace")], }); expect(blocks).toHaveLength(1); const block = requireBlock(blocks); expect(block.type).toBe("image"); expect(block.url).toContain("/api/chat/media/outgoing/agent%3Amain%3Amain/"); expect(block.openUrl).toContain("/full"); expect(block.url).toBe(block.openUrl); expect(block.alt).toBe("dot.png"); expect(JSON.stringify(block)).not.toContain(sourcePath); expect(JSON.stringify(block)).not.toContain("sig=secret"); expect(JSON.stringify(block)).not.toContain("preview"); const attachmentId = requireAttachmentIdFromUrl(block.url); const record = readManagedImageRecord(attachmentId, stateDir); const originalPath = requireManagedOriginalPath(stateDir, attachmentId); expect(record?.original.filename).toMatch(/\.png$/); expect(originalPath).not.toBe(sourcePath); expect(originalPath).toContain(path.join(stateDir, "media", "outgoing", "originals")); }); }, ); it("ingests external image URLs into managed storage instead of hotlinking them", async () => { const imageBuffer = Buffer.from(TINY_PNG_BASE64, "base64"); const upstream = http.createServer((req, res) => { expect(req.url).toBe("/remote-cat.png?sig=secret"); res.statusCode = 200; res.setHeader("content-type", "image/png"); res.end(imageBuffer); }); await new Promise((resolve) => { upstream.listen(0, "127.0.0.1", resolve); }); const address = upstream.address() as AddressInfo; setMediaStoreNetworkDepsForTest({ resolvePinnedHostname: async (hostname) => ({ hostname, addresses: ["127.0.0.1"], lookup: createPinnedLookup({ hostname, addresses: ["127.0.0.1"] }), }), }); try { await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { const sourceUrl = `http://127.0.0.1:${address.port}/remote-cat.png?sig=secret`; const blocks = await createManagedOutgoingImageBlocks({ stateDir, sessionKey: "agent:main:main", mediaUrls: [sourceUrl], }); expect(blocks).toHaveLength(1); const block = requireBlock(blocks); expect(block.alt).toBe("remote-cat.png"); expect(block.type).toBe("image"); expect(block.url).toContain("/api/chat/media/outgoing/agent%3Amain%3Amain/"); expect(block.openUrl).toContain("/full"); expect(block.url).toBe(block.openUrl); expect(JSON.stringify(block)).not.toContain("127.0.0.1"); expect(JSON.stringify(block)).not.toContain("sig=secret"); const attachmentId = requireAttachmentIdFromUrl(block.url); const record = readManagedImageRecord(attachmentId, stateDir); const originalPath = requireManagedOriginalPath(stateDir, attachmentId); expect(originalPath).toContain(path.join(stateDir, "media", "outgoing", "originals")); expect(JSON.stringify(record)).not.toContain("127.0.0.1"); expect(JSON.stringify(record)).not.toContain("sig=secret"); expect(await fs.readFile(originalPath)).toEqual(imageBuffer); }); } finally { setMediaStoreNetworkDepsForTest(); await new Promise((resolve, reject) => { upstream.close((error) => (error ? reject(error) : resolve())); }); } }); it("serves managed originals from a split config-path media root", async () => { const openClawHome = tempDirs.make("managed-image-home-"); const externalConfigDir = tempDirs.make("managed-image-config-"); const splitStateDir = path.join(openClawHome, ".openclaw"); const sourcePath = path.join(splitStateDir, "workspace", "fixtures", "dot.png"); await fs.mkdir(path.dirname(sourcePath), { recursive: true }); await fs.writeFile(sourcePath, Buffer.from(TINY_PNG_BASE64, "base64")); try { await withEnvAsync( { OPENCLAW_HOME: openClawHome, OPENCLAW_CONFIG_PATH: path.join(externalConfigDir, "config.json"), OPENCLAW_STATE_DIR: undefined, }, async () => { await prepareManagedSessionStore(splitStateDir); const blocks = await createManagedOutgoingImageBlocks({ stateDir: splitStateDir, sessionKey: "agent:main:main", messageId: "msg-1", mediaUrls: [sourcePath], localRoots: [path.join(splitStateDir, "workspace")], }); const attachmentId = requireAttachmentIdFromUrl(blocks[0]?.url); const record = readManagedImageRecord(attachmentId, splitStateDir); if (!record) { throw new Error(`expected managed image record ${attachmentId}`); } const originalPath = path.join( externalConfigDir, "media", record.original.mediaSubdir, record.original.mediaId, ); expect(originalPath).toContain( path.join(externalConfigDir, "media", "outgoing", "originals"), ); expect(record.original.mediaRoot).toBe(path.join(externalConfigDir, "media")); await expect(fs.access(originalPath)).resolves.toBeUndefined(); const { result } = await requestManagedImage({ stateDir: splitStateDir, pathName: String(blocks[0]?.url), authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); expect(result.body).toEqual(Buffer.from(TINY_PNG_BASE64, "base64")); await cleanupManagedOutgoingImageRecords({ stateDir: splitStateDir, sessionKey: "agent:main:main", forceDeleteSessionRecords: true, }); await expectPathMissing(originalPath); }, ); } finally { closeOpenClawStateDatabaseForTest(); await fs.rm(openClawHome, { recursive: true, force: true }); await fs.rm(externalConfigDir, { recursive: true, force: true }); } }); it("merges configured managed image limits with defaults", () => { expect(resolveManagedImageAttachmentLimits()).toEqual(DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS); expect( resolveManagedImageAttachmentLimits({ maxWidth: 8192, maxHeight: 2048, }), ).toEqual({ ...DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS, maxWidth: 8192, maxHeight: 2048, }); }); it("rejects managed outgoing images that exceed configured byte limits", async () => { await expect( createManagedOutgoingImageBlocks({ stateDir, sessionKey: "agent:main:main", mediaUrls: [`data:image/png;base64,${TINY_PNG_BASE64}`], limits: { maxBytes: 32 }, }), ).rejects.toThrow(/0MB limit|32 bytes|byte limit/i); }); it("adds a warning block when an image is resized to fit limits", async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [await createPngDataUrl(200, 120)], stateDir, limits: { maxWidth: 64, maxHeight: 64, maxPixels: 4096 }, }); expect(blocks).toHaveLength(2); expect(blocks[0]?.type).toBe("image"); expect(requireBlock(blocks, 1).type).toBe("text"); }); it("skips broken attachments when continueOnPrepareError is enabled", async () => { const onPrepareError = vi.fn(); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [await createPngDataUrl(32, 32), path.join(stateDir, "missing.png")], stateDir, localRoots: [stateDir], continueOnPrepareError: true, onPrepareError, }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks).type).toBe("image"); expect(onPrepareError).toHaveBeenCalledTimes(1); const firstPrepareError = onPrepareError.mock.calls[0]?.[0]; expect(firstPrepareError).toBeInstanceOf(Error); expect(firstPrepareError?.message).toMatch( /Managed image attachment .* could not be prepared/i, ); }); it("skips malformed media data URLs when continueOnPrepareError is enabled", async () => { const onPrepareError = vi.fn(); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: ["data:audio/mpeg;base64,not-valid!", await createPngDataUrl(8, 8)], stateDir, continueOnPrepareError: true, onPrepareError, }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks).type).toBe("image"); expect(onPrepareError).toHaveBeenCalledOnce(); }); it("accepts URL images up to the configured managed-image byte limit", async () => { const imageBuffer = await createNoisyPngBuffer(1600, 1200); expect(imageBuffer.byteLength).toBeGreaterThan(5 * 1024 * 1024); expect(imageBuffer.byteLength).toBeLessThan(DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS.maxBytes); const server = http.createServer((_req, res) => { res.statusCode = 200; res.setHeader("content-type", "image/png"); res.end(imageBuffer); }); await new Promise((resolve) => { server.listen(0, "127.0.0.1", resolve); }); const address = server.address() as AddressInfo; setMediaStoreNetworkDepsForTest({ resolvePinnedHostname: async (hostname) => ({ hostname, addresses: ["127.0.0.1"], lookup: createPinnedLookup({ hostname, addresses: ["127.0.0.1"] }), }), }); try { await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`http://127.0.0.1:${address.port}/large-image.png`], stateDir, }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks).type).toBe("image"); }); } finally { setMediaStoreNetworkDepsForTest(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); it("rejects local image paths outside allowed roots", async () => { const outsideDir = tempDirs.make("managed-image-outside-"); const outsidePath = path.join(outsideDir, "outside.png"); await fs.writeFile(outsidePath, Buffer.from(TINY_PNG_BASE64, "base64")); try { await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [outsidePath], stateDir, localRoots: [path.join(stateDir, "workspace")], }), ).rejects.toThrow(/could not be prepared/i); } finally { await fs.rm(outsideDir, { recursive: true, force: true }); } }); it("accepts local image paths inside allowed roots", async () => { const allowedDir = path.join(stateDir, "workspace", "uploads"); const allowedPath = path.join(allowedDir, "inside.png"); await fs.mkdir(allowedDir, { recursive: true }); await fs.writeFile(allowedPath, Buffer.from(TINY_PNG_BASE64, "base64")); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [allowedPath], stateDir, localRoots: [path.join(stateDir, "workspace")], }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks).type).toBe("image"); }); it("allows managed inbound image paths before validating explicit roots", async () => { const inboundPath = path.join(stateDir, "media", "inbound", "inbound.png"); await fs.mkdir(path.dirname(inboundPath), { recursive: true }); await fs.writeFile(inboundPath, Buffer.from(TINY_PNG_BASE64, "base64")); await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [inboundPath], stateDir, localRoots: [path.parse(stateDir).root], }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks).type).toBe("image"); }); }); it("rejects relative local image paths that resolve outside allowed roots", async () => { const allowedWorkspaceDir = path.join(stateDir, "workspace"); const outsidePath = path.join(stateDir, "outside.png"); await fs.mkdir(allowedWorkspaceDir, { recursive: true }); await fs.writeFile(outsidePath, Buffer.from(TINY_PNG_BASE64, "base64")); const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(allowedWorkspaceDir); try { await expect( createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: ["../outside.png"], stateDir, localRoots: [allowedWorkspaceDir], }), ).rejects.toThrow(/could not be prepared/i); } finally { cwdSpy.mockRestore(); } }); it("drops downloaded non-image sources without leaving orphaned originals", async () => { const pdfPath = path.join(stateDir, "not-an-image.pdf"); await fs.writeFile(pdfPath, Buffer.from("%PDF-1.4\n% test\n")); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [pdfPath], stateDir, localRoots: [stateDir], }); expect(blocks).toStrictEqual([]); const originalsDir = path.join(stateDir, "media", "outgoing", "originals"); let originals: string[] | null = null; try { originals = await fs.readdir(originalsDir); } catch (error) { expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); } expect(originals ?? []).toStrictEqual([]); }); it("does not apply the configured image cap to managed audio", async () => { const audioPath = path.join(stateDir, "large-audio.mp3"); await fs.writeFile(audioPath, Buffer.alloc(2048, 1)); const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [audioPath], stateDir, localRoots: [stateDir], allowLocalNonImage: true, limits: { maxBytes: 1024 }, }); expect(blocks).toHaveLength(1); expect(requireBlock(blocks)).toMatchObject({ type: "audio", mimeType: "audio/mpeg", sizeBytes: 2048, }); }); it("does not reap older transient records while creating a new managed image", async () => { const staleOriginalPath = path.join(stateDir, "files", "stale-cat.png"); const staleAttachmentId = "stale-att"; const staleRecordPath = path.join( stateDir, "media", "outgoing", "records", `${staleAttachmentId}.json`, ); await fs.mkdir(path.dirname(staleOriginalPath), { recursive: true }); await fs.mkdir(path.dirname(staleRecordPath), { recursive: true }); await fs.writeFile(staleOriginalPath, Buffer.from(TINY_PNG_BASE64, "base64")); await fs.writeFile( staleRecordPath, JSON.stringify( { attachmentId: staleAttachmentId, sessionKey: "agent:main:main", messageId: null, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), retentionClass: "transient", alt: "Stale cat", original: { path: staleOriginalPath, contentType: "image/png", width: 1, height: 1, sizeBytes: Buffer.from(TINY_PNG_BASE64, "base64").byteLength, filename: "stale-cat.png", }, }, null, 2, ), "utf-8", ); await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:image/png;base64,${TINY_PNG_BASE64}`], stateDir, }); await expect(fs.access(staleRecordPath)).resolves.toBeUndefined(); await expect(fs.access(staleOriginalPath)).resolves.toBeUndefined(); }); }); describe("attachManagedOutgoingImagesToMessage", () => { let stateDir: string; beforeEach(async () => { stateDir = tempDirs.make("managed-image-attach-"); vi.clearAllMocks(); await prepareManagedSessionStore(stateDir); }); afterEach(async () => { closeOpenClawStateDatabaseForTest(); await fs.rm(stateDir, { recursive: true, force: true }); }); it("upgrades transient image records to history when the message is committed", async () => { const blocks = await createManagedOutgoingImageBlocks({ sessionKey: "agent:main:main", mediaUrls: [`data:image/png;base64,${TINY_PNG_BASE64}`], stateDir, }); await attachManagedOutgoingImagesToMessage({ messageId: "msg-committed", blocks: blocks as Record[], stateDir, }); const attachmentId = requireAttachmentIdFromUrl(blocks[0]?.url); const record = readManagedImageRecord(attachmentId, stateDir); expect(record?.messageId).toBe("msg-committed"); expect(record?.retentionClass).toBe("history"); expect(typeof record?.updatedAt).toBe("string"); }); }); describe("cleanupManagedOutgoingImageRecords", () => { let stateDir: string; beforeEach(async () => { stateDir = tempDirs.make("managed-image-cleanup-"); vi.clearAllMocks(); await prepareManagedSessionStore(stateDir); }); afterEach(async () => { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); await fs.rm(stateDir, { recursive: true, force: true }); }); it("cleans up dereferenced records and original files", async () => { const fixture = await createFixture(stateDir); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(result.deletedRecordCount).toBe(1); expect(result.deletedFileCount).toBe(1); expect(result.retainedCount).toBe(0); await expectPathMissing(fixture.originalPath); }); it("retains an aged transient record while its session still has an active run", async () => { const fixture = await createFixture(stateDir, { messageId: null, createdAt: new Date(Date.now() - 16 * 60 * 1000).toISOString(), }); const checkedSessionKeys: string[] = []; const result = await cleanupManagedOutgoingImageRecords({ stateDir, hasActiveSessionRun: (sessionKey) => { checkedSessionKeys.push(sessionKey); return sessionKey === fixture.sessionKey; }, }); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(checkedSessionKeys).toEqual([fixture.sessionKey]); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect( attachManagedImageRecordToMessage({ attachmentId: fixture.attachmentId, sessionKey: fixture.sessionKey, messageId: "msg-late", updatedAt: new Date().toISOString(), stateDir, }), ).toBe(true); const attached = readManagedImageRecord(fixture.attachmentId, stateDir); expect(attached?.messageId).toBe("msg-late"); expect(attached?.retentionClass).toBe("history"); }); it("reaps an aged transient record once its session has no active run", async () => { const fixture = await createFixture(stateDir, { messageId: null, createdAt: new Date(Date.now() - 16 * 60 * 1000).toISOString(), }); const result = await cleanupManagedOutgoingImageRecords({ stateDir, hasActiveSessionRun: () => false, }); expect(result).toEqual({ deletedRecordCount: 1, deletedFileCount: 1, retainedCount: 0 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).toBeNull(); await expectPathMissing(fixture.originalPath); }); it("retries a durably claimed file deletion after a filesystem failure", async () => { const fixture = await createFixture(stateDir); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("synthetic rm failure")); let failed: Awaited>; try { failed = await cleanupManagedOutgoingImageRecords({ stateDir }); } finally { rmSpy.mockRestore(); } expect(failed).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); const retried = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(retried).toEqual({ deletedRecordCount: 1, deletedFileCount: 1, retainedCount: 0 }); await expectPathMissing(fixture.originalPath); }); it("reaps aged files left before a SQLite record was committed", async () => { const orphanPath = path.join( stateDir, "media", MANAGED_OUTGOING_ORIGINALS_SUBDIR, "orphan.png", ); await fs.mkdir(path.dirname(orphanPath), { recursive: true }); await fs.writeFile(orphanPath, "orphan"); await fs.utimes(orphanPath, new Date(0), new Date(0)); const result = await cleanupManagedOutgoingImageRecords({ stateDir, nowMs: 1_000_000, transientMaxAgeMs: 1_000, }); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 1, retainedCount: 0 }); await expectPathMissing(orphanPath); }); it("does not reap old unindexed files while legacy metadata still exists", async () => { const orphanPath = path.join( stateDir, "media", MANAGED_OUTGOING_ORIGINALS_SUBDIR, "legacy-owned.png", ); const legacyRecordPath = path.join( stateDir, "media", "outgoing", "records", "11111111-1111-4111-8111-111111111111.json", ); await fs.mkdir(path.dirname(orphanPath), { recursive: true }); await fs.mkdir(path.dirname(legacyRecordPath), { recursive: true }); await fs.writeFile(orphanPath, "legacy"); await fs.writeFile(legacyRecordPath, "{}"); await fs.utimes(orphanPath, new Date(0), new Date(0)); const result = await cleanupManagedOutgoingImageRecords({ stateDir, nowMs: 1_000_000, transientMaxAgeMs: 1_000, }); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 0 }); await expect(fs.access(orphanPath)).resolves.toBeUndefined(); }); it("fails closed when the legacy metadata directory cannot be inspected", async () => { const orphanPath = path.join( stateDir, "media", MANAGED_OUTGOING_ORIGINALS_SUBDIR, "unknown-owner.png", ); await fs.mkdir(path.dirname(orphanPath), { recursive: true }); await fs.writeFile(orphanPath, "unknown"); await fs.utimes(orphanPath, new Date(0), new Date(0)); const readdirSpy = vi .spyOn(fs, "readdir") .mockRejectedValueOnce(Object.assign(new Error("denied"), { code: "EACCES" })); let result!: Awaited>; try { result = await cleanupManagedOutgoingImageRecords({ stateDir, nowMs: 1_000_000 }); } finally { readdirSpy.mockRestore(); } expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 0 }); await expect(fs.access(orphanPath)).resolves.toBeUndefined(); }); it("retains history records when the session table is unavailable", async () => { const fixture = await createFixture(stateDir); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const databasePath = openOpenClawAgentDatabase({ agentId: "main", env }).path; closeOpenClawAgentDatabasesForTest(); const { DatabaseSync } = requireNodeSqlite(); const database = new DatabaseSync(databasePath); database.exec("DROP TABLE session_nodes;"); database.close(); getRuntimeConfigMock.mockReturnValue({ session: { store: databasePath } }); loadSessionEntryMock.mockReturnValue({ storePath: databasePath, entry: undefined }); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains history records when the session row is unreadable", async () => { const fixture = await createFixture(stateDir); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const opened = openOpenClawAgentDatabase({ agentId: "main", env }); opened.db .prepare( "INSERT INTO session_nodes (session_key, current_session_id, entry_json, entry_valid, updated_at) VALUES (?, ?, ?, -1, ?)", ) .run("agent:main:main", "broken-session", "{invalid", Date.now()); const databasePath = opened.path; closeOpenClawAgentDatabasesForTest(); getRuntimeConfigMock.mockReturnValue({ session: { store: databasePath } }); loadSessionEntryMock.mockReturnValue({ storePath: databasePath, entry: undefined }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("does not let a valid fallback mask an unreadable exact row", async () => { const fixture = await createFixture(stateDir); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const opened = openOpenClawAgentDatabase({ agentId: "main", env }); opened.db .prepare( "INSERT INTO session_nodes (session_key, current_session_id, entry_json, entry_valid, updated_at) VALUES (?, ?, ?, -1, ?)", ) .run("agent:main:main", "broken-session", "{invalid", Date.now()); const databasePath = opened.path; closeOpenClawAgentDatabasesForTest(); getRuntimeConfigMock.mockReturnValue({ session: { store: databasePath } }); loadSessionEntryMock.mockReturnValue({ storePath: databasePath, entry: { sessionId: "fallback-session", sessionFile: "/tmp/fallback.jsonl" }, }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains history records when a per-agent session database is missing", async () => { const fixture = await createFixture(stateDir); const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); getRuntimeConfigMock.mockReturnValue({ session: { store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json") }, }); loadSessionEntryMock.mockReturnValue({ storePath, entry: undefined }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains history when a healthy configured store masks an unreadable candidate", async () => { const fixture = await createFixture(stateDir); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const storeTemplate = path.join(stateDir, "custom", "{agentId}", "sessions.json"); const configuredStorePath = storeTemplate.replace("{agentId}", "main"); const configuredTarget = resolveSqliteTargetFromSessionStorePath(configuredStorePath, { agentId: "main", env, }); openOpenClawAgentDatabase({ agentId: "main", env, path: configuredTarget.path }); closeOpenClawAgentDatabasesForTest(); const discoveredDatabasePath = openOpenClawAgentDatabase({ agentId: "main", env }).path; closeOpenClawAgentDatabasesForTest(); const { DatabaseSync } = requireNodeSqlite(); const database = new DatabaseSync(discoveredDatabasePath); database.exec("DROP TABLE session_nodes;"); database.close(); getRuntimeConfigMock.mockReturnValue({ session: { store: storeTemplate } }); loadSessionEntryMock.mockReturnValue({ storePath: configuredStorePath, entry: undefined }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains history when a healthy discovered store masks a missing configured store", async () => { const fixture = await createFixture(stateDir); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const storeTemplate = path.join(stateDir, "missing-custom", "{agentId}", "sessions.json"); const discovered = openOpenClawAgentDatabase({ agentId: "main", env }); discovered.db .prepare( "INSERT INTO session_nodes (session_key, current_session_id, entry_json, entry_valid, updated_at) VALUES (?, ?, ?, 1, ?)", ) .run( "agent:main:main", "discovered-session", JSON.stringify({ sessionId: "discovered-session" }), Date.now(), ); closeOpenClawAgentDatabasesForTest(); getRuntimeConfigMock.mockReturnValue({ session: { store: storeTemplate } }); loadSessionEntryMock.mockReturnValue({ storePath: storeTemplate.replace("{agentId}", "main"), entry: undefined, }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains history records when a fixed store database is missing", async () => { const fixture = await createFixture(stateDir); const storePath = path.join(stateDir, "unmounted-sessions.json"); getRuntimeConfigMock.mockReturnValue({ session: { store: storePath } }); loadSessionEntryMock.mockReturnValue({ storePath, entry: undefined }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("does not assign the configured fixed store to a retired agent", async () => { const fixture = await createFixture(stateDir, { agentId: "retired", sessionKey: "agent:retired:main", }); const storePath = path.join(stateDir, "current-sessions.json"); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const target = resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "main", env }); const opened = openOpenClawAgentDatabase({ agentId: "main", env, path: target.path }); opened.db .prepare( "INSERT INTO session_nodes (session_key, current_session_id, entry_json, entry_valid, updated_at) VALUES (?, ?, ?, 1, ?)", ) .run("main", "current-session", JSON.stringify({ sessionId: "current-session" }), Date.now()); closeOpenClawAgentDatabasesForTest(); getRuntimeConfigMock.mockReturnValue({ session: { store: storePath } }); loadSessionEntryMock.mockReturnValue({ storePath, entry: undefined }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("cleans retired fixed-store history after explicit ownership is readable", async () => { const fixture = await createFixture(stateDir, { agentId: "retired", sessionKey: "agent:retired:main", }); const storePath = path.join(stateDir, "retired-readable-sessions.json"); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; await replaceTestSessionEntry( { agentId: "retired", env, storePath, sessionKey: "agent:retired:main" }, { sessionId: "retired-session", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); const config = { session: { store: storePath } }; getRuntimeConfigMock.mockReturnValue(config); expect( resolveExistingAgentSessionStoreTargetsReadOnlyResult(config, "retired", { env }), ).toEqual({ available: true, targets: [{ agentId: "retired", storePath }] }); const { loadExactSessionEntryReadOnlyResult } = await import("../config/sessions/session-accessor.sqlite-entry-availability.js"); expect( loadExactSessionEntryReadOnlyResult({ agentId: "retired", sessionKey: "agent:retired:main", storePath, }), ).toMatchObject({ found: true, value: { sessionKey: "agent:retired:main" } }); loadSessionEntryMock.mockReturnValue({ storePath, entry: { sessionId: "retired-session", sessionFile: "/tmp/retired.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 1, deletedFileCount: 1, retainedCount: 0 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).toBeNull(); await expectPathMissing(fixture.originalPath); }); it("retains history records when a retired fixed store is unavailable", async () => { const fixture = await createFixture(stateDir, { agentId: "retired", sessionKey: "agent:retired:main", }); const storePath = path.join(stateDir, "retired-sessions.json"); const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; const target = resolveSqliteTargetFromSessionStorePath(storePath, { agentId: "retired", env, }); openOpenClawAgentDatabase({ agentId: "retired", env, path: target.path }); closeOpenClawAgentDatabasesForTest(); const { DatabaseSync } = requireNodeSqlite(); const database = new DatabaseSync(target.path); database.exec("DROP TABLE schema_meta;"); database.close(); const config = { session: { store: storePath } }; getRuntimeConfigMock.mockReturnValue(config); loadSessionEntryMock.mockReturnValue({ storePath, entry: undefined }); expect( resolveExistingAgentSessionStoreTargetsReadOnlyResult(config, "retired", { env }), ).toEqual({ available: false, reason: "schema-missing" }); const result = await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => cleanupManagedOutgoingImageRecords({ stateDir }), ); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); expect(readManagedImageRecord(fixture.attachmentId, stateDir)).not.toBeNull(); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(readSessionMessagesMock).not.toHaveBeenCalled(); }); it("retains committed records that are still referenced by a full-image block", async () => { const fixture = await createFixture(stateDir); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" }, }); readSessionMessagesMock.mockReturnValue([ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(fixture.sessionKey)}/${fixture.attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(fixture.sessionKey)}/${fixture.attachmentId}/full`, }, ], }, ]); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(result.deletedRecordCount).toBe(0); expect(result.deletedFileCount).toBe(0); expect(result.retainedCount).toBe(1); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); expect(readSessionMessagesMock).toHaveBeenCalledTimes(1); }); it("reads each session transcript once while evaluating committed records", async () => { const firstFixture = await createFixture(stateDir, { attachmentId: "11111111-1111-4111-8111-111111111111", filename: "att-1.png", }); const secondFixture = await createFixture(stateDir, { attachmentId: "22222222-2222-4222-8222-222222222222", filename: "att-2.png", }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" }, }); readSessionMessagesMock.mockReturnValue([ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(firstFixture.sessionKey)}/${firstFixture.attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(firstFixture.sessionKey)}/${firstFixture.attachmentId}/full`, }, { type: "image", url: `/api/chat/media/outgoing/${encodeURIComponent(secondFixture.sessionKey)}/${secondFixture.attachmentId}/full`, openUrl: `/api/chat/media/outgoing/${encodeURIComponent(secondFixture.sessionKey)}/${secondFixture.attachmentId}/full`, }, ], }, ]); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(result.deletedRecordCount).toBe(0); expect(result.deletedFileCount).toBe(0); expect(result.retainedCount).toBe(2); expect(readSessionMessagesMock).toHaveBeenCalledTimes(1); }); it("does not delete files still referenced by other sessions during session-scoped cleanup", async () => { const retainedFixture = await createFixture(stateDir, { sessionKey: "agent:other:session", attachmentId: "33333333-3333-4333-8333-333333333333", }); const deletedFixture = await createFixture(stateDir, { sessionKey: "agent:main:main", attachmentId: "44444444-4444-4444-8444-444444444444", }); loadSessionEntryMock.mockImplementation((sessionKey: string) => ({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: sessionKey === retainedFixture.sessionKey ? "sess-other" : "sess-main", sessionFile: "/tmp/session.jsonl", }, })); readSessionMessagesMock.mockReturnValue([]); const result = await cleanupManagedOutgoingImageRecords({ stateDir, sessionKey: deletedFixture.sessionKey, forceDeleteSessionRecords: true, }); expect(result.deletedRecordCount).toBe(1); expect(result.retainedCount).toBe(1); await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); }); it("retains other selected-agent global records during scoped cleanup", async () => { await replaceTestSessionEntry( { agentId: "main", env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, sessionKey: "global", storePath: path.join(stateDir, "sessions.sqlite"), }, { sessionId: "sess-main-global", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); const retainedFixture = await createFixture(stateDir, { sessionKey: "global", agentId: "work", attachmentId: "55555555-5555-4555-8555-555555555555", }); const deletedFixture = await createFixture(stateDir, { sessionKey: "global", agentId: "main", attachmentId: "66666666-6666-4666-8666-666666666666", }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-main-global", sessionFile: "/tmp/global-main.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const result = await cleanupManagedOutgoingImageRecords({ stateDir, sessionKey: "global", agentId: "main", }); expect(result.deletedRecordCount).toBe(1); expect(result.retainedCount).toBe(1); await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); await expectPathMissing(deletedFixture.originalPath); }); it("uses the recorded owner for unscoped session keys", async () => { const fixture = await createFixture(stateDir, { agentId: "work", sessionKey: "legacy-session", }); getRuntimeConfigMock.mockReturnValue({ session: { store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json") }, }); prepareAgentSessionStore(stateDir, "work"); await replaceTestSessionEntry( { agentId: "work", env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, sessionKey: "legacy-session", }, { sessionId: "sess-work", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "agents", "work", "sessions", "sessions.json"), entry: { sessionId: "sess-work", sessionFile: "/tmp/work.jsonl" }, }); readSessionMessagesMock.mockReturnValue([ { __openclaw: { id: "msg-1" }, content: [ { type: "image", url: `/api/chat/media/outgoing/${fixture.sessionKey}/${fixture.attachmentId}/full`, }, ], }, ]); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(readSessionMessagesMock).toHaveBeenCalled(); expect(result).toEqual({ deletedRecordCount: 0, deletedFileCount: 0, retainedCount: 1 }); await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined(); }); it("treats legacy unscoped global records as the configured default agent", async () => { const config = { agents: { list: [{ id: "main" }, { id: "work", default: true }] }, }; getRuntimeConfigMock.mockReturnValue(config); prepareAgentSessionStore(stateDir, "work"); await replaceTestSessionEntry( { agentId: "work", env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, sessionKey: "global", }, { sessionId: "sess-work-global", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); expect( resolveExistingAgentSessionStoreTargetsReadOnlyResult(config, "work", { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, }), ).toMatchObject({ available: true }); const { loadExactSessionEntryReadOnlyResult } = await import("../config/sessions/session-accessor.sqlite-entry-availability.js"); expect( loadExactSessionEntryReadOnlyResult({ agentId: "work", env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, sessionKey: "global", }), ).toMatchObject({ found: true, value: { sessionKey: "global" } }); const deletedFixture = await createFixture(stateDir, { sessionKey: "global", attachmentId: "88888888-8888-4888-8888-888888888888", }); const retainedFixture = await createFixture(stateDir, { sessionKey: "global", agentId: "main", attachmentId: "99999999-9999-4999-8999-999999999999", }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-work-global", sessionFile: "/tmp/global-work.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const result = await cleanupManagedOutgoingImageRecords({ stateDir, sessionKey: "global", agentId: "work", }); expect(readSessionMessagesMock).toHaveBeenCalled(); expect(result.deletedRecordCount).toBe(1); expect(result.retainedCount).toBe(1); await expectPathMissing(deletedFixture.originalPath); await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); }); it("does not retain selected-agent global records during full cleanup", async () => { await replaceTestSessionEntry( { agentId: "work", env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, sessionKey: "global", storePath: path.join(stateDir, "sessions.sqlite"), }, { sessionId: "sess-work-global", updatedAt: Date.now() }, ); closeOpenClawAgentDatabasesForTest(); const fixture = await createFixture(stateDir, { sessionKey: "global", agentId: "work", attachmentId: "77777777-7777-4777-8777-777777777777", }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(stateDir, "gateway-sessions.json"), entry: { sessionId: "sess-work-global", sessionFile: "/tmp/global-work.jsonl" }, }); readSessionMessagesMock.mockReturnValue([]); const result = await cleanupManagedOutgoingImageRecords({ stateDir }); expect(result.deletedRecordCount).toBe(1); expect(result.retainedCount).toBe(0); await expectPathMissing(fixture.originalPath); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */