fix(gateway): unify media privacy in chat history (#121490)

* fix(gateway): unify media privacy in chat history

Centralize image, audio, video, and persisted media-fact privacy at the shared Gateway history projection. Remove duplicate sessions_history redaction, validate managed media claims canonically, and keep safe media-only user turns renderable.

* test(gateway): type history RPC integration

* test(agents): align history fixture with gateway projection
This commit is contained in:
Peter Steinberger
2026-08-10 03:30:44 -07:00
committed by GitHub
parent a4eabd5744
commit 0a6a1d9419
12 changed files with 481 additions and 187 deletions
+3 -21
View File
@@ -737,7 +737,7 @@ describe("sessions tools", () => {
);
});
it("sessions_history caps oversized payloads and strips heavy fields", async () => {
it("sessions_history caps oversized payloads and strips tool-owned heavy fields", async () => {
const oversized = Array.from({ length: 80 }, (_, idx) => ({
role: "assistant",
content: [
@@ -748,14 +748,6 @@ describe("sessions tools", () => {
{
type: "thinking",
thinking: "y".repeat(7000),
thinkingSignature: "sig".repeat(4000),
openclawReasoningReplay: {
v: 1,
source: "openai-responses",
provider: "openai",
api: "openai-chatgpt-responses",
model: "gpt-5.5",
},
},
],
details: {
@@ -765,10 +757,6 @@ describe("sessions tools", () => {
input: 1,
output: 1,
},
providerReplay: {
type: "openai-responses-compaction",
data: "opaque-sessions-history-compaction",
},
}));
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
@@ -795,7 +783,7 @@ describe("sessions tools", () => {
expect(details.truncated).toBe(true);
expect(details.droppedMessages).toBe(true);
expect(details.contentTruncated).toBe(true);
expect(details.contentRedacted).toBe(true);
expect(details.contentRedacted).toBe(false);
expect(typeof details.bytes).toBe("number");
expect((details.bytes ?? 0) <= 80 * 1024).toBe(true);
expect(details.messages && details.messages.length > 0).toBe(true);
@@ -804,26 +792,20 @@ describe("sessions tools", () => {
| {
details?: unknown;
usage?: unknown;
providerReplay?: unknown;
content?: Array<{
type?: string;
text?: string;
thinking?: string;
thinkingSignature?: string;
openclawReasoningReplay?: unknown;
}>;
}
| undefined;
expect(first?.details).toBeUndefined();
expect(first?.usage).toBeUndefined();
expect(first?.providerReplay).toBeUndefined();
expect(JSON.stringify(details.messages)).not.toContain("opaque-sessions-history-compaction");
const textBlock = first?.content?.find((block) => block.type === "text");
expect(typeof textBlock?.text).toBe("string");
expect((textBlock?.text ?? "").length <= 4015).toBe(true);
const thinkingBlock = first?.content?.find((block) => block.type === "thinking");
expect(thinkingBlock?.thinkingSignature).toBeUndefined();
expect(thinkingBlock?.openclawReasoningReplay).toBeUndefined();
expect((thinkingBlock?.thinking ?? "").length <= 4015).toBe(true);
});
it("sessions_history enforces a hard byte cap even when a single message is huge", async () => {
@@ -175,42 +175,6 @@ describe("sessions_history redaction", () => {
expect((result.details as { contentRedacted?: unknown }).contentRedacted).toBe(true);
});
it.each([
{
name: "reports decoded bytes for raw image data",
content: [
{
type: "image",
data: Buffer.from([0, 1, 2, 3, 4]).toString("base64"),
mimeType: "image/png",
},
],
expectedBytes: 5,
},
{
name: "replaces stale bytes for empty image data",
content: [{ type: "image", data: "", mimeType: "image/png", bytes: 999 }],
expectedBytes: 0,
},
{
name: "preserves existing bytes when image data is already omitted",
content: [{ type: "image", mimeType: "image/png", bytes: 37, omitted: true }],
expectedBytes: 37,
},
])("$name", async ({ content, expectedBytes }) => {
const tool = createHistoryToolWithMessage(content);
const result = await tool.execute("call-1", { sessionKey: "main" });
const details = readHistoryDetails(result);
expect(details.messages).toEqual([
{
role: "user",
content: [{ type: "image", mimeType: "image/png", bytes: expectedBytes, omitted: true }],
},
]);
});
it.each([0, 1.5])("rejects invalid limit value %s", async (limit) => {
const tool = createHistoryToolWithMessage("hello");
+5 -36
View File
@@ -3,8 +3,6 @@
*
* Reads bounded, redacted session transcript history after session visibility filtering.
*/
import { estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -123,29 +121,17 @@ function sanitizeHistoryContentBlock(block: unknown): {
const entry = { ...(block as Record<string, unknown>) };
let truncated = false;
let redacted = false;
const type = typeof entry.type === "string" ? entry.type : "";
if (typeof entry.text === "string") {
const res = truncateHistoryText(entry.text);
entry.text = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
if (type === "thinking") {
if (typeof entry.thinking === "string") {
const res = truncateHistoryText(entry.thinking);
entry.thinking = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
// The encrypted signature can be extremely large and is not useful for history recall.
if ("thinkingSignature" in entry) {
delete entry.thinkingSignature;
truncated = true;
}
if ("openclawReasoningReplay" in entry) {
delete entry.openclawReasoningReplay;
truncated = true;
}
if (entry.type === "thinking" && typeof entry.thinking === "string") {
const res = truncateHistoryText(entry.thinking);
entry.thinking = res.text;
truncated ||= res.truncated;
redacted ||= res.redacted;
}
if (typeof entry.partialJson === "string") {
const res = truncateHistoryText(entry.partialJson);
@@ -153,19 +139,6 @@ function sanitizeHistoryContentBlock(block: unknown): {
truncated ||= res.truncated;
redacted ||= res.redacted;
}
if (type === "image") {
const data = readStringValue(entry.data);
const existingBytes = typeof entry.bytes === "number" ? entry.bytes : undefined;
const bytes = data === undefined ? existingBytes : estimateBase64DecodedBytes(data);
if ("data" in entry) {
delete entry.data;
truncated = true;
}
entry.omitted = true;
if (bytes !== undefined) {
entry.bytes = bytes;
}
}
return { block: entry, truncated, redacted };
}
@@ -180,10 +153,6 @@ function sanitizeHistoryMessage(message: unknown): {
const entry = { ...(message as Record<string, unknown>) };
let truncated = false;
let redacted = false;
if ("providerReplay" in entry) {
delete entry.providerReplay;
redacted = true;
}
// Tool result details often contain very large nested payloads.
if ("details" in entry) {
delete entry.details;
+103 -70
View File
@@ -1,6 +1,7 @@
import { estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { parseInboundMediaUri } from "../media/media-reference.js";
import {
parseAssistantTextSignature,
resolveAssistantMessagePhase,
@@ -30,54 +31,109 @@ import {
WORKSPACE_CONFLICT_TRANSCRIPT_TYPE,
} from "./worker-environments/workspace-conflicts.js";
const AUDIO_LOCAL_PATH_FIELDS = ["path", "file", "filePath", "localPath"] as const;
const MEDIA_PRIVATE_FIELDS = ["data", "blob", "path", "file", "filePath", "localPath"] as const;
const MEDIA_REFERENCE_FIELDS = ["url", "openUrl", "image_url", "audio_url", "video_url"] as const;
const MEDIA_FACT_PRIVATE_FIELDS = [
"workspaceDir",
...MEDIA_PRIVATE_FIELDS.filter((field) => field !== "path"),
] as const;
function isInlineOrLocalAudioReference(value: unknown): boolean {
function projectChatHistoryMediaReference(value: unknown): string | undefined {
if (typeof value !== "string") {
return false;
return undefined;
}
const reference = value.trim();
const isManagedRoute = /^\/(?:api\/chat\/media\/outgoing|media|__openclaw__)\//u.test(reference);
return (
/^data:audio\//iu.test(reference) ||
/^file:/iu.test(reference) ||
/^~[\\/]/u.test(reference) ||
(!isManagedRoute &&
(reference.startsWith("/") ||
/^[A-Za-z]:[\\/]/u.test(reference) ||
reference.startsWith("\\\\")))
);
if (/^\/(?:api\/chat\/media\/outgoing|media|__openclaw__)\//u.test(reference)) {
return reference.split(/[?#]/u, 1)[0];
}
try {
if (/^media:/iu.test(reference)) {
return parseInboundMediaUri(reference)?.normalizedSource;
}
const url = new URL(reference);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return undefined;
}
url.username = url.password = url.search = url.hash = "";
return url.toString();
} catch {
return undefined;
}
}
function omitAudioHistoryContent(
entry: Record<string, unknown>,
referenceFields: readonly string[],
): boolean {
let removed = false;
if (Object.hasOwn(entry, "data")) {
const data = entry.data;
delete entry.data;
if (typeof data === "string") {
entry.bytes = estimateBase64DecodedBytes(data);
}
removed = true;
function projectChatHistoryMediaBlock(entry: Record<string, unknown>, fact = false): boolean {
if (!fact && (typeof entry.type !== "string" || !/^(?:image|audio|video)$/u.test(entry.type))) {
return false;
}
for (const field of AUDIO_LOCAL_PATH_FIELDS) {
if (Object.hasOwn(entry, field)) {
delete entry[field];
removed = true;
const media = entry as typeof entry & { type: "image" | "audio" | "video" };
const hasTopLevelPayload = typeof media.data === "string" || typeof media.blob === "string";
const source = fact ? undefined : readRecord(media.source);
const projectedSource = source ? { ...source } : undefined;
const records: Record<string, unknown>[] = [media, ...(projectedSource ? [projectedSource] : [])];
if (projectedSource) {
media.source = projectedSource;
}
const privateFields = fact ? MEDIA_FACT_PRIVATE_FIELDS : MEDIA_PRIVATE_FIELDS;
const referenceFields = fact ? (["path", "url"] as const) : MEDIA_REFERENCE_FIELDS;
const sourceIsReference =
!source &&
(!fact ||
typeof media.source !== "string" ||
/^(?:[a-z][a-z0-9+.-]*:|~?[\\/])|[\\/]/iu.test(media.source));
let encodedPayload: string | undefined;
for (const record of records) {
let omitted = false;
const payload = typeof record.data === "string" ? record.data : record.blob;
if (encodedPayload === undefined && typeof payload === "string") {
encodedPayload = payload;
}
for (const field of privateFields) {
if (!Object.hasOwn(record, field)) {
continue;
}
delete record[field];
omitted = true;
}
const recordReferences =
record === media && sourceIsReference ? [...referenceFields, "source"] : referenceFields;
for (const field of recordReferences) {
if (!Object.hasOwn(record, field)) {
continue;
}
const projected = projectChatHistoryMediaReference(record[field]);
record[field] = projected;
if (projected === undefined) {
delete record[field];
omitted = true;
}
}
if (!fact && omitted) {
// Preserve shipped image/audio omission ownership; new video blocks mark both levels.
if (record === media || media.type !== "image") {
record.omitted = true;
}
if (record === media || media.type !== "audio") {
media.omitted = true;
}
}
}
for (const field of referenceFields) {
if (isInlineOrLocalAudioReference(entry[field])) {
delete entry[field];
removed = true;
}
if (!fact && encodedPayload !== undefined) {
(media.type === "audio" && !hasTopLevelPayload && projectedSource
? projectedSource
: media
).bytes = estimateBase64DecodedBytes(encodedPayload);
}
if (removed) {
entry.omitted = true;
}
return removed;
return true;
}
function projectChatHistoryMediaFacts(value: unknown): unknown[] | undefined {
return Array.isArray(value)
? value.map((fact) => {
const projected = { ...readRecord(fact) };
projectChatHistoryMediaBlock(projected, true);
return projected;
})
: undefined;
}
export function sanitizeChatHistoryContentBlock(
@@ -146,37 +202,8 @@ export function sanitizeChatHistoryContentBlock(
delete entry.openclawReasoningReplay;
changed = true;
}
const type = typeof entry.type === "string" ? entry.type : "";
if (type === "image") {
let imageData = typeof entry.data === "string" ? entry.data : undefined;
const source = readRecord(entry.source);
if (source?.type === "base64" && typeof source.data === "string") {
imageData ??= source.data;
const projectedSource = { ...source };
delete projectedSource.data;
entry.source = projectedSource;
}
if (imageData !== undefined) {
delete entry.data;
entry.omitted = true;
entry.bytes = estimateBase64DecodedBytes(imageData);
changed = true;
}
}
if (type === "audio") {
// Audio transcripts can retain model-input bytes and host-local references.
// Strip them at the shared display boundary while preserving safe metadata.
const blockChanged = omitAudioHistoryContent(entry, ["url", "openUrl", "audio_url"]);
changed ||= blockChanged;
const source = readRecord(entry.source);
if (source) {
const projectedSource = { ...source };
if (omitAudioHistoryContent(projectedSource, ["url"])) {
entry.source = projectedSource;
changed = true;
}
}
}
const mediaChanged = projectChatHistoryMediaBlock(entry);
changed ||= mediaChanged;
return { block: changed ? entry : block, changed };
}
@@ -360,11 +387,17 @@ export function sanitizeChatHistoryMessage(
changed = true;
}
const openClawMeta = readRecord(entry["__openclaw"]);
if (openClawMeta && "upstreamUserText" in openClawMeta) {
if (openClawMeta && ("upstreamUserText" in openClawMeta || "media" in openClawMeta)) {
// Codex retains the decorated upstream prompt for transcript reconstruction.
// It is not display data and can otherwise evict the visible row from history.
const projectedMeta = { ...openClawMeta };
delete projectedMeta.upstreamUserText;
if ("media" in projectedMeta) {
projectedMeta.media = projectChatHistoryMediaFacts(projectedMeta.media);
if (projectedMeta.media === undefined) {
delete projectedMeta.media;
}
}
if (Object.keys(projectedMeta).length > 0) {
entry["__openclaw"] = projectedMeta;
} else {
+142
View File
@@ -21,6 +21,148 @@ function projectHistoryTransports(message: Record<string, unknown>) {
}
describe("oversized multimodal chat history", () => {
it("projects one mixed-media message through every history boundary", async () => {
const inlineImage = Buffer.from("inline image").toString("base64");
const inlineAudio = Buffer.from("inline audio").toString("base64");
const inlineVideo = Buffer.from("inline video").toString("base64");
const rawMessage = {
role: "user",
content: [
{ type: "text", text: "keep mixed media metadata" },
{
type: "image",
mimeType: "image/png",
data: inlineImage,
path: "/tmp/private-image.png",
url: "https://image-user@media.example/image.png?signature=image-secret#image-fragment",
source: {
type: "base64",
data: inlineImage,
blob: inlineImage,
url: "media://inbound/image-claim",
},
},
{
type: "audio",
mimeType: "audio/wav",
blob: inlineAudio,
filePath: String.raw`C:\private-audio.wav`,
audio_url: "media://inbound/audio-claim",
source: {
type: "url",
data: inlineAudio,
url: "https://audio-user@media.example/audio.wav?token=audio-secret#audio-fragment",
},
},
{
type: "video",
mimeType: "video/mp4",
data: inlineVideo,
localPath: String.raw`\\server\share\private-video.mp4`,
video_url:
"https://video-user@media.example/video.mp4?X-Amz-Signature=video-secret#video-fragment",
source: {
type: "url",
blob: inlineVideo,
url: "media://inbound/video-claim",
},
},
],
};
const expected = projectChatDisplayMessages([rawMessage]);
const snapshot = buildSessionHistorySnapshot({ rawMessages: [rawMessage] }).history.messages;
const sseState = SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "mixed-media", sessionKey: "agent:main:mixed-media" },
rawMessages: [],
});
const incremental = sseState.appendInlineMessage({ message: rawMessage })?.message;
const projections = [
["projectChatDisplayMessages", expected],
["session-history snapshot", snapshot],
["incremental SSE state", incremental ? [incremental] : []],
] as const;
for (const [boundary, messages] of projections) {
expect(messages, boundary).toHaveLength(1);
expect(messages[0], boundary).toMatchObject({
role: "user",
content: expected[0]?.content,
});
const serialized = JSON.stringify(messages);
for (const secret of [
inlineImage,
inlineAudio,
inlineVideo,
"private-image",
"private-audio",
"private-video",
"image-user",
"audio-user",
"video-user",
"image-secret",
"audio-secret",
"video-secret",
"image-fragment",
"audio-fragment",
"video-fragment",
]) {
expect(serialized, `${boundary}: ${secret}`).not.toContain(secret);
}
expect(serialized, boundary).toContain("media://inbound/image-claim");
expect(serialized, boundary).toContain("media://inbound/audio-claim");
expect(serialized, boundary).toContain("media://inbound/video-claim");
expect(serialized, boundary).toContain("https://media.example/image.png");
expect(serialized, boundary).toContain("https://media.example/audio.wav");
expect(serialized, boundary).toContain("https://media.example/video.mp4");
}
});
it("projects media even when another block field is sanitized first", () => {
const payload = Buffer.from("short-circuit video payload");
const encoded = payload.toString("base64");
const message = {
role: "user",
content: [
{
type: "video",
mimeType: "video/mp4",
data: encoded,
blob: encoded,
path: "/private/short-circuit-video.mp4",
url: "https://media-user@media.example/video.mp4?signature=private-signature#private-fragment",
openclawReasoningReplay: { private: true },
},
],
};
const messages = sanitizeChatHistoryMessages([message]);
expect(messages).toEqual([
{
role: "user",
content: [
{
type: "video",
mimeType: "video/mp4",
url: "https://media.example/video.mp4",
omitted: true,
bytes: payload.length,
},
],
},
]);
const serialized = JSON.stringify(messages);
for (const privateValue of [
encoded,
"/private/short-circuit-video.mp4",
"media-user",
"private-signature",
"private-fragment",
"openclawReasoningReplay",
]) {
expect(serialized).not.toContain(privateValue);
}
});
it.each([
{
name: "native image data",
@@ -1996,13 +1996,19 @@ describe("projectRecentChatDisplayMessages", () => {
it.each([
{
name: "facts-only",
message: { __openclaw: { media: [{ path: "/tmp/openclaw/fact.png" }] } },
expectedPath: "/tmp/openclaw/fact.png",
message: {
__openclaw: { media: [{ path: "/tmp/openclaw/fact.png", contentType: "image/png" }] },
},
expectedPath: undefined,
},
{
name: "sparse",
message: { __openclaw: { media: [{}, { path: "/tmp/openclaw/sparse.png" }] } },
expectedPath: "/tmp/openclaw/sparse.png",
message: {
__openclaw: {
media: [{}, { path: "/tmp/openclaw/sparse.png", contentType: "image/png" }],
},
},
expectedPath: undefined,
expectedIndex: 1,
},
{
@@ -2012,8 +2018,12 @@ describe("projectRecentChatDisplayMessages", () => {
},
{
name: "media-only",
message: { __openclaw: { media: [{ path: "/tmp/openclaw/media-only.png" }] } },
expectedPath: "/tmp/openclaw/media-only.png",
message: {
__openclaw: {
media: [{ path: "/tmp/openclaw/media-only.png", contentType: "image/png" }],
},
},
expectedPath: undefined,
},
])("keeps $name media-only users through canonical display projection", (testCase) => {
const result = projectRecentChatDisplayMessages([
@@ -7,6 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vite
import { createDeferred } from "../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
import { createSessionsHistoryTool } from "../agents/tools/sessions-history-tool.js";
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js";
@@ -35,6 +36,7 @@ import {
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
import { buildPersistedUserTurnMessage } from "../sessions/user-turn-transcript.js";
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
@@ -5065,6 +5067,171 @@ describe("gateway server chat", () => {
});
});
test("projects persisted media facts through Gateway history and sessions_history", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
const invalidClaims = [
"media://inbound/nested/file.png",
"media://inbound/nested%2Ffile.png",
"media://inbound/nested%5Cfile.png",
"media://inbound/file%00.png",
"media://inbound/",
"media://inbound/.",
"media://inbound/..",
["media://user", "password@inbound/claim.png"].join(":"),
"media://inbound/claim.png?signature=private-secret",
"media://inbound/claim.png#private-fragment",
];
const persisted = buildPersistedUserTurnMessage({
text: "inspect mixed attachments",
timestamp: Date.now(),
media: [
{
kind: "image",
path: "/private/media/local-image.png",
workspaceDir: "/private/workspace",
contentType: "image/png",
fileName: "local-image.png",
sizeBytes: 42,
width: 640,
height: 480,
messageId: "local-source-id",
},
{
kind: "audio",
url: "https://media-user@media.example/audio.wav?signature=private-signature#audio-fragment",
contentType: "audio/wav",
fileName: "remote-audio.wav",
durationMs: 1234,
},
{
kind: "video",
path: "media://inbound/video-claim",
contentType: "video/mp4",
fileName: "managed-video.mp4",
durationMs: 5678,
},
{
kind: "document",
url: "not a media reference",
contentType: "application/pdf",
fileName: "metadata-only.pdf",
},
...invalidClaims.map((claim, index) => ({
kind: "image" as const,
path: claim,
contentType: "image/png",
fileName: `invalid-claim-${index}.png`,
})),
],
}) as unknown as Record<string, unknown>;
const metadata = persisted["__openclaw"] as Record<string, unknown>;
const facts = metadata.media as Array<Record<string, unknown>>;
Object.assign(expectDefined(facts[0], "local media fact"), {
data: "private-inline-data",
blob: "private-inline-blob",
filePath: "/private/media/alternate-image.png",
source: "telegram-attachment-1",
});
metadata.upstreamUserText = "private upstream prompt";
metadata.keepMe = { durable: true };
await writeMainSessionTranscript([{ id: "persisted-media", message: persisted }]);
const historyMessages = await fetchHistoryMessages(ws);
const tool = createSessionsHistoryTool({
config: {},
callGateway: async <T = Record<string, unknown>>(request: {
method: string;
params?: unknown;
}) => {
const response = await rpcReq<T & Record<string, unknown>>(
ws,
request.method,
request.params,
);
expect(response.ok).toBe(true);
return expectDefined(response.payload, `${request.method} payload`);
},
});
const toolResult = await tool.execute("persisted-media", { sessionKey: "main" });
const sessionsHistory = (toolResult.details as { messages: unknown[] }).messages;
for (const [boundary, messages] of [
["chat.history", historyMessages],
["sessions_history", sessionsHistory],
] as const) {
expect(messages, boundary).toHaveLength(1);
expect(messages[0], boundary).toMatchObject({
role: "user",
content: "inspect mixed attachments",
__openclaw: {
keepMe: { durable: true },
media: [
{
kind: "image",
contentType: "image/png",
fileName: "local-image.png",
sizeBytes: 42,
width: 640,
height: 480,
messageId: "local-source-id",
source: "telegram-attachment-1",
},
{
kind: "audio",
url: "https://media.example/audio.wav",
contentType: "audio/wav",
fileName: "remote-audio.wav",
durationMs: 1234,
},
{
kind: "video",
path: "media://inbound/video-claim",
contentType: "video/mp4",
fileName: "managed-video.mp4",
durationMs: 5678,
},
{
kind: "document",
contentType: "application/pdf",
fileName: "metadata-only.pdf",
},
...invalidClaims.map((_, index) => ({
kind: "image",
contentType: "image/png",
fileName: `invalid-claim-${index}.png`,
})),
],
},
});
const projectedMedia = (
(messages[0] as { __openclaw?: { media?: Array<Record<string, unknown>> } })["__openclaw"]
?.media ?? []
).map((fact) => fact.path ?? fact.url ?? null);
expect(projectedMedia, boundary).toEqual([
null,
"https://media.example/audio.wav",
"media://inbound/video-claim",
...Array.from({ length: invalidClaims.length + 1 }, () => null),
]);
const serialized = JSON.stringify(messages);
for (const privateValue of [
"/private/media",
"/private/workspace",
"private-inline-data",
"private-inline-blob",
"media-user",
"private-signature",
"audio-fragment",
"private upstream prompt",
"not a media reference",
]) {
expect(serialized, `${boundary}: ${privateValue}`).not.toContain(privateValue);
}
}
});
});
test("chat.history keeps recent messages within the production byte budget", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
+7
View File
@@ -214,6 +214,13 @@ describe("media reference helpers", () => {
MediaReferenceError,
);
expect(() => parseInboundMediaUri("media://inbound/%00.png")).toThrow(MediaReferenceError);
for (const claim of [
["media://user", "password@inbound/claim.png"].join(":"),
"media://inbound/claim.png?signature=private-secret",
"media://inbound/claim.png#private-fragment",
]) {
expect(() => parseInboundMediaUri(claim), claim).toThrow(MediaReferenceError);
}
});
it("rejects symlinked inbound media files", async () => {
+5 -1
View File
@@ -136,6 +136,9 @@ export function parseInboundMediaUri(source: string): InboundMediaUri | null {
`Unsupported media URI location: ${parsed.hostname || "(missing)"}`,
);
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new MediaReferenceError("invalid-path", `Invalid media URI: ${normalizedSource}`);
}
let id: string;
try {
@@ -146,7 +149,8 @@ export function parseInboundMediaUri(source: string): InboundMediaUri | null {
});
}
if (!id || id.includes("/") || id.includes("\\") || id.includes("\0")) {
const invalidId = !id || id === "." || id === "..";
if (invalidId || id.includes("/") || id.includes("\\") || id.includes("\0")) {
throw new MediaReferenceError("invalid-path", `Invalid media URI: ${normalizedSource}`);
}
+2 -11
View File
@@ -1,10 +1,7 @@
// Control UI chat module implements message extract behavior.
import { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js";
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
import {
isMeaningfulMediaFact,
readPersistedMediaFacts,
} from "../../../../src/media/media-facts.js";
import { readPersistedMediaFacts } from "../../../../src/media/media-facts.js";
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
@@ -125,12 +122,6 @@ export function extractRawText(message: unknown): string | null {
return null;
}
function hasTranscriptMediaFacts(message: unknown): boolean {
return message != null && typeof message === "object"
? (readPersistedMediaFacts(message) ?? []).some(isMeaningfulMediaFact)
: false;
}
export function readTranscriptMediaEntries(message: unknown): Array<{
path: string;
mediaType: string | undefined;
@@ -196,7 +187,7 @@ export function isEmptyUserTextOnlyMessage(message: unknown): boolean {
if (normalizeLowercaseStringOrEmpty(entry.role) !== "user") {
return false;
}
if (hasTranscriptMediaFacts(entry)) {
if (readTranscriptMediaEntries(entry).length > 0) {
return false;
}
if (!isTextOnlyContent(entry.content ?? entry.text)) {
+27 -3
View File
@@ -3,6 +3,7 @@ import {
isEmptyUserTextOnlyMessage,
readTranscriptMediaEntries,
} from "../../lib/chat/message-extract.ts";
import { buildChatItems } from "./chat-thread-build.ts";
import { extractTranscriptAttachments } from "./components/chat-message-media.ts";
const MANAGED_UUID = "43007e90-2ade-43f2-a781-42b843e9eca3";
@@ -19,7 +20,6 @@ describe("chat history canonical media filtering", () => {
it.each([
["facts-only", [{ path: "/media/fact.png", contentType: "image/png" }]],
["sparse", [{}, { path: "/media/sparse.png", contentType: "image/png" }]],
["type-only", [{ contentType: "image/png" }]],
["media-only", [{ url: "media://inbound/media-only.png", kind: "image" }]],
])("keeps an empty %s user row", (_name, media) => {
expect(
@@ -31,8 +31,32 @@ describe("chat history canonical media filtering", () => {
).toBe(false);
});
it("drops a truly empty user row", () => {
expect(isEmptyUserTextOnlyMessage({ role: "user", content: "" })).toBe(true);
it.each([
["truly empty", { role: "user", content: "" }],
["metadata-only media", userMessageWithMedia([{ contentType: "image/png" }])],
])("drops a %s user row", (_name, message) => {
expect(isEmptyUserTextOnlyMessage(message)).toBe(true);
});
it("renders a safe media-only user turn without rendering metadata-only local media", () => {
const safeRef = "media://inbound/safe-history-image.png";
const items = buildChatItems({
paneId: "media-history",
sessionKey: "main",
messages: [
userMessageWithMedia([{ path: safeRef, contentType: "image/png" }]),
userMessageWithMedia([{ contentType: "image/png", fileName: "metadata-only-local.png" }]),
],
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
showToolCalls: true,
});
const serialized = JSON.stringify(items);
expect(serialized).toContain(safeRef);
expect(serialized).not.toContain("metadata-only-local.png");
});
});
+4 -3
View File
@@ -8,7 +8,7 @@ import type {
NormalizedMessage,
ToolCard,
} from "../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../lib/chat/message-extract.ts";
import { extractTextCached, readTranscriptMediaEntries } from "../../lib/chat/message-extract.ts";
import {
normalizeMessage,
stripMessageDisplayMetadataText,
@@ -561,8 +561,9 @@ export function hasRenderableNormalizedMessage(message: unknown): boolean {
return false;
}
const role = normalizeRoleForGrouping(normalized.role);
const hasVisibleSenderLabel = role === "assistant" && Boolean(normalized.senderLabel?.trim());
return normalized.content.length > 0 || Boolean(normalized.replyTarget) || hasVisibleSenderLabel;
const label = role === "assistant" && normalized.senderLabel?.trim();
const media = role === "user" && readTranscriptMediaEntries(message).length;
return Boolean(normalized.content.length || normalized.replyTarget || label || media);
}
export function sanitizeStreamText(text: string): string {