fix: keep before-agent blocks redacted

This commit is contained in:
jesse-merhi
2026-05-06 00:08:12 +10:00
committed by clawsweeper
parent 1ea050c543
commit 043e8a6216
40 changed files with 94 additions and 850 deletions

View File

@@ -334,30 +334,12 @@ class ChatController(
}
private suspend fun requestChatHistoryJson(sessionKey: String): String {
val params =
return session.request(
"chat.history",
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
put("includeBlockedOriginalContent", JsonPrimitive(true))
}
val response = session.requestDetailed("chat.history", params.toString())
if (response.ok) return response.payloadJson ?: ""
val error = response.error
if (
error?.code == "INVALID_REQUEST" &&
error.message.contains("includeBlockedOriginalContent")
) {
val legacyParams =
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
}
val legacyResponse = session.requestDetailed("chat.history", legacyParams.toString())
if (legacyResponse.ok) return legacyResponse.payloadJson ?: ""
val legacyError = legacyResponse.error
throw IllegalStateException(
"${legacyError?.code ?: "UNAVAILABLE"}: ${legacyError?.message ?: "request failed"}",
)
}
throw IllegalStateException("${error?.code ?: "UNAVAILABLE"}: ${error?.message ?: "request failed"}")
}.toString(),
)
}
private suspend fun pollHealthIfNeeded(force: Boolean) {
@@ -535,21 +517,11 @@ class ChatController(
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
val originalBlockedContent =
obj["__openclaw"]
.asObjectOrNull()
?.get("originalBlockedContent")
.asObjectOrNull()
?.get("content")
.asArrayOrNull()
?.mapNotNull(::parseMessageContent)
?: emptyList()
val ts = obj["timestamp"].asLongOrNull()
ChatMessage(
id = UUID.randomUUID().toString(),
role = role,
content = content,
originalBlockedContent = originalBlockedContent,
timestampMs = ts,
)
}
@@ -677,14 +649,6 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
.orEmpty(),
).joinToString(separator = "\u001F")
}
val blockedFingerprint =
message.originalBlockedContent.joinToString(separator = "\u001E") { part ->
listOf(
part.type.trim().lowercase(),
part.text?.trim().orEmpty(),
).joinToString(separator = "\u001F")
}
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint, blockedFingerprint).joinToString(separator = "|")
}

View File

@@ -4,7 +4,6 @@ data class ChatMessage(
val id: String,
val role: String,
val content: List<ChatMessageContent>,
val originalBlockedContent: List<ChatMessageContent> = emptyList(),
val timestampMs: Long?,
)

View File

@@ -57,44 +57,6 @@ fun ChatMessageBubble(message: ChatMessage) {
val role = message.role.trim().lowercase(Locale.US)
val style = bubbleStyle(role)
val displayContent =
if (role == "user" && message.originalBlockedContent.isNotEmpty()) {
message.originalBlockedContent
} else {
message.content
}
// Filter to only displayable content parts (text with content, or base64 images).
val displayableContent =
displayContent.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
}
if (displayableContent.isEmpty()) return
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
ChatMessageBody(content = displayableContent, textColor = mobileText)
if (role == "user" && message.originalBlockedContent.isNotEmpty()) {
Surface(
color = Color.Transparent,
border = BorderStroke(0.dp, Color.Transparent),
modifier = Modifier.fillMaxWidth(),
) {
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
androidx.compose.material3.HorizontalDivider(
color = mobileText.copy(alpha = 0.18f),
thickness = 1.dp,
)
Text(
text = "The agent cannot read this message.",
style = mobileCaption1.copy(fontWeight = FontWeight.Medium),
color = mobileText.copy(alpha = 0.68f),
)
}
}
}
}
}

View File

@@ -54,35 +54,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct ParamsWithBlockedOriginals: Codable {
var sessionKey: String
var includeBlockedOriginalContent: Bool
}
struct LegacyParams: Codable {
var sessionKey: String
}
let encoder = JSONEncoder()
let data = try encoder.encode(
ParamsWithBlockedOriginals(sessionKey: sessionKey, includeBlockedOriginalContent: true))
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
let res: Data
do {
res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
} catch {
guard Self.isUnsupportedBlockedOriginalHistoryParam(error) else { throw error }
let legacyData = try encoder.encode(LegacyParams(sessionKey: sessionKey))
let legacyJson = String(data: legacyData, encoding: .utf8)
res = try await self.gateway.request(method: "chat.history", paramsJSON: legacyJson, timeoutSeconds: 15)
}
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
}
private static func isUnsupportedBlockedOriginalHistoryParam(_ error: Error) -> Bool {
guard let response = error as? GatewayResponseError else { return false }
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
return response.message.contains("includeBlockedOriginalContent")
}
func sendMessage(
sessionKey: String,
message: String,

View File

@@ -630,30 +630,14 @@ extension GatewayConnection {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"includeBlockedOriginalContent": AnyCodable(true),
]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
do {
return try await self.requestDecoded(
method: .chatHistory,
params: params,
timeoutMs: timeout)
} catch {
guard Self.isUnsupportedBlockedOriginalHistoryParam(error) else { throw error }
params.removeValue(forKey: "includeBlockedOriginalContent")
return try await self.requestDecoded(
method: .chatHistory,
params: params,
timeoutMs: timeout)
}
}
private static func isUnsupportedBlockedOriginalHistoryParam(_ error: Error) -> Bool {
guard let response = error as? GatewayResponseError else { return false }
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
return response.message.contains("includeBlockedOriginalContent")
return try await self.requestDecoded(
method: .chatHistory,
params: params,
timeoutMs: timeout)
}
func chatSend(

View File

@@ -5554,25 +5554,21 @@ public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?
public let maxchars: Int?
public let includeblockedoriginalcontent: Bool?
public init(
sessionkey: String,
limit: Int?,
maxchars: Int?,
includeblockedoriginalcontent: Bool? = nil)
maxchars: Int?)
{
self.sessionkey = sessionkey
self.limit = limit
self.maxchars = maxchars
self.includeblockedoriginalcontent = includeblockedoriginalcontent
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case limit
case maxchars = "maxChars"
case includeblockedoriginalcontent = "includeBlockedOriginalContent"
}
}

