mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
Cap sessions_history payloads to prevent context overflow (#10000)
* Cap sessions_history payloads to prevent context overflow * fix: harden sessions_history payload caps * fix: cap sessions_history payloads to prevent context overflow (#10000) (thanks @gut-puncture) --------- Co-authored-by: Shailesh Rana <shaileshrana@ShaileshMM.local> Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
@@ -53,6 +53,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
|
||||
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
|
||||
- Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb.
|
||||
- Agents: cap `sessions_history` tool output and strip oversized fields to prevent context overflow. (#10000) Thanks @gut-puncture.
|
||||
- Security: normalize code safety finding paths in `openclaw security audit --deep` output for cross-platform consistency. (#10000) Thanks @gut-puncture.
|
||||
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
|
||||
- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
|
||||
- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
|
||||
|
||||
@@ -181,6 +181,128 @@ describe("sessions tools", () => {
|
||||
expect(withToolsDetails.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sessions_history caps oversized payloads and strips heavy fields", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const oversized = Array.from({ length: 80 }, (_, idx) => ({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${String(idx)}:${"x".repeat(5000)}`,
|
||||
},
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "y".repeat(7000),
|
||||
thinkingSignature: "sig".repeat(4000),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
giant: "z".repeat(12000),
|
||||
},
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
},
|
||||
}));
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "chat.history") {
|
||||
return { messages: oversized };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_history tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call4b", {
|
||||
sessionKey: "main",
|
||||
includeTools: true,
|
||||
});
|
||||
const details = result.details as {
|
||||
messages?: Array<Record<string, unknown>>;
|
||||
truncated?: boolean;
|
||||
droppedMessages?: boolean;
|
||||
contentTruncated?: boolean;
|
||||
bytes?: number;
|
||||
};
|
||||
expect(details.truncated).toBe(true);
|
||||
expect(details.droppedMessages).toBe(true);
|
||||
expect(details.contentTruncated).toBe(true);
|
||||
expect(typeof details.bytes).toBe("number");
|
||||
expect((details.bytes ?? 0) <= 80 * 1024).toBe(true);
|
||||
expect(details.messages && details.messages.length > 0).toBe(true);
|
||||
|
||||
const first = details.messages?.[0] as
|
||||
| {
|
||||
details?: unknown;
|
||||
usage?: unknown;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
thinkingSignature?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
expect(first?.details).toBeUndefined();
|
||||
expect(first?.usage).toBeUndefined();
|
||||
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();
|
||||
});
|
||||
|
||||
it("sessions_history enforces a hard byte cap even when a single message is huge", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "chat.history") {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
extra: "x".repeat(200_000),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_history tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call4c", {
|
||||
sessionKey: "main",
|
||||
includeTools: true,
|
||||
});
|
||||
const details = result.details as {
|
||||
messages?: Array<Record<string, unknown>>;
|
||||
truncated?: boolean;
|
||||
droppedMessages?: boolean;
|
||||
contentTruncated?: boolean;
|
||||
bytes?: number;
|
||||
};
|
||||
expect(details.truncated).toBe(true);
|
||||
expect(details.droppedMessages).toBe(true);
|
||||
expect(details.contentTruncated).toBe(false);
|
||||
expect(typeof details.bytes).toBe("number");
|
||||
expect((details.bytes ?? 0) <= 80 * 1024).toBe(true);
|
||||
expect(details.messages).toHaveLength(1);
|
||||
expect(details.messages?.[0]?.content).toContain(
|
||||
"[sessions_history omitted: message too large]",
|
||||
);
|
||||
});
|
||||
|
||||
it("sessions_history resolves sessionId inputs", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "sess-group";
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Type } from "@sinclair/typebox";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
|
||||
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { jsonResult, readStringParam } from "./common.js";
|
||||
import {
|
||||
createAgentToAgentPolicy,
|
||||
@@ -19,6 +21,131 @@ const SessionsHistoryToolSchema = Type.Object({
|
||||
includeTools: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SESSIONS_HISTORY_MAX_BYTES = 80 * 1024;
|
||||
const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000;
|
||||
|
||||
function truncateHistoryText(text: string): { text: string; truncated: boolean } {
|
||||
if (text.length <= SESSIONS_HISTORY_TEXT_MAX_CHARS) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
const cut = truncateUtf16Safe(text, SESSIONS_HISTORY_TEXT_MAX_CHARS);
|
||||
return { text: `${cut}\n…(truncated)…`, truncated: true };
|
||||
}
|
||||
|
||||
function sanitizeHistoryContentBlock(block: unknown): { block: unknown; truncated: boolean } {
|
||||
if (!block || typeof block !== "object") {
|
||||
return { block, truncated: false };
|
||||
}
|
||||
const entry = { ...(block as Record<string, unknown>) };
|
||||
let truncated = 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;
|
||||
}
|
||||
if (type === "thinking") {
|
||||
if (typeof entry.thinking === "string") {
|
||||
const res = truncateHistoryText(entry.thinking);
|
||||
entry.thinking = res.text;
|
||||
truncated ||= res.truncated;
|
||||
}
|
||||
// The encrypted signature can be extremely large and is not useful for history recall.
|
||||
if ("thinkingSignature" in entry) {
|
||||
delete entry.thinkingSignature;
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
if (typeof entry.partialJson === "string") {
|
||||
const res = truncateHistoryText(entry.partialJson);
|
||||
entry.partialJson = res.text;
|
||||
truncated ||= res.truncated;
|
||||
}
|
||||
if (type === "image") {
|
||||
const data = typeof entry.data === "string" ? entry.data : undefined;
|
||||
const bytes = data ? data.length : undefined;
|
||||
if ("data" in entry) {
|
||||
delete entry.data;
|
||||
truncated = true;
|
||||
}
|
||||
entry.omitted = true;
|
||||
if (bytes !== undefined) {
|
||||
entry.bytes = bytes;
|
||||
}
|
||||
}
|
||||
return { block: entry, truncated };
|
||||
}
|
||||
|
||||
function sanitizeHistoryMessage(message: unknown): { message: unknown; truncated: boolean } {
|
||||
if (!message || typeof message !== "object") {
|
||||
return { message, truncated: false };
|
||||
}
|
||||
const entry = { ...(message as Record<string, unknown>) };
|
||||
let truncated = false;
|
||||
// Tool result details often contain very large nested payloads.
|
||||
if ("details" in entry) {
|
||||
delete entry.details;
|
||||
truncated = true;
|
||||
}
|
||||
if ("usage" in entry) {
|
||||
delete entry.usage;
|
||||
truncated = true;
|
||||
}
|
||||
if ("cost" in entry) {
|
||||
delete entry.cost;
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (typeof entry.content === "string") {
|
||||
const res = truncateHistoryText(entry.content);
|
||||
entry.content = res.text;
|
||||
truncated ||= res.truncated;
|
||||
} else if (Array.isArray(entry.content)) {
|
||||
const updated = entry.content.map((block) => sanitizeHistoryContentBlock(block));
|
||||
entry.content = updated.map((item) => item.block);
|
||||
truncated ||= updated.some((item) => item.truncated);
|
||||
}
|
||||
if (typeof entry.text === "string") {
|
||||
const res = truncateHistoryText(entry.text);
|
||||
entry.text = res.text;
|
||||
truncated ||= res.truncated;
|
||||
}
|
||||
return { message: entry, truncated };
|
||||
}
|
||||
|
||||
function jsonUtf8Bytes(value: unknown): number {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
} catch {
|
||||
return Buffer.byteLength(String(value), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function enforceSessionsHistoryHardCap(params: {
|
||||
items: unknown[];
|
||||
bytes: number;
|
||||
maxBytes: number;
|
||||
}): { items: unknown[]; bytes: number; hardCapped: boolean } {
|
||||
if (params.bytes <= params.maxBytes) {
|
||||
return { items: params.items, bytes: params.bytes, hardCapped: false };
|
||||
}
|
||||
|
||||
const last = params.items.at(-1);
|
||||
const lastOnly = last ? [last] : [];
|
||||
const lastBytes = jsonUtf8Bytes(lastOnly);
|
||||
if (lastBytes <= params.maxBytes) {
|
||||
return { items: lastOnly, bytes: lastBytes, hardCapped: true };
|
||||
}
|
||||
|
||||
const placeholder = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "[sessions_history omitted: message too large]",
|
||||
},
|
||||
];
|
||||
return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true };
|
||||
}
|
||||
|
||||
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {
|
||||
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||
}
|
||||
@@ -131,10 +258,26 @@ export function createSessionsHistoryTool(opts?: {
|
||||
params: { sessionKey: resolvedKey, limit },
|
||||
});
|
||||
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
|
||||
const messages = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message));
|
||||
const contentTruncated = sanitizedMessages.some((entry) => entry.truncated);
|
||||
const cappedMessages = capArrayByJsonBytes(
|
||||
sanitizedMessages.map((entry) => entry.message),
|
||||
SESSIONS_HISTORY_MAX_BYTES,
|
||||
);
|
||||
const droppedMessages = cappedMessages.items.length < selectedMessages.length;
|
||||
const hardened = enforceSessionsHistoryHardCap({
|
||||
items: cappedMessages.items,
|
||||
bytes: cappedMessages.bytes,
|
||||
maxBytes: SESSIONS_HISTORY_MAX_BYTES,
|
||||
});
|
||||
return jsonResult({
|
||||
sessionKey: displayKey,
|
||||
messages,
|
||||
messages: hardened.items,
|
||||
truncated: droppedMessages || contentTruncated || hardened.hardCapped,
|
||||
droppedMessages: droppedMessages || hardened.hardCapped,
|
||||
contentTruncated,
|
||||
bytes: hardened.bytes,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1123,7 +1123,8 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string):
|
||||
relPath && relPath !== "." && !relPath.startsWith("..")
|
||||
? relPath
|
||||
: path.basename(finding.file);
|
||||
return ` - [${finding.ruleId}] ${finding.message} (${filePath}:${finding.line})`;
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user