mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
f6fff4f7fd
* refactor: use canonical re-export names * fix(test): classify suite support as test source * fix(agents): retarget gateway stub session-entry import * test(gateway): retarget session-utils mock keys after alias removal
2662 lines
100 KiB
TypeScript
2662 lines
100 KiB
TypeScript
// 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<PlaybackModeForSourceResolver>();
|
|
const resolvePlaybackTranscodeMock = vi.fn(
|
|
async (): Promise<PlaybackTranscodeResolution> => ({ 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<typeof import("../media/playback-transcode.js")>();
|
|
const testApi = (globalThis as Record<PropertyKey, unknown>)[
|
|
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<void> {
|
|
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<string> {
|
|
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<Buffer> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown>;
|
|
headers?: http.ClientRequestArgs["headers"];
|
|
transcriptMessages?: Record<string, unknown>[];
|
|
sessionEntry?: { sessionId: string; sessionFile?: string };
|
|
resolvedTranscriptPath?: string | null;
|
|
onReadTranscriptMessages?: () => Promise<void> | 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<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address() as AddressInfo;
|
|
|
|
try {
|
|
const result = await new Promise<RequestResult>((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<void>((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<void>> | 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<void>((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<void>((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<void>((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<void>((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<string, unknown>[],
|
|
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<ReturnType<typeof cleanupManagedOutgoingImageRecords>>;
|
|
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<ReturnType<typeof cleanupManagedOutgoingImageRecords>>;
|
|
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. */
|