View File

@@ -275,14 +275,7 @@ private struct ChatMessageBody: View {
}
private var displayContent: [OpenClawChatMessageContent] {
if self.isBlockedUserMessage, let original = self.message.originalBlockedContent {
return original
}
return self.message.content
}
private var isBlockedUserMessage: Bool {
self.isUser && !(self.message.originalBlockedContent?.isEmpty ?? true)
self.message.content
}
private var toolCalls: [OpenClawChatMessageContent] {

View File

@@ -139,25 +139,15 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
public var id: UUID = .init()
public let role: String
public let content: [OpenClawChatMessageContent]
public let originalBlockedContent: [OpenClawChatMessageContent]?
public let timestamp: Double?
public let toolCallId: String?
public let toolName: String?
public let usage: OpenClawChatUsage?
public let stopReason: String?
private struct OpenClawMetadata: Codable, Sendable {
let originalBlockedContent: OriginalBlockedContent?
}
private struct OriginalBlockedContent: Codable, Sendable {
let content: [OpenClawChatMessageContent]?
}
enum CodingKeys: String, CodingKey {
case role
case content
case openclawMetadata = "__openclaw"
case timestamp
case toolCallId
case tool_call_id
@@ -171,7 +161,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
id: UUID = .init(),
role: String,
content: [OpenClawChatMessageContent],
originalBlockedContent: [OpenClawChatMessageContent]? = nil,
timestamp: Double?,
toolCallId: String? = nil,
toolName: String? = nil,
@@ -181,7 +170,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
self.id = id
self.role = role
self.content = content
self.originalBlockedContent = originalBlockedContent
self.timestamp = timestamp
self.toolCallId = toolCallId
self.toolName = toolName
@@ -201,9 +189,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
container.decodeIfPresent(String.self, forKey: .tool_name)
self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
let metadata = try container.decodeIfPresent(OpenClawMetadata.self, forKey: .openclawMetadata)
self.originalBlockedContent = metadata?.originalBlockedContent?.content
if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) {
self.content = decoded
return
@@ -239,12 +224,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encode(self.content, forKey: .content)
if let originalBlockedContent = self.originalBlockedContent {
try container.encode(
OpenClawMetadata(
originalBlockedContent: OriginalBlockedContent(content: originalBlockedContent)),
forKey: .openclawMetadata)
}
}
}

View File

@@ -281,27 +281,12 @@ public final class OpenClawChatViewModel {
name: content.name,
arguments: content.arguments)
}
let sanitizedOriginalBlockedContent = message.originalBlockedContent?.map { content -> OpenClawChatMessageContent in
guard let text = content.text else { return content }
let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned
return OpenClawChatMessageContent(
type: content.type,
text: cleaned,
thinking: content.thinking,
thinkingSignature: content.thinkingSignature,
mimeType: content.mimeType,
fileName: content.fileName,
content: content.content,
id: content.id,
name: content.name,
arguments: content.arguments)
}
return OpenClawChatMessage(
id: message.id,
role: message.role,
content: sanitizedContent,
originalBlockedContent: sanitizedOriginalBlockedContent,
timestamp: message.timestamp,
toolCallId: message.toolCallId,
toolName: message.toolName,
@@ -318,21 +303,11 @@ public final class OpenClawChatViewModel {
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let originalBlockedFingerprint = (message.originalBlockedContent ?? []).map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
return [contentFingerprint, originalBlockedFingerprint].joined(separator: "\\u{001D}")
return contentFingerprint
}
private static func userVisibleContentFingerprint(for message: OpenClawChatMessage) -> String {
let content = {
if let originalBlockedContent = message.originalBlockedContent, !originalBlockedContent.isEmpty {
return originalBlockedContent
}
return message.content
}()
let content = message.content
return content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -404,7 +379,6 @@ public final class OpenClawChatViewModel {
id: reusedId,
role: message.role,
content: message.content,
originalBlockedContent: message.originalBlockedContent,
timestamp: message.timestamp,
toolCallId: message.toolCallId,
toolName: message.toolName,

View File

@@ -5554,25 +5554,21 @@ public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?
public let maxchars: Int?
public let includeblockedoriginalcontent: Bool?
public init(
sessionkey: String,
limit: Int?,
maxchars: Int?,
includeblockedoriginalcontent: Bool? = nil)
maxchars: Int?)
{
self.sessionkey = sessionkey
self.limit = limit
self.maxchars = maxchars
self.includeblockedoriginalcontent = includeblockedoriginalcontent
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case limit
case maxchars = "maxChars"
case includeblockedoriginalcontent = "includeBlockedOriginalContent"
}
}

View File

@@ -16,7 +16,6 @@ private func chatBlockedUserMessage(redactedText: String, originalText: String,
"role": "user",
"content": [["type": "text", "text": redactedText]],
"__openclaw": [
"originalBlockedContent": [
"content": [["type": "text", "text": originalText]],
],
],
@@ -630,7 +629,6 @@ extension TestChatTransportState {
message.role == "user" &&
message.content.compactMap(\.text).joined(separator: "\n") ==
"The agent cannot read this message." &&
message.originalBlockedContent?.compactMap(\.text).joined(separator: "\n") ==
"hello from mac webchat"
}
let hasAssistant = vm.messages.contains { message in

View File

@@ -707,9 +707,12 @@ describe("runCliAgent reliability", () => {
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
const blockedLine = JSON.parse(lines[lines.length - 1]);
expect(blockedLine.message.content[0].text).toBe("The agent cannot read this message.");
expect(blockedLine.message.__openclaw.originalBlockedContent.content[0].text).toBe(
"secret prompt",
);
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).toMatchObject({
blockedBy: "policy-plugin",
reason: "contains protected content",
});
expect(Object.hasOwn(blockedLine.message.__openclaw, "beforeAgentRunBlocked")).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}

View File

@@ -246,7 +246,6 @@ export async function runPreparedCliAgent(
}): Promise<void> => {
try {
const nowMs = Date.now();
const originalText = params.transcriptPrompt ?? params.prompt;
const sessionManager = SessionManager.open(params.sessionFile);
sessionManager.appendMessage({
role: "user",
@@ -254,8 +253,7 @@ export async function runPreparedCliAgent(
timestamp: nowMs,
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
__openclaw: {
originalBlockedContent: {
content: originalText ? [{ type: "text", text: originalText }] : [],
beforeAgentRunBlocked: {
blockedBy: block.pluginId,
reason: block.reason,
blockedAt: nowMs,

View File

@@ -28,26 +28,6 @@ type HistoryEntry = {
summary?: unknown;
};
function stripBlockedOriginalContentMeta(message: unknown): unknown {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return message;
}
const record = message as Record<string, unknown>;
const openclaw =
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
? (record.__openclaw as Record<string, unknown>)
: undefined;
if (!openclaw || !Object.hasOwn(openclaw, "originalBlockedContent")) {
return message;
}
const { originalBlockedContent: _originalBlockedContent, ...remainingOpenClaw } = openclaw;
const { __openclaw: _openclaw, ...remainingMessage } = record;
if (Object.keys(remainingOpenClaw).length === 0) {
return remainingMessage;
}
return { ...remainingMessage, __openclaw: remainingOpenClaw };
}
function coerceHistoryText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
@@ -199,7 +179,7 @@ export async function loadCliSessionHistoryMessages(params: {
}): Promise<unknown[]> {
const history = (await loadCliSessionEntries(params)).flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
return candidate.type === "message" ? [candidate.message] : [];
});
return limitAgentHookHistoryMessages(history, MAX_CLI_SESSION_HISTORY_MESSAGES);
}
@@ -228,7 +208,7 @@ export async function loadCliSessionReseedMessages(params: {
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
return candidate.type === "message" ? [candidate.message] : [];
});
return [
{

View File

@@ -698,6 +698,28 @@ describe("runWithModelFallback", () => {
).toBeNull();
});
it("keeps before_agent_run hook blocks out of empty-result fallback", () => {
const runResult: EmbeddedPiRunResult = {
payloads: [],
meta: {
durationMs: 1,
livenessState: "blocked",
error: {
kind: "hook_block",
message: "Blocked by before-run policy.",
},
},
};
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: "atlassian-ai-gateway-openai",
model: "gpt-5.5-2026-04-23",
result: runResult,
}),
).toBeNull();
});
it("uses harness-owned terminal classification for GPT-5 fallback", () => {
const runResult: EmbeddedPiRunResult = {
payloads: [],

View File

@@ -18,6 +18,9 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult {
}
function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean {
if (result.meta.error?.kind === "hook_block") {
return true;
}
return [result.meta.finalAssistantRawText, result.meta.finalAssistantVisibleText].some(
(text) => typeof text === "string" && isSilentReplyPayloadText(text),
);

View File

@@ -49,7 +49,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
it("surfaces before_agent_run hook block messages instead of generic prompt failure text", async () => {
it("does not emit a duplicate agent payload when before_agent_run blocks", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
@@ -64,12 +64,11 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.payloads).toEqual([
{
text: "Blocked by before-run policy.",
isError: true,
},
]);
expect(result.payloads).toEqual([]);
expect(result.meta).toMatchObject({
livenessState: "blocked",
error: { kind: "hook_block", message: "Blocked by before-run policy." },
});
expect(result.meta?.error).toEqual({
kind: "hook_block",
message: "Blocked by before-run policy.",

View File

@@ -1834,12 +1834,7 @@ export async function runEmbeddedPiAgent(
livenessState: "blocked",
});
return {
payloads: [
{
text: errorText,
isError: true,
},
],
payloads: [],
meta: {
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({

View File

@@ -151,15 +151,14 @@ describe("normalizeMessagesForLlmBoundary", () => {
);
});
it("strips blocked original content metadata from the LLM boundary", () => {
it("keeps only safe blocked metadata at the LLM boundary", () => {
const input = [
{
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
timestamp: 1,
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret prompt" }],
beforeAgentRunBlocked: {
blockedBy: "policy-plugin",
reason: "contains protected content",
blockedAt: 1,
@@ -175,7 +174,7 @@ describe("normalizeMessagesForLlmBoundary", () => {
expect(output[0]?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(output[0]).not.toHaveProperty("__openclaw");
expect(output[0]).toHaveProperty("__openclaw.beforeAgentRunBlocked");
expect(JSON.stringify(output)).not.toContain("secret prompt");
expect(input[0]).toHaveProperty("__openclaw");
});

View File

@@ -492,39 +492,13 @@ function summarizeSessionContext(messages: AgentMessage[]): {
export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] {
const normalized = stripToolResultDetails(normalizeAssistantReplayContent(messages));
return stripBlockedOriginalContentFromMessages(
stripHistoricalRuntimeContextCustomMessages(normalized),
);
return stripHistoricalRuntimeContextCustomMessages(normalized);
}
function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] {
return messages.map((message) => structuredClone(message));
}
function stripBlockedOriginalContentFromMessages(messages: AgentMessage[]): AgentMessage[] {
return messages.map(stripBlockedOriginalContentFromMessage);
}
function stripBlockedOriginalContentFromMessage(message: AgentMessage): AgentMessage {
const record = message as AgentMessage & { __openclaw?: unknown };
const meta =
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
? (record.__openclaw as Record<string, unknown>)
: undefined;
if (!meta || !Object.hasOwn(meta, "originalBlockedContent")) {
return message;
}
const { originalBlockedContent: _originalBlockedContent, ...remainingMeta } = meta;
const { __openclaw: _openclaw, ...remainingMessage } = record;
if (Object.keys(remainingMeta).length === 0) {
return remainingMessage as AgentMessage;
}
return {
...remainingMessage,
__openclaw: remainingMeta,
} as unknown as AgentMessage;
}
function sessionMessagesContainIdempotencyKey(
messages: AgentMessage[],
idempotencyKey: string,
@@ -2829,18 +2803,13 @@ export async function runEmbeddedAttempt(
return true;
}
const nowMs = Date.now();
const originalBlockedContent =
blockedTranscriptPrompt.length > 0
? [{ type: "text" as const, text: blockedTranscriptPrompt }]
: [];
const redactedUserMessage = {
role: "user" as const,
content: [{ type: "text" as const, text: block.message }],
timestamp: nowMs,
idempotencyKey,
__openclaw: {
originalBlockedContent: {
content: originalBlockedContent,
beforeAgentRunBlocked: {
blockedBy: block.pluginId,
reason: block.reason,
blockedAt: nowMs,
@@ -3352,9 +3321,7 @@ export async function runEmbeddedAttempt(
);
}
}
messagesSnapshot = stripBlockedOriginalContentFromMessages(
snapshotSelection.messagesSnapshot,
);
messagesSnapshot = snapshotSelection.messagesSnapshot;
sessionIdUsed = snapshotSelection.sessionIdUsed;
lastAssistant = messagesSnapshot

View File

@@ -347,14 +347,6 @@ function sanitizeChatHistoryMessage(
}
}
if ("__openclaw" in entry) {
const sanitized = sanitizeBlockedOriginalContentMeta(entry.__openclaw, maxChars);
if (sanitized.changed) {
entry.__openclaw = sanitized.meta;
changed = true;
}
}
return { message: changed ? entry : message, changed };
}

View File

@@ -28,7 +28,6 @@ export const ChatHistoryParamsSchema = Type.Object(
sessionKey: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 500_000 })),
includeBlockedOriginalContent: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -570,137 +570,6 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.dispatchBlockedByBeforeAgentRun = false;
});
it("includes blocked original content for scoped chat history callers", async () => {
createTranscriptFixture("openclaw-chat-history-blocked-original-");
fs.writeFileSync(
mockState.transcriptPath,
[
{
type: "session",
version: CURRENT_SESSION_VERSION,
id: mockState.sessionId,
timestamp: new Date(0).toISOString(),
cwd: "/tmp",
},
{
type: "message",
id: "blocked-1",
parentId: null,
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
timestamp: 1,
},
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
blockedBy: "policy-plugin",
reason: "blocked by policy",
blockedAt: 1,
},
},
]
.map((line) => JSON.stringify(line))
.join("\n") + "\n",
"utf-8",
);
const scoped = await runChatHistory({
client: createScopedCliClient(["operator.admin"]),
requestParams: { includeBlockedOriginalContent: true },
});
expect(
(
scoped.messages?.[0] as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
).toBe("secret blocked prompt");
const sensitiveScoped = await runChatHistory({
client: createScopedCliClient(["operator.talk.secrets"]),
requestParams: { includeBlockedOriginalContent: true },
});
expect(
(
sensitiveScoped.messages?.[0] as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
).toBe("secret blocked prompt");
const writeScoped = await runChatHistory({
client: createScopedCliClient(["operator.write"]),
requestParams: { includeBlockedOriginalContent: true },
});
expect(
(
writeScoped.messages?.[0] as {
__openclaw?: { originalBlockedContent?: unknown };
}
)?.__openclaw?.originalBlockedContent,
).toBeUndefined();
const unscoped = await runChatHistory({
client: createScopedCliClient(["operator.read"]),
requestParams: { includeBlockedOriginalContent: true },
});
expect(
(
unscoped.messages?.[0] as {
__openclaw?: { originalBlockedContent?: unknown };
}
)?.__openclaw?.originalBlockedContent,
).toBeUndefined();
});
it("applies chat history text caps to blocked original content", async () => {
createTranscriptFixture("openclaw-chat-history-blocked-original-maxchars-");
fs.writeFileSync(
mockState.transcriptPath,
[
{
type: "session",
version: CURRENT_SESSION_VERSION,
id: mockState.sessionId,
timestamp: new Date(0).toISOString(),
cwd: "/tmp",
},
{
type: "message",
id: "blocked-1",
parentId: null,
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
timestamp: 1,
},
originalBlockedContent: {
content: [{ type: "text", text: "secret ".repeat(20) }],
blockedBy: "policy-plugin",
reason: "blocked by policy",
blockedAt: 1,
},
},
]
.map((line) => JSON.stringify(line))
.join("\n") + "\n",
"utf-8",
);
const scoped = await runChatHistory({
client: createScopedCliClient(["operator.admin"]),
requestParams: { includeBlockedOriginalContent: true, maxChars: 24 },
});
expect(
(
scoped.messages?.[0] as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
).toBe("secret secret secret sec\n...(truncated)...");
});
it("registers tool-event recipients for clients advertising tool-events capability", async () => {
createTranscriptFixture("openclaw-chat-send-tool-events-");
mockState.finalText = "ok";
@@ -2277,51 +2146,6 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("does not emit raw user transcript content after before_agent_run blocks", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-gate-");
mockState.finalText = "The agent cannot read this message.";
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
mockState.onAfterAgentRunStart = () => {
fs.appendFileSync(
mockState.transcriptPath,
`${JSON.stringify({
type: "message",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
idempotencyKey: "hook-block:before_agent_run:user:idem-user-transcript-blocked-gate",
},
originalBlockedContent: {
content: [{ type: "text", text: "secret prompt that was blocked" }],
blockedBy: "policy-plugin",
reason: "contains protected content",
blockedAt: 1,
},
})}\n`,
"utf-8",
);
};
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-blocked-gate",
message: "secret prompt that was blocked",
expectBroadcast: false,
});
const userUpdates = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdates).toHaveLength(0);
});
it("does not emit raw user transcript content when before_agent_run blocks without a persisted marker", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-live-signal-");
mockState.finalText = "The agent cannot read this message.";

View File

@@ -761,11 +761,6 @@ function canInjectSystemProvenance(client: GatewayRequestHandlerOptions["client"
return scopes.includes(ADMIN_SCOPE);
}
function canIncludeBlockedOriginalContent(client: GatewayRequestHandlerOptions["client"]): boolean {
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
}
async function persistChatSendImages(params: {
images: ChatImageContent[];
imageOrder: PromptImageOrderEntry[];
@@ -1731,11 +1726,10 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
const { sessionKey, limit, maxChars, includeBlockedOriginalContent } = params as {
const { sessionKey, limit, maxChars } = params as {
sessionKey: string;
limit?: number;
maxChars?: number;
includeBlockedOriginalContent?: boolean;
};
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
@@ -1751,8 +1745,6 @@ export const chatHandlers: GatewayRequestHandlers = {
? await readRecentSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
maxMessages: max,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
includeBlockedOriginalContent:
includeBlockedOriginalContent === true && canIncludeBlockedOriginalContent(client),
})
: [];
const rawMessages = augmentChatHistoryWithCliSessionImports({

View File

@@ -455,31 +455,6 @@ describe("sanitizeChatHistoryMessages", () => {
},
]);
});
it("truncates blocked original sidecar content with the chat history text cap", () => {
const result = sanitizeChatHistoryMessages(
[
{
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret ".repeat(20) }],
},
},
},
],
24,
);
expect(
(
result[0] as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
).toBe("secret secret secret sec\n...(truncated)...");
});
});
describe("projectRecentChatDisplayMessages", () => {

View File

@@ -12,7 +12,6 @@ import {
loadGatewaySessionRow,
loadSessionEntry,
readSessionMessageCountAsync,
stripBlockedOriginalContentMeta,
type GatewaySessionRow,
} from "./session-utils.js";
@@ -127,7 +126,7 @@ async function handleTranscriptUpdateBroadcast(
sessionRow: loadGatewaySessionRow(sessionKey, { transcriptUsageMaxBytes: 64 * 1024 }),
includeSession: true,
});
const rawMessage = attachOpenClawTranscriptMeta(stripBlockedOriginalContentMeta(update.message), {
const rawMessage = attachOpenClawTranscriptMeta(update.message, {
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
...(typeof messageSeq === "number" ? { seq: messageSeq } : {}),
});

View File

@@ -288,64 +288,4 @@ describe("SessionHistorySseState", () => {
).toBeNull();
expect(state.snapshot().messages).toHaveLength(1);
});
test("strips blocked original content from inline SSE messages", () => {
const state = SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "sess-main" },
rawMessages: [],
});
const appended = state.appendInlineMessage({
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
},
messageId: "blocked-1",
});
expect(
(
appended?.message as {
__openclaw?: { originalBlockedContent?: unknown };
}
).__openclaw?.originalBlockedContent,
).toBeUndefined();
expect(state.snapshot().messages[0]?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
});
test("keeps blocked original content for authorized inline SSE messages", () => {
const state = SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "sess-main" },
rawMessages: [],
includeBlockedOriginalContent: true,
});
const appended = state.appendInlineMessage({
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
},
messageId: "blocked-1",
});
expect(
(
appended?.message as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
).toBe("secret blocked prompt");
});
});

View File

@@ -6,7 +6,6 @@ import {
attachOpenClawTranscriptMeta,
readRecentSessionMessagesWithStatsAsync,
readSessionMessagesAsync,
stripBlockedOriginalContentMeta,
} from "./session-utils.js";
type SessionHistoryTranscriptMeta = {
@@ -157,7 +156,6 @@ export class SessionHistorySseState {
private readonly maxChars: number;
private readonly limit: number | undefined;
private readonly cursor: string | undefined;
private readonly includeBlockedOriginalContent: boolean;
private sentHistory: PaginatedSessionHistory;
private rawTranscriptSeq: number;
@@ -169,14 +167,12 @@ export class SessionHistorySseState {
maxChars?: number;
limit?: number;
cursor?: string;
includeBlockedOriginalContent?: boolean;
}): SessionHistorySseState {
return new SessionHistorySseState({
target: params.target,
maxChars: params.maxChars,
limit: params.limit,
cursor: params.cursor,
includeBlockedOriginalContent: params.includeBlockedOriginalContent,
initialRawMessages: params.rawMessages,
rawTranscriptSeq: params.rawTranscriptSeq,
totalRawMessages: params.totalRawMessages,
@@ -188,7 +184,6 @@ export class SessionHistorySseState {
maxChars?: number;
limit?: number;
cursor?: string;
includeBlockedOriginalContent?: boolean;
initialRawMessages: unknown[];
rawTranscriptSeq?: number;
totalRawMessages?: number;
@@ -197,7 +192,6 @@ export class SessionHistorySseState {
this.maxChars = params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
this.limit = params.limit;
this.cursor = params.cursor;
this.includeBlockedOriginalContent = params.includeBlockedOriginalContent === true;
const rawSnapshot = {
rawMessages: params.initialRawMessages,
...(typeof params.rawTranscriptSeq === "number"
@@ -235,10 +229,7 @@ export class SessionHistorySseState {
return null;
}
this.rawTranscriptSeq += 1;
const projectedMessage = this.includeBlockedOriginalContent
? update.message
: stripBlockedOriginalContentMeta(update.message);
const nextMessage = attachOpenClawTranscriptMeta(projectedMessage, {
const nextMessage = attachOpenClawTranscriptMeta(update.message, {
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
seq: this.rawTranscriptSeq,
});
@@ -286,7 +277,6 @@ export class SessionHistorySseState {
this.target.sessionFile,
{
...resolveSessionHistoryTailReadOptions(this.limit),
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
},
);
return {
@@ -303,7 +293,6 @@ export class SessionHistorySseState {
{
mode: "full",
reason: "session history cursor pagination",
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
},
),
};

View File

@@ -314,20 +314,18 @@ describe("session.message websocket events", () => {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
beforeAgentRunBlocked: { blockedBy: "policy-plugin", reason: "blocked", blockedAt: 1 },
},
},
});
const payload = messageEvent.payload as {
message?: { content?: unknown; __openclaw?: { originalBlockedContent?: unknown } };
message?: { content?: unknown; __openclaw?: { beforeAgentRunBlocked?: unknown } };
};
expect(payload.message?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(payload.message?.__openclaw?.originalBlockedContent).toBeUndefined();
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
});
});
@@ -353,8 +351,7 @@ describe("session.message websocket events", () => {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
beforeAgentRunBlocked: {
blockedBy: "policy-plugin",
reason: "contains protected content",
blockedAt: Date.now(),
@@ -368,14 +365,14 @@ describe("session.message websocket events", () => {
message?: {
role?: unknown;
content?: unknown;
__openclaw?: { originalBlockedContent?: unknown };
__openclaw?: { beforeAgentRunBlocked?: unknown };
};
};
expect(payload.message?.role).toBe("user");
expect(payload.message?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(payload.message?.__openclaw?.originalBlockedContent).toBeUndefined();
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
});
});

View File

@@ -92,7 +92,7 @@ function appendBlockedUserMessageWithSessionManager(params: {
timestamp: Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
__openclaw: {
originalBlockedContent: {
beforeAgentRunBlocked: {
content: params.originalText ? [{ type: "text", text: params.originalText }] : [],
blockedBy: params.pluginId,
reason: params.reason,
@@ -1305,9 +1305,7 @@ describe("readSessionMessages", () => {
});
expect(messageId).toBeTruthy();
const out = readSessionMessages(sessionId, storePath, sessionFile, {
includeBlockedOriginalContent: true,
});
const out = readSessionMessages(sessionId, storePath, sessionFile, {});
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
@@ -1319,8 +1317,8 @@ describe("readSessionMessages", () => {
{ role: "user", text: [{ type: "text", text: "Blocked by HITL test hook." }] },
]);
expect(
(out[2] as { __openclaw?: { originalBlockedContent?: { content?: unknown } } }).__openclaw
?.originalBlockedContent?.content,
(out[2] as { __openclaw?: { beforeAgentRunBlocked?: { content?: unknown } } }).__openclaw
?.beforeAgentRunBlocked?.content,
).toEqual([{ type: "text", text: "[hitl:block] hello" }]);
});
@@ -1359,17 +1357,15 @@ describe("readSessionMessages", () => {
reason: "blocked by test policy",
});
const out = readSessionMessages(sessionId, storePath, sessionFile, {
includeBlockedOriginalContent: true,
});
const out = readSessionMessages(sessionId, storePath, sessionFile, {});
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
original: (
message as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
__openclaw?: { beforeAgentRunBlocked?: { content?: Array<{ text?: string }> } };
}
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
).__openclaw?.beforeAgentRunBlocked?.content?.[0]?.text,
})),
).toEqual([
{ role: "user", original: "[hitl:block] first" },

View File

@@ -139,28 +139,7 @@ export function attachOpenClawTranscriptMeta(
};
}
export function stripBlockedOriginalContentMeta(message: unknown): unknown {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return message;
}
const record = message as Record<string, unknown>;
const existing =
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
? (record.__openclaw as Record<string, unknown>)
: null;
if (!existing || !("originalBlockedContent" in existing)) {
return message;
}
const { originalBlockedContent: _originalBlockedContent, ...remainingMeta } = existing;
return {
...record,
__openclaw: remainingMeta,
};
}
type SessionMessageProjectionOptions = {
includeBlockedOriginalContent?: boolean;
};
type SessionMessageProjectionOptions = Record<never, never>;
export function readSessionMessages(
sessionId: string,
@@ -605,7 +584,7 @@ export async function visitSessionMessagesAsync(
storePath: string | undefined,
sessionFile: string | undefined,
visit: (message: unknown, seq: number) => void,
opts: { mode: "full"; reason: string; includeBlockedOriginalContent?: boolean },
opts: { mode: "full"; reason: string },
): Promise<number> {
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
if (!filePath) {
@@ -759,36 +738,9 @@ function parsedSessionEntryToMessage(
}
const entry = parsed as Record<string, unknown>;
if (entry.message) {
const messageRecord =
entry.message && typeof entry.message === "object" && !Array.isArray(entry.message)
? (entry.message as Record<string, unknown>)
: undefined;
const messageOpenClaw =
messageRecord?.__openclaw &&
typeof messageRecord.__openclaw === "object" &&
!Array.isArray(messageRecord.__openclaw)
? (messageRecord.__openclaw as Record<string, unknown>)
: undefined;
const originalBlockedContent =
opts?.includeBlockedOriginalContent === true &&
!messageOpenClaw?.originalBlockedContent &&
entry.originalBlockedContent &&
typeof entry.originalBlockedContent === "object" &&
!Array.isArray(entry.originalBlockedContent)
? {
originalBlockedContent: {
content: (entry.originalBlockedContent as { content?: unknown }).content,
},
}
: {};
const projectedMessage =
opts?.includeBlockedOriginalContent === true
? entry.message
: stripBlockedOriginalContentMeta(entry.message);
return attachOpenClawTranscriptMeta(projectedMessage, {
return attachOpenClawTranscriptMeta(entry.message, {
...(typeof entry.id === "string" ? { id: entry.id } : {}),
seq,
...originalBlockedContent,
});
}

View File

@@ -118,7 +118,6 @@ export {
readSessionTitleFieldsFromTranscriptAsync,
readSessionPreviewItemsFromTranscript,
readSessionMessagesAsync,
stripBlockedOriginalContentMeta,
visitSessionMessagesAsync,
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";

View File

@@ -106,28 +106,21 @@ vi.mock("./session-history-state.js", () => ({
history: { items: [], nextCursor: null, messages: [] },
}),
SessionHistorySseState: {
fromRawSnapshot: (params: { includeBlockedOriginalContent?: boolean }) => ({
fromRawSnapshot: (_params: unknown) => ({
snapshot: () => ({ items: [], nextCursor: null, messages: [] }),
appendInlineMessage: ({ message, messageId }: { message: unknown; messageId?: string }) => ({
message:
params.includeBlockedOriginalContent || !message || typeof message !== "object"
? message
: (() => {
const clone = { ...(message as Record<string, unknown>) };
delete clone.__openclaw;
return clone;
})(),
message,
messageSeq: 1,
messageId,
}),
refreshAsync: async () => ({
items: [
params.includeBlockedOriginalContent
false
? {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
beforeAgentRunBlocked: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
@@ -139,12 +132,12 @@ vi.mock("./session-history-state.js", () => ({
],
nextCursor: null,
messages: [
params.includeBlockedOriginalContent
false
? {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
beforeAgentRunBlocked: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
@@ -252,112 +245,6 @@ describe("session history SSE auth revocation", () => {
expect(res.writableEnded).toBe(true);
});
it("closes original-content streams when admin scope is downgraded", async () => {
currentScopes = ["operator.read", "operator.admin"];
const req = new MockReq("/sessions/agent%3Amain/history?includeBlockedOriginalContent=true");
const res = new MockRes();
const handled = await handleSessionHistoryHttpRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse,
{ auth: { mode: "trusted-proxy" } as never },
);
expect(handled).toBe(true);
expect(transcriptUpdateHandler).toBeTypeOf("function");
currentScopes = ["operator.read"];
transcriptUpdateHandler?.({
sessionFile: "/tmp/session-1.jsonl",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
},
messageId: "blocked-1",
});
await new Promise((resolve) => setTimeout(resolve, 0));
const joined = res.writes.join("");
expect(joined).not.toContain("event: message");
expect(joined).not.toContain("secret blocked prompt");
expect(res.writableEnded).toBe(true);
});
it("refreshes authorized SSE history for redacted blocked update originals", async () => {
currentScopes = ["operator.read", "operator.talk.secrets"];
const req = new MockReq("/sessions/agent%3Amain/history?includeBlockedOriginalContent=true");
const res = new MockRes();
const handled = await handleSessionHistoryHttpRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse,
{ auth: { mode: "trusted-proxy" } as never },
);
expect(handled).toBe(true);
expect(transcriptUpdateHandler).toBeTypeOf("function");
transcriptUpdateHandler?.({
sessionFile: "/tmp/session-1.jsonl",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
},
messageId: "blocked-1",
forceHistoryRefresh: true,
});
await new Promise((resolve) => setTimeout(resolve, 0));
const joined = res.writes.join("");
expect(joined).toContain("event: history");
expect(joined).toContain("secret blocked prompt");
expect(res.writableEnded).toBe(false);
});
it("strips blocked originals on unscoped live inline SSE updates", async () => {
currentScopes = ["operator.read"];
const req = new MockReq("/sessions/agent%3Amain/history");
const res = new MockRes();
const handled = await handleSessionHistoryHttpRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse,
{ auth: { mode: "trusted-proxy" } as never },
);
expect(handled).toBe(true);
expect(transcriptUpdateHandler).toBeTypeOf("function");
transcriptUpdateHandler?.({
sessionFile: "/tmp/session-1.jsonl",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
},
messageId: "blocked-1",
});
await new Promise((resolve) => setTimeout(resolve, 0));
const joined = res.writes.join("");
expect(joined).toContain("event: message");
expect(joined).not.toContain("secret blocked prompt");
expect(res.writableEnded).toBe(false);
});
it("rechecks SSE auth against live proxy config instead of startup fallbacks", async () => {
const req = new MockReq("/sessions/agent%3Amain/history");
const res = new MockRes();

View File

@@ -80,18 +80,6 @@ function resolveLimit(req: IncomingMessage): number | undefined {
return Math.min(MAX_SESSION_HISTORY_LIMIT, Math.max(1, value));
}
function shouldIncludeBlockedOriginalContent(
req: IncomingMessage,
requestAuth: Parameters<typeof resolveTrustedHttpOperatorScopes>[1],
): boolean {
const raw = getRequestUrl(req).searchParams.get("includeBlockedOriginalContent");
if (raw !== "1" && raw !== "true") {
return false;
}
const scopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
}
function canonicalizePath(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
@@ -149,8 +137,7 @@ export async function handleSessionHistoryHttpRequest(
if (!authResult) {
return true;
}
const { cfg, requestAuth } = authResult;
const includeBlockedOriginalContent = shouldIncludeBlockedOriginalContent(req, requestAuth);
const { cfg } = authResult;
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
const store = loadSessionStore(target.storePath);
@@ -179,7 +166,6 @@ export async function handleSessionHistoryHttpRequest(
entry.sessionFile,
{
...resolveSessionHistoryTailReadOptions(limit),
includeBlockedOriginalContent,
},
)
: undefined;
@@ -191,7 +177,6 @@ export async function handleSessionHistoryHttpRequest(
? await readSessionMessagesAsync(entry.sessionId, target.storePath, entry.sessionFile, {
mode: "full",
reason: "session history cursor pagination",
includeBlockedOriginalContent,
})
: []);
const historySnapshot = buildSessionHistorySnapshot({
@@ -238,7 +223,6 @@ export async function handleSessionHistoryHttpRequest(
maxChars: effectiveMaxChars,
limit,
cursor,
includeBlockedOriginalContent,
});
sentHistory = sseState.snapshot();
setSseHeaders(res);
@@ -308,13 +292,6 @@ export async function handleSessionHistoryHttpRequest(
return false;
}
const requestedScopes = resolveTrustedHttpOperatorScopes(req, currentRequestAuth.requestAuth);
if (
includeBlockedOriginalContent &&
!requestedScopes.includes(ADMIN_SCOPE) &&
!requestedScopes.includes(TALK_SECRETS_SCOPE)
) {
return false;
}
return authorizeOperatorScopesForMethod("chat.history", requestedScopes).allowed;
};

View File

@@ -664,11 +664,7 @@ describe("grouped chat rendering", () => {
{
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
__openclaw: {},
timestamp: 1000,
},
"user",

View File

@@ -1433,8 +1433,7 @@ function renderGroupedMessage(
const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
const markdown = markdownBase;
const isBlockedUserMessage =
normalizedRole === "user" && normalizedMessage.isBlockedOriginalContent === true;
const isBlockedUserMessage = false;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());

View File

@@ -22,30 +22,6 @@ function processMessageText(text: string, role: string): string {
export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
// Sidecar `__openclaw.originalBlockedContent` is set on user messages
// that were blocked by `before_agent_run` (or any pre-LLM hook). The
// agent transcript only ever sees the redacted stub in `message.content`,
// but the SPA renders the human's original input from this sidecar so
// the user can see what they typed. NOTE: this is for HUMAN display only;
// never inject this back into agent context.
if (role === "user") {
const meta = m.__openclaw as Record<string, unknown> | undefined;
const originalBlocked = meta?.originalBlockedContent as { content?: unknown } | undefined;
if (originalBlocked && Array.isArray(originalBlocked.content)) {
const parts = originalBlocked.content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") {
return item.text;
}
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
return processMessageText(parts.join("\n"), role);
}
}
}
const raw =
role === "assistant" ? extractSharedAssistantVisibleText(message) : extractRawText(message);
if (!raw) {

View File

@@ -42,29 +42,6 @@ describe("message-normalizer", () => {
expect(result.audioAsVoice).toBeUndefined();
});
it("renders blocked originals without mutating the raw redacted message", () => {
const rawMessage = {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
};
const result = normalizeMessage(rawMessage);
expect(result).toMatchObject({
role: "user",
content: [{ type: "text", text: "secret blocked prompt" }],
isBlockedOriginalContent: true,
});
expect(rawMessage.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
});
it("normalizes message with array content", () => {
const result = normalizeMessage({
role: "assistant",

View File

@@ -240,17 +240,7 @@ function expandTextContent(text: string): {
export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>;
let role = typeof m.role === "string" ? m.role : "unknown";
let isBlockedOriginalContent = false;
let contentRaw = m.content;
if (role === "user") {
const oc = m.__openclaw as Record<string, unknown> | undefined;
const obc = oc?.originalBlockedContent as { content?: unknown } | undefined;
if (obc && Array.isArray(obc.content) && obc.content.length > 0) {
contentRaw = obc.content;
isBlockedOriginalContent = true;
}
}
const contentRaw = m.content;
// Detect tool messages by common gateway shapes.
// Some tool events come through as assistant role with tool_* items in the content array.
@@ -396,7 +386,6 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
timestamp,
id,
senderLabel,
...(isBlockedOriginalContent ? { isBlockedOriginalContent: true } : {}),
...(audioAsVoice ? { audioAsVoice: true } : {}),
...(replyTarget ? { replyTarget } : {}),
};

View File

@@ -58,7 +58,6 @@ export type NormalizedMessage = {
timestamp: number;
id?: string;
senderLabel?: string | null;
isBlockedOriginalContent?: boolean;
audioAsVoice?: boolean;
replyTarget?:
| {