fix: add before-agent-run blocking hook

This commit is contained in:
Jesse Merhi
2026-05-05 11:30:35 +10:00
committed by clawsweeper
parent cc9f88e6e6
commit 5e64394239
86 changed files with 3500 additions and 300 deletions

View File

@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
- Developer tooling: add checked-in VS Code Gateway debugging configs and an opt-in `OUTPUT_SOURCE_MAPS=1` source-map build path for breakpoints in TypeScript source. (#45710) Thanks @SwissArmyBud.
- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi.
- Telegram/native commands: show the current thinking level above the `/think` level picker so users can see the active setting before changing it. (#78278) Thanks @obviyus.
- Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi.
### Fixes

View File

@@ -300,7 +300,7 @@ class ChatController(
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
}
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
val historyJson = requestChatHistoryJson(key)
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
@@ -333,6 +333,33 @@ class ChatController(
}
}
private suspend fun requestChatHistoryJson(sessionKey: String): String {
val params =
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"}")
}
private suspend fun pollHealthIfNeeded(force: Boolean) {
val now = System.currentTimeMillis()
val last = lastHealthPollAtMs
@@ -375,8 +402,7 @@ class ChatController(
_streamingAssistantText.value = null
scope.launch {
try {
val historyJson =
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
val historyJson = requestChatHistoryJson(_sessionKey.value)
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
@@ -509,11 +535,21 @@ 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,
)
}
@@ -641,9 +677,16 @@ 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).joinToString(separator = "|")
return listOf(role, timestamp, contentFingerprint, blockedFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

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

View File

@@ -56,10 +56,16 @@ private data class ChatBubbleStyle(
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 =
message.content.filter { part ->
displayContent.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
@@ -70,6 +76,25 @@ fun ChatMessageBubble(message: ChatMessage) {
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,13 +54,35 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
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))
let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
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)
}
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

@@ -104,14 +104,16 @@ private struct ExecApprovalPromptCard: View {
}
VStack(spacing: 10) {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.frame(maxWidth: .infinity)
if self.prompt.allowsAllowOnce {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
if self.prompt.allowsAllowAlways {
Button {
@@ -125,14 +127,16 @@ private struct ExecApprovalPromptCard: View {
}
HStack(spacing: 10) {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
if self.prompt.allowsDeny {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
Button(role: .cancel) {
self.onCancel()

View File

@@ -71,9 +71,17 @@ final class NodeAppModel {
let agentId: String?
let expiresAtMs: Int?
var allowsAllowOnce: Bool {
self.allowedDecisions.contains("allow-once")
}
var allowsAllowAlways: Bool {
self.allowedDecisions.contains("allow-always")
}
var allowsDeny: Bool {
self.allowedDecisions.contains("deny")
}
}
private enum ExecApprovalResolutionOutcome {

View File

@@ -212,7 +212,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(firstPrompt.id == "approval-1")
#expect(firstPrompt.commandText == "echo first")
#expect(firstPrompt.allowsAllowOnce)
#expect(firstPrompt.allowsAllowAlways == false)
#expect(firstPrompt.allowsDeny)
appModel._test_presentExecApprovalPrompt(
try #require(
@@ -228,7 +230,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(secondPrompt.id == "approval-2")
#expect(secondPrompt.commandText == "echo second")
#expect(secondPrompt.allowsAllowOnce)
#expect(secondPrompt.allowsAllowAlways)
#expect(secondPrompt.allowsDeny)
appModel._test_dismissPendingExecApprovalPrompt()
#expect(appModel._test_pendingExecApprovalPrompt() == nil)

View File

@@ -14,6 +14,7 @@ struct ExecApprovalPromptRequest: Codable {
var agentId: String?
var resolvedPath: String?
var sessionKey: String?
var allowedDecisions: [ExecApprovalDecision]?
}
private struct ExecApprovalSocketRequest: Codable {
@@ -235,20 +236,43 @@ enum ExecApprovalsPromptPresenter {
alert.informativeText = "Review the command details before allowing."
alert.accessoryView = self.buildAccessoryView(request)
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
let decisions = self.renderedDecisions(request)
if decisions.isEmpty {
return .deny
}
for decision in decisions {
alert.addButton(withTitle: self.buttonTitle(decision))
}
if #available(macOS 11.0, *) {
for (index, decision) in decisions.enumerated()
where decision == .deny && alert.buttons.indices.contains(index)
{
alert.buttons[index].hasDestructiveAction = true
}
}
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
let response = alert.runModal()
let selectedIndex = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
if decisions.indices.contains(selectedIndex) {
return decisions[selectedIndex]
}
return .deny
}
private static func renderedDecisions(_ request: ExecApprovalPromptRequest) -> [ExecApprovalDecision] {
let defaults: [ExecApprovalDecision] = [.allowOnce, .allowAlways, .deny]
let allowed = request.allowedDecisions ?? defaults
return defaults.filter { allowed.contains($0) }
}
private static func buttonTitle(_ decision: ExecApprovalDecision) -> String {
switch decision {
case .allowOnce:
"Allow Once"
case .allowAlways:
"Always Allow"
case .deny:
"Don't Allow"
}
}

View File

@@ -628,14 +628,32 @@ extension GatewayConnection {
timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
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) }
return try await self.requestDecoded(
method: .chatHistory,
params: params,
timeoutMs: timeout)
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")
}
func chatSend(

View File

@@ -5233,6 +5233,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
public let turnsourceto: String?
public let turnsourceaccountid: String?
public let turnsourcethreadid: AnyCodable?
public let alloweddecisions: [String]?
public let timeoutms: Int?
public let twophase: Bool?
@@ -5249,6 +5250,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
turnsourceto: String?,
turnsourceaccountid: String?,
turnsourcethreadid: AnyCodable?,
alloweddecisions: [String]? = nil,
timeoutms: Int?,
twophase: Bool?)
{
@@ -5264,6 +5266,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.alloweddecisions = alloweddecisions
self.timeoutms = timeoutms
self.twophase = twophase
}
@@ -5281,6 +5284,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case alloweddecisions = "allowedDecisions"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
@@ -5554,21 +5558,25 @@ 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?)
maxchars: Int?,
includeblockedoriginalcontent: Bool? = nil)
{
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

@@ -202,6 +202,13 @@ private struct ChatMessageBody: View {
variant: self.markdownVariant,
font: .system(size: 14),
textColor: textColor)
if self.isBlockedUserMessage {
Divider()
.overlay(OpenClawChatTheme.userText.opacity(0.18))
Text("The agent cannot read this message.")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(OpenClawChatTheme.userText.opacity(0.68))
}
} else {
ChatAssistantTextBody(
text: text,
@@ -248,7 +255,7 @@ private struct ChatMessageBody: View {
}
private var primaryText: String {
let parts = self.message.content.compactMap { content -> String? in
let parts = self.displayContent.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
@@ -257,7 +264,7 @@ private struct ChatMessageBody: View {
}
private var inlineAttachments: [OpenClawChatMessageContent] {
self.message.content.filter { content in
self.displayContent.filter { content in
switch content.type ?? "text" {
case "file", "attachment":
true
@@ -267,6 +274,17 @@ 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)
}
private var toolCalls: [OpenClawChatMessageContent] {
self.message.content.filter { content in
let kind = (content.type ?? "").lowercased()

View File

@@ -139,15 +139,25 @@ 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
@@ -161,6 +171,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
id: UUID = .init(),
role: String,
content: [OpenClawChatMessageContent],
originalBlockedContent: [OpenClawChatMessageContent]? = nil,
timestamp: Double?,
toolCallId: String? = nil,
toolName: String? = nil,
@@ -170,6 +181,7 @@ 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
@@ -189,6 +201,8 @@ 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
@@ -225,6 +239,12 @@ 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,11 +281,27 @@ 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,
@@ -294,7 +310,30 @@ public final class OpenClawChatViewModel {
}
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
message.content.map { item in
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
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}")
}
private static func userVisibleContentFingerprint(for message: OpenClawChatMessage) -> String {
let content = {
if let originalBlockedContent = message.originalBlockedContent, !originalBlockedContent.isEmpty {
return originalBlockedContent
}
return message.content
}()
return content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -326,7 +365,7 @@ public final class OpenClawChatViewModel {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard role == "user" else { return nil }
let contentFingerprint = Self.messageContentFingerprint(for: message)
let contentFingerprint = Self.userVisibleContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
@@ -365,6 +404,7 @@ 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

@@ -5233,6 +5233,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
public let turnsourceto: String?
public let turnsourceaccountid: String?
public let turnsourcethreadid: AnyCodable?
public let alloweddecisions: [String]?
public let timeoutms: Int?
public let twophase: Bool?
@@ -5249,6 +5250,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
turnsourceto: String?,
turnsourceaccountid: String?,
turnsourcethreadid: AnyCodable?,
alloweddecisions: [String]? = nil,
timeoutms: Int?,
twophase: Bool?)
{
@@ -5264,6 +5266,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
self.turnsourceto = turnsourceto
self.turnsourceaccountid = turnsourceaccountid
self.turnsourcethreadid = turnsourcethreadid
self.alloweddecisions = alloweddecisions
self.timeoutms = timeoutms
self.twophase = twophase
}
@@ -5281,6 +5284,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
case turnsourceto = "turnSourceTo"
case turnsourceaccountid = "turnSourceAccountId"
case turnsourcethreadid = "turnSourceThreadId"
case alloweddecisions = "allowedDecisions"
case timeoutms = "timeoutMs"
case twophase = "twoPhase"
}
@@ -5554,21 +5558,25 @@ 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?)
maxchars: Int?,
includeblockedoriginalcontent: Bool? = nil)
{
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

@@ -11,6 +11,19 @@ private func chatTextMessage(role: String, text: String, timestamp: Double) -> A
])
}
private func chatBlockedUserMessage(redactedText: String, originalText: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": "user",
"content": [["type": "text", "text": redactedText]],
"__openclaw": [
"originalBlockedContent": [
"content": [["type": "text", "text": originalText]],
],
],
"timestamp": timestamp,
])
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
@@ -587,6 +600,48 @@ extension TestChatTransportState {
}
}
@Test func doesNotDuplicateUserMessageWhenRefreshReturnsBlockedCanonicalMessage() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000
let history1 = historyPayload(sessionId: sessionId)
let history2 = historyPayload(
sessionId: sessionId,
messages: [
chatBlockedUserMessage(
redactedText: "The agent cannot read this message.",
originalText: "hello from mac webchat",
timestamp: now + 5_000),
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: now + 6_000),
])
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "hello from mac webchat")
try await waitUntil("blocked canonical refresh keeps one visible user message") {
await MainActor.run {
let userMessages = vm.messages.filter { message in
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
message.role == "assistant" &&
message.content.compactMap(\.text).joined(separator: "\n") == "final answer"
}
return hasAssistant && userMessages.count == 1
}
}
}
@Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000

View File

@@ -1,4 +1,4 @@
5dd302a20b8a6347425617323d0ad7875f9b7631acd3ed3935cfaaf7708a32dd config-baseline.json
d192d678668712b81cc2e76ddcb6420893ab5144944ccb830b290019d6a717a4 config-baseline.core.json
e9f4dc24f705bdd2091f7f6a71b35364137f1ce0d594c7b8f62a275d8e5e764a config-baseline.json
e5e03ecca52aa5ae6735c057bc4740cd05ca79c92134affc270a5bf402c79cb2 config-baseline.core.json
cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json
6871e789b74722e4ff2c877940dac256c232433ae26b305fc6ca782b90662097 config-baseline.plugin.json
0a89dd69bbf969b93acef6b88aad7085137947390a1815ddfb63ebea9af320ed config-baseline.plugin.json

View File

@@ -275,7 +275,7 @@ For runtime hook debugging:
- `openclaw plugins inspect <id> --runtime --json` shows registered hooks and diagnostics from a module-loaded inspection pass. Runtime inspection never installs dependencies; use `openclaw doctor --fix` to clean legacy dependency state or recover missing downloadable plugins that are referenced by config.
- `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health.
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
- Non-bundled conversation hooks (`llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, `agent_end`) require `plugins.entries.<id>.hooks.allowConversationAccess=true`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):

View File

@@ -195,7 +195,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`.
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_model_resolve`, `before_agent_reply`, `before_agent_run`, `before_agent_finalize`, and `agent_end`.
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).

View File

@@ -104,6 +104,7 @@ observation-only.
- `agent_turn_prepare` - consume queued plugin turn injections and add same-turn context before prompt hooks
- `before_prompt_build` - add dynamic context or system-prompt text before the model call
- `before_agent_start` - compatibility-only combined phase; prefer the two hooks above
- **`before_agent_run`** - inspect the final prompt and session messages before model submission and optionally block the run
- **`before_agent_reply`** - short-circuit the model turn with a synthetic reply or silence
- **`before_agent_finalize`** - inspect the natural final answer and request one more model pass
- `agent_end` - observe final messages, success state, and run duration
@@ -232,6 +233,19 @@ Use the phase-specific hooks for new plugins:
`before_agent_start` remains for compatibility. Prefer the explicit hooks above
so your plugin does not depend on a legacy combined phase.
`before_agent_run` runs after prompt construction and before any model input,
including prompt-local image loading and `llm_input` observation. It receives
the current user input as `prompt`, plus loaded session history in `messages`
and the active system prompt. Return `{ outcome: "block", reason, message? }`
to stop the run before the model can read the prompt. `reason` is internal;
`message` is the user-facing replacement. The only supported outcomes are
`pass` and `block`; unsupported decision shapes fail closed.
When a run is blocked, OpenClaw stores only the replacement in model-visible
`message.content`. The human's original text is kept in blocked-content
metadata for authorized admin or transcript-secret history viewers so clients can show what
the user typed with an "agent cannot read" notice.
`before_agent_start` and `agent_end` include `event.runId` when OpenClaw can
identify the active run. The same value is also available on `ctx.runId`.
Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
@@ -280,8 +294,9 @@ type BeforeAgentFinalizeRetry = {
equivalent finalize decisions, and `maxAttempts` caps how many extra passes the
host will allow before continuing with the natural final answer.
Non-bundled plugins that need `llm_input`, `llm_output`,
`before_agent_finalize`, or `agent_end` must set:
Non-bundled plugins that need raw conversation hooks (`before_model_resolve`,
`before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`,
`agent_end`, or `before_agent_run`) must set:
```json
{

View File

@@ -33,6 +33,11 @@ const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by
.map((c) => ` case ${camelCase(c)} = "${c}"`)
.join("\n")}\n}\n`;
const OPTIONAL_INIT_DEFAULTS = new Map<string, Set<string>>([
["ChatHistoryParams", new Set(["includeblockedoriginalcontent"])],
["PluginApprovalRequestParams", new Set(["alloweddecisions"])],
]);
const reserved = new Set([
"associatedtype",
"class",
@@ -190,7 +195,9 @@ function emitStruct(name: string, schema: JsonSchema): string {
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`;
const defaultValue =
!req && OPTIONAL_INIT_DEFAULTS.get(name)?.has(propName) ? " = nil" : "";
return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}${defaultValue}`;
})
.join(",\n") +
")\n" +

View File

@@ -58,7 +58,19 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-hooks-"));
vi.stubEnv("OPENCLAW_STATE_DIR", dir);
const sessionFile = path.join(dir, "agents", "main", "sessions", "s1.jsonl");
const storePath = path.join(path.dirname(sessionFile), "sessions.json");
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "s1",
sessionFile,
updatedAt: Date.now(),
},
}),
"utf-8",
);
fs.writeFileSync(
sessionFile,
`${JSON.stringify({
@@ -87,7 +99,7 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
"utf-8",
);
}
return { dir, sessionFile };
return { dir, sessionFile, storePath };
}
function buildPreparedContext(params?: {
@@ -620,6 +632,89 @@ describe("runCliAgent reliability", () => {
}
});
it("blocks CLI runs before llm_input and model execution when before_agent_run blocks", async () => {
supervisorSpawnMock.mockClear();
const hookRunner = {
hasHooks: vi.fn((hookName: string) =>
["before_agent_run", "llm_input", "agent_end"].includes(hookName),
),
runBeforeAgentRun: vi.fn(async () => ({
pluginId: "policy-plugin",
decision: {
outcome: "block" as const,
reason: "contains protected content",
message: "The agent cannot read this message.",
},
})),
runLlmInput: vi.fn(async () => undefined),
runAgentEnd: vi.fn(async () => undefined),
};
setHookRunnerForTest(hookRunner);
const { dir, sessionFile } = createSessionFile({
history: [{ role: "user", content: "earlier context" }],
});
try {
const result = await runPreparedCliAgent({
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" }),
params: {
...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-blocked-cli" })
.params,
agentId: "main",
sessionFile,
workspaceDir: dir,
prompt: "secret prompt",
},
});
expect(result.payloads).toEqual([
{ text: "The agent cannot read this message.", isError: true },
]);
expect(result.meta.livenessState).toBe("blocked");
expect(supervisorSpawnMock).not.toHaveBeenCalled();
expect(hookRunner.runLlmInput).not.toHaveBeenCalled();
expect(hookRunner.runBeforeAgentRun).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "secret prompt",
messages: expect.arrayContaining([
expect.objectContaining({ role: "user", content: "earlier context" }),
]),
}),
expect.objectContaining({
runId: "run-blocked-cli",
agentId: "main",
sessionKey: "agent:main:main",
}),
);
await vi.waitFor(() => {
expect(hookRunner.runAgentEnd).toHaveBeenCalledTimes(1);
});
expect(hookRunner.runAgentEnd).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: "The agent cannot read this message.",
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: "The agent cannot read this message.",
}),
]),
}),
expect.any(Object),
);
expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt");
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",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("does not emit llm_output when the CLI run returns no assistant text", async () => {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),

View File

@@ -1,11 +1,15 @@
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import { buildAgentHookContext } from "./harness/hook-context.js";
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
import {
runAgentHarnessAgentEndHook,
@@ -15,6 +19,12 @@ import {
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
const log = createSubsystemLogger("agents/cli-runner");
function flushSessionManagerFile(sessionManager: SessionManager): void {
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
}
function buildHandledReplyPayloads(reply?: ReplyPayload) {
const normalized = reply ?? { text: SILENT_REPLY_TOKEN };
return [
@@ -127,8 +137,9 @@ export async function runPreparedCliAgent(
const hasLlmInputHooks = hookRunner?.hasHooks("llm_input") === true;
const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;
const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;
const hasBeforeAgentRunHooks = hookRunner?.hasHooks("before_agent_run") === true;
const historyMessages =
hasLlmInputHooks || hasAgentEndHooks
hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks
? await loadCliSessionHistoryMessages({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
@@ -175,6 +186,92 @@ export async function runPreparedCliAgent(
durationMs: Date.now() - context.started,
});
const buildBlockedAgentEndEvent = (message: string) => ({
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [buildCliHookUserMessage(message)],
}),
success: false,
error: message,
durationMs: Date.now() - context.started,
});
const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedPiRunResult => ({
payloads: [{ text: message, isError: true }],
meta: {
durationMs: Date.now() - context.started,
finalAssistantVisibleText: message,
finalAssistantRawText: message,
livenessState: "blocked",
error: {
kind: "hook_block",
message,
},
systemPromptReport: context.systemPromptReport,
executionTrace: {
winnerProvider: params.provider,
winnerModel: context.modelId,
attempts: [
{
provider: params.provider,
model: context.modelId,
result: "error",
reason: "before_agent_run blocked the run",
},
],
fallbackUsed: false,
runner: "cli",
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "blocked",
stopReason: "blocked",
refusal: true,
},
agentMeta: {
sessionId: params.sessionId ?? "",
provider: params.provider,
model: context.modelId,
},
},
});
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
reason: string;
}): Promise<void> => {
try {
const nowMs = Date.now();
const originalText = params.transcriptPrompt ?? params.prompt;
const sessionManager = SessionManager.open(params.sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: block.message }],
timestamp: nowMs,
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
__openclaw: {
originalBlockedContent: {
content: originalText ? [{ type: "text", text: originalText }] : [],
blockedBy: block.pluginId,
reason: block.reason,
blockedAt: nowMs,
},
},
} as Parameters<typeof sessionManager.appendMessage>[0]);
flushSessionManagerFile(sessionManager);
} catch (err) {
log.warn(
`before_agent_run block: failed to persist redacted CLI user message: ${formatErrorMessage(
err,
)}`,
);
}
};
const toCliRunFailure = (error: unknown): never => {
if (isFailoverError(error)) {
throw error;
@@ -304,6 +401,57 @@ export async function runPreparedCliAgent(
// Try with the provided CLI session ID first
try {
if (hasBeforeAgentRunHooks && hookRunner) {
let beforeRunResult:
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
| undefined;
try {
beforeRunResult = await hookRunner.runBeforeAgentRun(
{
prompt: params.prompt,
systemPrompt: context.systemPrompt,
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [],
}),
channelId: hookContext.channelId,
accountId: params.agentAccountId,
senderIsOwner: params.senderIsOwner,
},
buildAgentHookContext(hookContext),
);
} catch (err) {
const blockMessage = "Request blocked by before_agent_run policy.";
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: "before_agent_run",
reason: `before_agent_run hook failed closed: ${formatErrorMessage(err)}`,
});
runAgentHarnessAgentEndHook({
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
const beforeRunDecision = beforeRunResult?.decision;
if (beforeRunDecision?.outcome === "block") {
const blockMessage = resolveBlockMessage(beforeRunDecision);
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: beforeRunResult?.pluginId ?? "unknown",
reason: beforeRunDecision.reason,
});
runAgentHarnessAgentEndHook({
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
}
runAgentHarnessLlmInputHook({
event: llmInputEvent,
ctx: hookContext,

View File

@@ -28,6 +28,26 @@ 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();
@@ -179,7 +199,7 @@ export async function loadCliSessionHistoryMessages(params: {
}): Promise<unknown[]> {
const history = (await loadCliSessionEntries(params)).flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [candidate.message] : [];
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
});
return limitAgentHookHistoryMessages(history, MAX_CLI_SESSION_HISTORY_MESSAGES);
}
@@ -208,7 +228,7 @@ export async function loadCliSessionReseedMessages(params: {
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [candidate.message] : [];
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
});
return [
{

View File

@@ -49,6 +49,34 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
it("surfaces before_agent_run hook block messages instead of generic prompt failure text", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
promptError: new Error("Blocked by before-run policy."),
promptErrorSource: "hook:before_agent_run",
}),
);
const result = await runEmbeddedPiAgent({
...overflowBaseRunParams,
runId: "run-before-agent-run-hook-block",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.payloads).toEqual([
{
text: "Blocked by before-run policy.",
isError: true,
},
]);
expect(result.meta?.error).toEqual({
kind: "hook_block",
message: "Blocked by before-run policy.",
});
expect(result.meta?.livenessState).toBe("blocked");
});
it("warns before retrying when an incomplete turn already sent a message", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(

View File

@@ -1826,6 +1826,41 @@ export async function runEmbeddedPiAgent(
};
}
if (promptErrorSource === "hook:before_agent_run" && !aborted) {
const errorText = formatErrorMessage(promptError);
const replayInvalid = resolveReplayInvalidForAttempt();
attempt.setTerminalLifecycleMeta?.({
replayInvalid,
livenessState: "blocked",
});
return {
payloads: [
{
text: errorText,
isError: true,
},
],
meta: {
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
usageAccumulator,
lastRunPromptUsage,
lastAssistant: sessionLastAssistant,
lastTurnTotal,
}),
systemPromptReport: attempt.systemPromptReport,
finalPromptText: attempt.finalPromptText,
replayInvalid,
livenessState: "blocked",
error: { kind: "hook_block", message: errorText },
},
};
}
if (promptError && !aborted && promptErrorSource !== "compaction") {
// Normalize wrapped errors (e.g. abort-wrapped RESOURCE_EXHAUSTED) into
// FailoverError so rate-limit classification works even for nested shapes.

View File

@@ -20,6 +20,7 @@ import {
resolveAttemptFsWorkspaceOnly,
resolveEmbeddedAgentStreamFn,
resolveUnknownToolGuardThreshold,
shouldRunLlmOutputHooksForAttempt,
resolveAttemptToolPolicyMessageProvider,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
@@ -149,6 +150,35 @@ describe("normalizeMessagesForLlmBoundary", () => {
expect.arrayContaining([expect.objectContaining({ customType: "other-extension-context" })]),
);
});
it("strips blocked original content metadata from 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" }],
blockedBy: "policy-plugin",
reason: "contains protected content",
blockedAt: 1,
},
},
},
];
const output = normalizeMessagesForLlmBoundary(
input as Parameters<typeof normalizeMessagesForLlmBoundary>[0],
) as Array<Record<string, unknown>>;
expect(output[0]?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(output[0]).not.toHaveProperty("__openclaw");
expect(JSON.stringify(output)).not.toContain("secret prompt");
expect(input[0]).toHaveProperty("__openclaw");
});
});
describe("resolveAttemptToolPolicyMessageProvider", () => {
@@ -166,6 +196,16 @@ describe("resolveAttemptToolPolicyMessageProvider", () => {
});
});
describe("shouldRunLlmOutputHooksForAttempt", () => {
it("skips llm_output after before_agent_run blocks before model submission", () => {
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "hook:before_agent_run" })).toBe(
false,
);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "prompt" })).toBe(true);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: null })).toBe(true);
});
});
describe("resolvePromptBuildHookResult", () => {
function createLegacyOnlyHookRunner() {
return {

View File

@@ -25,6 +25,7 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js";
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import {
extractModelCompat,
@@ -335,6 +336,7 @@ import {
} from "./preemptive-compaction.js";
import {
buildCurrentTurnPromptContextSuffix,
buildRuntimeContextSystemContext,
queueRuntimeContextForNextTurn,
resolveRuntimeContextPromptParts,
} from "./runtime-context-prompt.js";
@@ -490,7 +492,56 @@ function summarizeSessionContext(messages: AgentMessage[]): {
export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] {
const normalized = stripToolResultDetails(normalizeAssistantReplayContent(messages));
return stripHistoricalRuntimeContextCustomMessages(normalized);
return stripBlockedOriginalContentFromMessages(
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,
): boolean {
return messages.some(
(message) =>
typeof (message as { idempotencyKey?: unknown }).idempotencyKey === "string" &&
(message as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey,
);
}
function flushSessionManagerFile(sessionManager: ReturnType<typeof guardSessionManager>): void {
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
}
export function shouldRunLlmOutputHooksForAttempt(params: { promptErrorSource: string | null }) {
return params.promptErrorSource !== "hook:before_agent_run";
}
function isMidTurnPrecheckAssistantError(message: AgentMessage | undefined): boolean {
@@ -2496,7 +2547,7 @@ export async function runEmbeddedAttempt(
const activeSessionManager = sessionManager;
let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"];
let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null;
let promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
const handleMidTurnPrecheckRequest = (request: MidTurnPrecheckRequest) => {
const logMidTurnPrecheck = (route: string, extra?: string) => {
log.warn(
@@ -2659,25 +2710,6 @@ export async function runEmbeddedAttempt(
});
}
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
apiKey: await resolveEmbeddedAgentApiKey({
provider: params.provider,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
}),
extraParams: effectiveExtraParams,
model: params.model,
modelId: params.modelId,
provider: params.provider,
sessionManager,
signal: runAbortController.signal,
streamFn: activeSession.agent.streamFn,
systemPrompt: systemPromptText,
});
if (googlePromptCacheStreamFn) {
activeSession.agent.streamFn = googlePromptCacheStreamFn;
}
const routingSummary = describeProviderRequestRoutingSummary({
provider: params.provider,
api: params.model.api,
@@ -2689,11 +2721,6 @@ export async function runEmbeddedAttempt(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId} ` +
routingSummary,
);
cacheTrace?.recordStage("prompt:before", {
prompt: effectivePrompt,
messages: activeSession.messages,
});
// Repair orphaned trailing user messages so new prompts don't violate role ordering.
const leafEntry = isRawModelRun ? null : sessionManager.getLeafEntry();
if (leafEntry?.type === "message" && leafEntry.message.role === "user") {
@@ -2765,6 +2792,11 @@ export async function runEmbeddedAttempt(
? ""
: buildCurrentTurnPromptContextSuffix(params.currentTurnContext);
const promptForModel = promptSubmission.prompt + currentTurnPromptContextSuffix;
const blockedTranscriptPrompt =
effectiveTranscriptPrompt ??
(isRawModelRun
? params.prompt
: annotateInterSessionPromptText(params.prompt, params.inputProvenance));
const runtimeSystemContext = promptSubmission.runtimeSystemContext?.trim();
if (promptSubmission.runtimeOnly && runtimeSystemContext) {
const runtimeSystemPrompt = composeSystemPromptWithHookContext({
@@ -2776,40 +2808,179 @@ export async function runEmbeddedAttempt(
systemPromptText = runtimeSystemPrompt;
}
}
const runtimeContextForModel = promptSubmission.runtimeOnly
? undefined
: promptSubmission.runtimeContext?.trim();
const runtimeSystemPromptForModel = runtimeContextForModel
? composeSystemPromptWithHookContext({
baseSystemPrompt: systemPromptText,
appendSystemContext: buildRuntimeContextSystemContext(runtimeContextForModel),
})
: undefined;
const systemPromptForModel = runtimeSystemPromptForModel ?? systemPromptText;
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
reason: string;
}): Promise<boolean> => {
const idempotencyKey = `hook-block:before_agent_run:user:${params.runId}`;
if (sessionMessagesContainIdempotencyKey(activeSession.messages, idempotencyKey)) {
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,
blockedBy: block.pluginId,
reason: block.reason,
blockedAt: nowMs,
},
},
};
try {
activeSessionManager.appendMessage(
redactedUserMessage as Parameters<typeof activeSessionManager.appendMessage>[0],
);
flushSessionManagerFile(activeSessionManager);
activeSession.agent.state.messages =
activeSessionManager.buildSessionContext().messages;
return true;
} catch (err) {
log.warn(
`before_agent_run block: failed to persist redacted user message: ${
(err as Error)?.message ?? String(err)
}`,
);
return false;
}
};
if (hookRunner?.hasHooks("before_agent_run")) {
const beforeRunMessages = cloneHookMessages(
normalizeMessagesForLlmBoundary(activeSession.messages),
);
let beforeRunResult:
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
| undefined;
try {
beforeRunResult = await hookRunner.runBeforeAgentRun(
{
prompt: promptForModel,
systemPrompt: systemPromptForModel,
messages: beforeRunMessages,
channelId: hookCtx.channelId,
accountId: params.agentAccountId ?? undefined,
senderId: params.senderId ?? undefined,
senderIsOwner: params.senderIsOwner ?? undefined,
},
hookCtx,
);
} catch (err) {
log.warn(`before_agent_run hook failed: ${formatErrorMessage(err)}`);
await persistBlockedBeforeAgentRun({
message: "Request blocked by before_agent_run policy.",
pluginId: "before_agent_run",
reason: "before_agent_run hook failed closed",
});
promptError = new Error("Request blocked by before_agent_run policy.");
promptErrorSource = "hook:before_agent_run";
skipPromptSubmission = true;
}
const beforeRunDecision = beforeRunResult?.decision;
const beforeRunPluginId = beforeRunResult?.pluginId ?? "unknown";
if (beforeRunDecision?.outcome === "block") {
const blockReplacementMsg = resolveBlockMessage(beforeRunDecision);
log.warn(
`before_agent_run hook blocked by ${beforeRunPluginId}: ${beforeRunDecision.reason}`,
);
await persistBlockedBeforeAgentRun({
message: blockReplacementMsg,
pluginId: beforeRunPluginId,
reason: beforeRunDecision.reason,
});
promptError = new Error(blockReplacementMsg);
promptErrorSource = "hook:before_agent_run";
skipPromptSubmission = true;
}
}
if (!skipPromptSubmission) {
const googlePromptCacheStreamFn = await prepareGooglePromptCacheStreamFn({
apiKey: await resolveEmbeddedAgentApiKey({
provider: params.provider,
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
}),
extraParams: effectiveExtraParams,
model: params.model,
modelId: params.modelId,
provider: params.provider,
sessionManager,
signal: runAbortController.signal,
streamFn: activeSession.agent.streamFn,
systemPrompt: systemPromptText,
});
if (googlePromptCacheStreamFn) {
activeSession.agent.streamFn = googlePromptCacheStreamFn;
}
}
// Detect and load images referenced in the visible prompt for vision-capable models.
// Images are prompt-local only (pi-like behavior).
const imageResult = await detectAndLoadPromptImages({
prompt: promptSubmission.prompt,
workspaceDir: effectiveWorkspace,
model: params.model,
existingImages: params.images,
imageOrder: params.imageOrder,
maxBytes: MAX_IMAGE_BYTES,
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
workspaceOnly: effectiveFsWorkspaceOnly,
// Enforce sandbox path restrictions when sandbox is enabled
sandbox:
sandbox?.enabled && sandbox?.fsBridge
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
: undefined,
});
const imageResult = skipPromptSubmission
? {
images: [],
detectedRefs: [],
loadedCount: 0,
skippedCount: 0,
}
: await detectAndLoadPromptImages({
prompt: promptSubmission.prompt,
workspaceDir: effectiveWorkspace,
model: params.model,
existingImages: params.images,
imageOrder: params.imageOrder,
maxBytes: MAX_IMAGE_BYTES,
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
workspaceOnly: effectiveFsWorkspaceOnly,
// Enforce sandbox path restrictions when sandbox is enabled
sandbox:
sandbox?.enabled && sandbox?.fsBridge
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
: undefined,
});
cacheTrace?.recordStage("prompt:images", {
prompt: promptForModel,
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length}`,
});
trajectoryRecorder?.recordEvent("context.compiled", {
systemPrompt: systemPromptText,
prompt: promptForModel,
messages: activeSession.messages,
tools: toTrajectoryToolDefinitions(effectiveTools),
imagesCount: imageResult.images.length,
streamStrategy,
transport: effectiveAgentTransport,
transcriptLeafId,
});
if (!skipPromptSubmission) {
cacheTrace?.recordStage("prompt:before", {
prompt: promptForModel,
messages: activeSession.messages,
});
cacheTrace?.recordStage("prompt:images", {
prompt: promptForModel,
messages: activeSession.messages,
note: `images: prompt=${imageResult.images.length}`,
});
trajectoryRecorder?.recordEvent("context.compiled", {
systemPrompt: systemPromptForModel,
prompt: promptForModel,
messages: activeSession.messages,
tools: toTrajectoryToolDefinitions(effectiveTools),
imagesCount: imageResult.images.length,
streamStrategy,
transport: effectiveAgentTransport,
transcriptLeafId,
});
}
const promptSkipReason = skipPromptSubmission
? null
@@ -2838,7 +3009,7 @@ export async function runEmbeddedAttempt(
}
const msgCount = activeSession.messages.length;
const systemLen = systemPromptText?.length ?? 0;
const systemLen = systemPromptForModel?.length ?? 0;
const promptLen = effectivePrompt.length;
const sessionSummary = summarizeSessionContext(activeSession.messages);
const reserveTokens = settingsManager.getCompactionReserveTokens();
@@ -2880,7 +3051,7 @@ export async function runEmbeddedAttempt(
);
}
if (!isRawModelRun && hookRunner?.hasHooks("llm_input")) {
if (!skipPromptSubmission && !isRawModelRun && hookRunner?.hasHooks("llm_input")) {
hookRunner
.runLlmInput(
{
@@ -2888,9 +3059,11 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
systemPrompt: systemPromptText,
prompt: effectivePrompt,
historyMessages: activeSession.messages,
systemPrompt: systemPromptForModel,
prompt: promptForModel,
historyMessages: cloneHookMessages(
normalizeMessagesForLlmBoundary(activeSession.messages),
),
imagesCount: imageResult.images.length,
},
{
@@ -2909,22 +3082,24 @@ export async function runEmbeddedAttempt(
});
}
const preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
messages: activeSession.messages,
...(contextEnginePromptAuthority === "preassembly_may_overflow"
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
: {}),
systemPrompt: systemPromptText,
prompt: effectivePrompt,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
});
if (preemptiveCompaction.route === "truncate_tool_results_only") {
const preemptiveCompaction = skipPromptSubmission
? null
: shouldPreemptivelyCompactBeforePrompt({
messages: activeSession.messages,
...(contextEnginePromptAuthority === "preassembly_may_overflow"
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
: {}),
systemPrompt: systemPromptForModel,
prompt: promptForModel,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
});
if (preemptiveCompaction?.route === "truncate_tool_results_only") {
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
@@ -2969,7 +3144,7 @@ export async function runEmbeddedAttempt(
skipPromptSubmission = true;
}
}
if (preemptiveCompaction.shouldCompact) {
if (preemptiveCompaction?.shouldCompact) {
preflightRecovery =
preemptiveCompaction.route === "compact_then_truncate"
? { route: "compact_then_truncate" }
@@ -3001,7 +3176,7 @@ export async function runEmbeddedAttempt(
finalPromptText = promptForModel;
trajectoryRecorder?.recordEvent("prompt.submitted", {
prompt: promptForModel,
systemPrompt: systemPromptText,
systemPrompt: systemPromptForModel,
messages: activeSession.messages,
imagesCount: imageResult.images.length,
});
@@ -3014,20 +3189,28 @@ export async function runEmbeddedAttempt(
if (promptSubmission.runtimeOnly) {
await abortable(activeSession.prompt(promptForModel));
} else {
const runtimeContext = promptSubmission.runtimeContext?.trim();
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext,
});
if (runtimeSystemPromptForModel) {
applySystemPromptOverrideToSession(activeSession, runtimeSystemPromptForModel);
}
try {
await queueRuntimeContextForNextTurn({
session: activeSession,
runtimeContext: runtimeContextForModel,
});
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
await abortable(
activeSession.prompt(promptForModel, { images: imageResult.images }),
);
} else {
await abortable(activeSession.prompt(promptForModel));
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
await abortable(
activeSession.prompt(promptForModel, { images: imageResult.images }),
);
} else {
await abortable(activeSession.prompt(promptForModel));
}
} finally {
if (runtimeSystemPromptForModel) {
applySystemPromptOverrideToSession(activeSession, systemPromptText);
}
}
}
}
@@ -3169,7 +3352,9 @@ export async function runEmbeddedAttempt(
);
}
}
messagesSnapshot = snapshotSelection.messagesSnapshot;
messagesSnapshot = stripBlockedOriginalContentFromMessages(
snapshotSelection.messagesSnapshot,
);
sessionIdUsed = snapshotSelection.sessionIdUsed;
lastAssistant = messagesSnapshot
@@ -3435,7 +3620,10 @@ export async function runEmbeddedAttempt(
}
}
if (hookRunner?.hasHooks("llm_output")) {
if (
hookRunner?.hasHooks("llm_output") &&
shouldRunLlmOutputHooksForAttempt({ promptErrorSource })
) {
hookRunner
.runLlmOutput(
{

View File

@@ -71,9 +71,10 @@ export type EmbeddedRunAttemptResult = {
* this must not be retried as a fresh prompt or the same tool turn can replay.
* - "precheck": pre-prompt overflow recovery intentionally short-circuited the prompt so the
* outer run loop can recover via compaction/truncation before any model call is made.
* - "hook:before_agent_run": a lifecycle hook blocked the run before the prompt was sent.
* - null: no promptError.
*/
promptErrorSource: "prompt" | "compaction" | "precheck" | null;
promptErrorSource: "prompt" | "compaction" | "precheck" | "hook:before_agent_run" | null;
preflightRecovery?:
| {
route: Exclude<PreemptiveCompactionRoute, "fits">;

View File

@@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = {
| "compaction_failure"
| "role_ordering"
| "image_size"
| "retry_limit";
| "retry_limit"
| "hook_block";
message: string;
};
failureSignal?: EmbeddedRunFailureSignal;

View File

@@ -960,6 +960,33 @@ describe("before_tool_call requireApproval handling", () => {
expect(onResolution).toHaveBeenCalledWith("allow-once");
});
it("allows allow-always decisions for tool approvals", async () => {
const onResolution = vi.fn();
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
title: "Needs durable approval",
description: "Check this durable approval",
onResolution,
},
});
mockCallGateway.mockResolvedValueOnce({ id: "server-id-allow-always", status: "accepted" });
mockCallGateway.mockResolvedValueOnce({
id: "server-id-allow-always",
decision: "allow-always",
});
const result = await runBeforeToolCallHook({
toolName: "bash",
params: { command: "echo ok" },
ctx: { agentId: "main", sessionKey: "main" },
});
expect(result).toEqual({ blocked: false, params: { command: "echo ok" } });
expect(onResolution).toHaveBeenCalledWith("allow-always");
});
it("does not await onResolution before returning approval outcome", async () => {
const onResolution = vi.fn(() => new Promise<void>(() => {}));

View File

@@ -39,6 +39,16 @@ export type ToolOutcomeObservation = {
export type ToolOutcomeObserver = (observation: ToolOutcomeObservation) => void;
export function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
if (!signal?.aborted) {
return false;
}
if (err === signal.reason) {
return true;
}
return err instanceof Error && err.name === "AbortError";
}
export type HookContext = {
agentId?: string;
config?: OpenClawConfig;
@@ -47,6 +57,7 @@ export type HookContext = {
sessionId?: string;
runId?: string;
trace?: DiagnosticTraceContext;
channelId?: string;
loopDetection?: ToolLoopDetectionConfig;
onToolOutcome?: ToolOutcomeObserver;
};
@@ -114,19 +125,6 @@ function mergeParamsWithApprovalOverrides(
return originalParams;
}
function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
if (!signal?.aborted) {
return false;
}
if (err === signal.reason) {
return true;
}
if (err instanceof Error && err.name === "AbortError") {
return true;
}
return false;
}
function unwrapErrorCause(err: unknown): unknown {
try {
if (!(err instanceof Error)) {
@@ -180,6 +178,7 @@ async function requestPluginToolApproval(params: {
title: approval.title,
description: approval.description,
severity: approval.severity,
allowedDecisions: approval.allowedDecisions,
toolName: params.toolName,
toolCallId: params.toolCallId,
agentId: params.ctx?.agentId,
@@ -504,6 +503,7 @@ export async function runBeforeToolCallHook(args: {
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
};
const trustedPolicyResult = await runTrustedToolPolicies(
{

View File

@@ -7,6 +7,7 @@ import {
type OperatorScope,
} from "../../gateway/method-scopes.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../gateway/protocol/client-info.js";
import type { DeviceIdentity } from "../../infra/device-identity.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -20,6 +21,7 @@ export type GatewayCallOptions = {
gatewayUrl?: string;
gatewayToken?: string;
timeoutMs?: number;
deviceIdentity?: DeviceIdentity | null;
};
type GatewayOverrideTarget = "local" | "remote";
@@ -165,6 +167,7 @@ export async function callGatewayTool<T = Record<string, unknown>>(
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
deviceIdentity: opts.deviceIdentity,
scopes,
});
}

View File

@@ -56,6 +56,7 @@ export type ReplyPayloadMetadata = {
* assistant source replies are message-tool-only; sendPolicy deny still wins.
*/
deliverDespiteSourceReplySuppression?: boolean;
beforeAgentRunBlocked?: boolean;
};
const replyPayloadMetadata = new WeakMap<object, ReplyPayloadMetadata>();

View File

@@ -38,7 +38,10 @@ import {
buildFallbackNotice,
resolveFallbackTransition,
} from "../fallback-state.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import {
markReplyPayloadForSourceSuppressionDelivery,
setReplyPayloadMetadata,
} from "../reply-payload.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
@@ -93,6 +96,12 @@ import type { TypingController } from "./typing.js";
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
return payloads.map((payload) =>
setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
);
}
function buildInlinePluginStatusPayload(params: {
entry: SessionEntry | undefined;
includeTraceLines: boolean;
@@ -1838,6 +1847,9 @@ export async function runReplyAgent(params: {
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
if (runResult.meta?.error?.kind === "hook_block") {
finalPayloads = markBeforeAgentRunBlockedPayloads(finalPayloads);
}
// Capture only policy-visible final payloads in session store to support
// durable delivery retries. Hidden reasoning, message-tool-only replies,

View File

@@ -1516,6 +1516,9 @@ export async function dispatchReplyFromConfig(
}
const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
const beforeAgentRunBlocked = replies.some(
(reply) => getReplyPayloadMetadata(reply)?.beforeAgentRunBlocked === true,
);
let queuedFinal = false;
let routedFinalCount = 0;
@@ -1619,7 +1622,11 @@ export async function dispatchReplyFromConfig(
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
);
markIdle("message_completed");
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
return attachSourceReplyDeliveryMode({
queuedFinal,
counts,
...(beforeAgentRunBlocked ? { beforeAgentRunBlocked } : {}),
});
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
if (inboundDedupeReplayUnsafe) {

View File

@@ -10,6 +10,7 @@ export type DispatchFromConfigResult = {
counts: Record<ReplyDispatchKind, number>;
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
beforeAgentRunBlocked?: boolean;
};
export type DispatchFromConfigParams = {

View File

@@ -1242,7 +1242,7 @@ export const FIELD_HELP: Record<string, string> = {
"plugins.entries.*.hooks.allowPromptInjection":
"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"plugins.entries.*.hooks.allowConversationAccess":
"Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
"Controls whether this plugin may read raw conversation content from typed hooks such as `before_agent_run`, `before_model_resolve`, `before_agent_reply`, `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
"plugins.entries.*.hooks.timeoutMs":
"Default timeout in milliseconds for this plugin's typed hooks, capped at 600000. Use this to bound slow plugin hooks without changing plugin code; per-hook values in hooks.timeouts take precedence.",
"plugins.entries.*.hooks.timeouts":

View File

@@ -26,6 +26,10 @@ type TranscriptLeafInfo = {
nonSessionEntryCount: number;
};
export type TranscriptRawAppendParentLink = {
parentId?: string | null;
};
async function yieldTranscriptAppendScan(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
@@ -229,6 +233,32 @@ async function withTranscriptAppendQueue<T>(
}
}
export async function resolveTranscriptRawAppendParentLink(params: {
transcriptPath: string;
useRawWhenLinear?: boolean;
}): Promise<TranscriptRawAppendParentLink> {
const stat = await fs.stat(params.transcriptPath).catch(() => null);
let leafInfo: TranscriptLeafInfo = await readTranscriptLeafInfo(params.transcriptPath).catch(
() => ({
hasParentLinkedEntries: false,
nonSessionEntryCount: 0,
}),
);
const hasLinearEntries = !leafInfo.hasParentLinkedEntries && leafInfo.nonSessionEntryCount > 0;
const allowRawWhenLinear = params.useRawWhenLinear !== false;
const shouldRawAppend =
allowRawWhenLinear && hasLinearEntries && (stat?.size ?? 0) > SESSION_MANAGER_APPEND_MAX_BYTES;
if (hasLinearEntries && !shouldRawAppend) {
const migrated = await migrateLinearTranscriptToParentLinked(params.transcriptPath);
leafInfo = {
...(migrated.leafId ? { leafId: migrated.leafId } : {}),
hasParentLinkedEntries: Boolean(migrated.leafId),
nonSessionEntryCount: leafInfo.nonSessionEntryCount,
};
}
return shouldRawAppend ? {} : { parentId: leafInfo.leafId ?? null };
}
export async function appendSessionTranscriptMessage(params: {
transcriptPath: string;
message: unknown;
@@ -264,31 +294,14 @@ async function appendSessionTranscriptMessageLocked(params: {
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.cwd ? { cwd: params.cwd } : {}),
});
const stat = await fs.stat(params.transcriptPath).catch(() => null);
let leafInfo: TranscriptLeafInfo = await readTranscriptLeafInfo(params.transcriptPath).catch(
() => ({
hasParentLinkedEntries: false,
nonSessionEntryCount: 0,
}),
);
const hasLinearEntries = !leafInfo.hasParentLinkedEntries && leafInfo.nonSessionEntryCount > 0;
const allowRawWhenLinear = params.useRawWhenLinear !== false;
const shouldRawAppend =
allowRawWhenLinear &&
hasLinearEntries &&
(stat?.size ?? 0) > SESSION_MANAGER_APPEND_MAX_BYTES;
if (hasLinearEntries && !shouldRawAppend) {
const migrated = await migrateLinearTranscriptToParentLinked(params.transcriptPath);
leafInfo = {
...(migrated.leafId ? { leafId: migrated.leafId } : {}),
hasParentLinkedEntries: Boolean(migrated.leafId),
nonSessionEntryCount: leafInfo.nonSessionEntryCount,
};
}
const parentLink = await resolveTranscriptRawAppendParentLink({
transcriptPath: params.transcriptPath,
useRawWhenLinear: params.useRawWhenLinear,
});
const entry = {
type: "message",
id: messageId,
...(shouldRawAppend ? {} : { parentId: leafInfo.leafId ?? null }),
...parentLink,
timestamp: new Date(now).toISOString(),
message: params.message,
};

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { SessionWriteLockAcquireTimeoutConfig } from "../../agents/session-write-lock.js";
import { type SessionWriteLockAcquireTimeoutConfig } from "../../agents/session-write-lock.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { extractAssistantVisibleText } from "../../shared/chat-message-content.js";

View File

@@ -4,7 +4,9 @@ export type PluginEntryConfig = {
/** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */
allowPromptInjection?: boolean;
/**
* Controls access to raw conversation content from llm_input/llm_output/agent_end hooks.
* Controls access to raw conversation content from conversation hooks including
* before_agent_run, before_model_resolve, before_agent_reply, llm_input, llm_output,
* before_agent_finalize, and agent_end.
* Non-bundled plugins must opt in explicitly; bundled plugins stay allowed unless disabled.
*/
allowConversationAccess?: boolean;

View File

@@ -347,6 +347,14 @@ 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,6 +28,7 @@ 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

@@ -20,6 +20,9 @@ export const PluginApprovalRequestParamsSchema = Type.Object(
turnSourceTo: Type.Optional(Type.String()),
turnSourceAccountId: Type.Optional(Type.String()),
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
allowedDecisions: Type.Optional(
Type.Array(Type.String({ enum: ["allow-once", "allow-always", "deny"] })),
),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PLUGIN_APPROVAL_TIMEOUT_MS })),
twoPhase: Type.Optional(Type.Boolean()),
},

View File

@@ -47,7 +47,9 @@ const mockState = vi.hoisted(() => ({
};
}>,
dispatchError: null as Error | null,
dispatchErrorAfterAgentRunStart: null as Error | null,
triggerAgentRunStart: false,
onAfterAgentRunStart: null as (() => void) | null,
agentRunId: "run-agent-1",
sessionEntry: {} as Record<string, unknown>,
lastDispatchCtx: undefined as MsgContext | undefined,
@@ -69,6 +71,8 @@ const mockState = vi.hoisted(() => ({
sandboxWorkspace: null as { workspaceDir: string; containerWorkdir?: string } | null,
stageSandboxMediaError: null as Error | null,
stagedRelativePaths: null as string[] | null,
hasBeforeAgentRunHooks: false,
dispatchBlockedByBeforeAgentRun: false,
// `unstagedSources` lets tests simulate partial staging failure: absolute
// source paths listed here are excluded from the returned `staged` map even
// though ctx still carries their rewritten paths. This mirrors how the real
@@ -176,6 +180,10 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
}
if (mockState.triggerAgentRunStart) {
params.replyOptions?.onAgentRunStart?.(mockState.agentRunId);
mockState.onAfterAgentRunStart?.();
}
if (mockState.dispatchErrorAfterAgentRunStart) {
throw mockState.dispatchErrorAfterAgentRunStart;
}
if (mockState.dispatchedReplies.length > 0) {
for (const reply of mockState.dispatchedReplies) {
@@ -194,7 +202,12 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
}
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return { ok: true };
return {
ok: true,
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
...(mockState.dispatchBlockedByBeforeAgentRun ? { beforeAgentRunBlocked: true } : {}),
};
},
),
}));
@@ -212,6 +225,13 @@ vi.mock("../../infra/outbound/session-binding-service.js", async () => {
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({
hasHooks: (hookName: string) =>
hookName === "before_agent_run" && mockState.hasBeforeAgentRunHooks,
}),
}));
vi.mock("../../sessions/transcript-events.js", () => ({
emitSessionTranscriptUpdate: vi.fn(
(update: {
@@ -369,6 +389,27 @@ function createScopedCliClient(
};
}
async function runChatHistory(params: {
client?: unknown;
requestParams?: Record<string, unknown>;
}) {
const respond = vi.fn();
await chatHandlers["chat.history"]({
params: {
sessionKey: "main",
limit: 200,
...params.requestParams,
},
respond: respond as unknown as Parameters<(typeof chatHandlers)["chat.history"]>[0]["respond"],
req: {} as never,
client: (params.client ?? null) as never,
isWebchatConnect: () => false,
context: createChatContext() as GatewayRequestContext,
});
expect(respond).toHaveBeenCalledWith(true, expect.anything());
return respond.mock.calls[0]?.[1] as { messages?: unknown[] };
}
function createChatContext(): Pick<
GatewayRequestContext,
| "broadcast"
@@ -501,8 +542,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.finalPayload = null;
mockState.dispatchedReplies = [];
mockState.dispatchError = null;
mockState.dispatchErrorAfterAgentRunStart = null;
mockState.mainSessionKey = "main";
mockState.triggerAgentRunStart = false;
mockState.onAfterAgentRunStart = null;
mockState.agentRunId = "run-agent-1";
mockState.sessionEntry = {};
mockState.lastDispatchCtx = undefined;
@@ -523,6 +566,139 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
mockState.stagedRelativePaths = null;
mockState.unstagedSources = null;
mockState.deleteMediaBufferCalls = [];
mockState.hasBeforeAgentRunHooks = false;
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 () => {
@@ -2058,6 +2234,155 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(finalBroadcast).toBeUndefined();
});
it("does not emit pre-gate user transcript content when before_agent_run hooks are registered", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-before-run-gate-");
mockState.finalText = "ok";
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
let userUpdateCountAtAgentStart = 0;
mockState.onAfterAgentRunStart = () => {
userUpdateCountAtAgentStart = mockState.emittedTranscriptUpdates.filter(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
).length;
};
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-before-run-gate",
message: "secret prompt that may be blocked",
expectBroadcast: false,
});
expect(userUpdateCountAtAgentStart).toBe(0);
const userUpdate = mockState.emittedTranscriptUpdates.find(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdate).toMatchObject({
sessionFile: expect.stringMatching(/sess\.jsonl$/),
sessionKey: "main",
message: {
role: "user",
content: "secret prompt that may be blocked",
timestamp: expect.any(Number),
},
});
});
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.";
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
mockState.dispatchBlockedByBeforeAgentRun = true;
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-blocked-live-signal",
message: "secret prompt blocked before persistence",
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("emits raw user transcript content when before_agent_run passes but the agent fails", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-gate-pass-error-");
mockState.triggerAgentRunStart = true;
mockState.hasBeforeAgentRunHooks = true;
mockState.dispatchErrorAfterAgentRunStart = new Error("model unavailable");
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-user-transcript-gate-pass-error",
message: "prompt allowed before model error",
expectBroadcast: false,
});
await waitForAssertion(() => {
const userUpdate = mockState.emittedTranscriptUpdates.find(
(update) =>
typeof update.message === "object" &&
update.message !== null &&
(update.message as { role?: unknown }).role === "user",
);
expect(userUpdate).toMatchObject({
sessionFile: expect.stringMatching(/sess\.jsonl$/),
sessionKey: "main",
message: {
role: "user",
content: "prompt allowed before model error",
timestamp: expect.any(Number),
},
});
});
});
it("adds persisted media paths to the user transcript update", async () => {
createTranscriptFixture("openclaw-chat-send-user-transcript-images-");
mockState.finalText = "ok";

View File

@@ -35,6 +35,7 @@ import {
} from "../../media/store.js";
import { createChannelMessageReplyPipeline } from "../../plugin-sdk/channel-message.js";
import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
@@ -78,7 +79,7 @@ import {
cleanupManagedOutgoingImageRecords,
createManagedOutgoingImageBlocks,
} from "../managed-image-attachments.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../method-scopes.js";
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_MODES,
@@ -760,6 +761,11 @@ 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[];
@@ -1713,7 +1719,7 @@ function broadcastChatError(params: {
}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async ({ params, respond, context }) => {
"chat.history": async ({ params, respond, context, client }) => {
if (!validateChatHistoryParams(params)) {
respond(
false,
@@ -1725,10 +1731,11 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
const { sessionKey, limit, maxChars } = params as {
const { sessionKey, limit, maxChars, includeBlockedOriginalContent } = params as {
sessionKey: string;
limit?: number;
maxChars?: number;
includeBlockedOriginalContent?: boolean;
};
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
@@ -1744,6 +1751,8 @@ 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({
@@ -2258,6 +2267,30 @@ export const chatHandlers: GatewayRequestHandlers = {
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
let appendedWebchatAgentMedia = false;
let userTranscriptUpdatePromise: Promise<void> | null = null;
let agentRunStarted = false;
let beforeAgentRunBlocked = false;
const hasBeforeAgentRunGate = getGlobalHookRunner()?.hasHooks("before_agent_run") === true;
const beforeAgentRunBlockIdempotencyKey = `hook-block:before_agent_run:user:${clientRunId}`;
const hasPersistedBeforeAgentRunBlock = async () => {
if (!hasBeforeAgentRunGate) {
return false;
}
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey);
const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId;
if (!resolvedSessionId) {
return false;
}
const transcriptPath = resolveTranscriptPath({
sessionId: resolvedSessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile ?? entry?.sessionFile,
agentId,
});
if (!transcriptPath) {
return false;
}
return await transcriptHasIdempotencyKey(transcriptPath, beforeAgentRunBlockIdempotencyKey);
};
const emitUserTranscriptUpdate = async () => {
if (userTranscriptUpdatePromise) {
await userTranscriptUpdatePromise;
@@ -2291,6 +2324,12 @@ export const chatHandlers: GatewayRequestHandlers = {
})();
await userTranscriptUpdatePromise;
};
const emitUserTranscriptUpdateUnlessBeforeAgentRunBlocked = async () => {
if (beforeAgentRunBlocked || (await hasPersistedBeforeAgentRunBlock())) {
return;
}
await emitUserTranscriptUpdate();
};
let transcriptMediaRewriteDone = false;
const rewriteUserTranscriptMedia = async () => {
if (transcriptMediaRewriteDone) {
@@ -2432,16 +2471,6 @@ export const chatHandlers: GatewayRequestHandlers = {
},
});
// Surface accepted inbound turns immediately so transcript subscribers
// (gateway watchers, MCP bridges, external channel backends) do not wait
// on model startup, completion, or failure paths before seeing the user turn.
void emitUserTranscriptUpdate().catch((transcriptErr) => {
context.logGateway.warn(
`webchat eager user transcript update failed: ${formatForLog(transcriptErr)}`,
);
});
let agentRunStarted = false;
void dispatchInboundMessage({
ctx,
cfg,
@@ -2453,7 +2482,9 @@ export const chatHandlers: GatewayRequestHandlers = {
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
onAgentRunStart: (runId) => {
agentRunStarted = true;
void emitUserTranscriptUpdate();
if (!hasBeforeAgentRunGate) {
void emitUserTranscriptUpdate();
}
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
@@ -2474,7 +2505,8 @@ export const chatHandlers: GatewayRequestHandlers = {
onModelSelected,
},
})
.then(async () => {
.then(async (dispatchResult) => {
beforeAgentRunBlocked = dispatchResult.beforeAgentRunBlocked === true;
await rewriteUserTranscriptMedia();
// WebChat persistence has two owners. Agent runs persist model-visible turns
// through Pi's SessionManager; this dispatcher only owns live delivery payloads.
@@ -2655,7 +2687,11 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
} else {
void emitUserTranscriptUpdate();
await emitUserTranscriptUpdateUnlessBeforeAgentRunBlocked().catch((transcriptErr) => {
context.logGateway.warn(
`webchat user transcript update failed after agent run: ${formatForLog(transcriptErr)}`,
);
});
}
if (!context.chatAbortedRuns.has(clientRunId)) {
setGatewayDedupeEntry({
@@ -2669,13 +2705,18 @@ export const chatHandlers: GatewayRequestHandlers = {
});
}
})
.catch((err) => {
.catch(async (err) => {
void rewriteUserTranscriptMedia().catch((rewriteErr) => {
context.logGateway.warn(
`webchat transcript media rewrite failed after error: ${formatForLog(rewriteErr)}`,
);
});
void emitUserTranscriptUpdate().catch((transcriptErr) => {
const emitAfterError = !agentRunStarted
? emitUserTranscriptUpdate()
: hasBeforeAgentRunGate
? emitUserTranscriptUpdateUnlessBeforeAgentRunBlocked()
: emitUserTranscriptUpdate();
await emitAfterError.catch((transcriptErr) => {
context.logGateway.warn(
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
);

View File

@@ -125,6 +125,145 @@ describe("createPluginApprovalHandlers", () => {
);
});
it("preserves explicit allowed decisions on plugin approval requests", async () => {
const handlers = createPluginApprovalHandlers(manager);
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "Sensitive action",
description: "This tool modifies production data",
severity: "warning",
allowedDecisions: ["allow-once", "deny"],
twoPhase: true,
},
{ respond },
);
const handlerPromise = handlers["plugin.approval.request"](opts);
await vi.waitFor(() => {
expect(opts.context.broadcast).toHaveBeenCalledWith(
"plugin.approval.requested",
expect.objectContaining({
request: expect.objectContaining({
allowedDecisions: ["allow-once", "deny"],
}),
}),
{ dropIfSlow: true },
);
});
const acceptedCall = respond.mock.calls.find(
(c) => (c[1] as Record<string, unknown>)?.status === "accepted",
);
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
manager.resolve(approvalId, "allow-once");
await handlerPromise;
});
it("keeps deny available on restricted plugin approval requests", async () => {
const handlers = createPluginApprovalHandlers(manager);
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "Sensitive action",
description: "This tool modifies production data",
severity: "warning",
allowedDecisions: ["allow-once"],
twoPhase: true,
},
{ respond },
);
const handlerPromise = handlers["plugin.approval.request"](opts);
await vi.waitFor(() => {
expect(opts.context.broadcast).toHaveBeenCalledWith(
"plugin.approval.requested",
expect.objectContaining({
request: expect.objectContaining({
allowedDecisions: ["allow-once", "deny"],
}),
}),
{ dropIfSlow: true },
);
});
const acceptedCall = respond.mock.calls.find(
(c) => (c[1] as Record<string, unknown>)?.status === "accepted",
);
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
manager.resolve(approvalId, "deny");
await handlerPromise;
expect(respond).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ id: approvalId, decision: "deny" }),
undefined,
);
});
it("rejects explicit empty allowed decisions on plugin approval requests", async () => {
const handlers = createPluginApprovalHandlers(manager);
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "Sensitive action",
description: "This tool modifies production data",
severity: "warning",
allowedDecisions: [],
twoPhase: true,
},
{ respond },
);
await handlers["plugin.approval.request"](opts);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "allowedDecisions must include at least one supported decision",
}),
);
expect(opts.context.broadcast).not.toHaveBeenCalledWith(
"plugin.approval.requested",
expect.anything(),
expect.anything(),
);
});
it("rejects invalid-only allowed decisions on plugin approval requests", async () => {
const handlers = createPluginApprovalHandlers(manager);
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "Sensitive action",
description: "This tool modifies production data",
severity: "warning",
allowedDecisions: ["forever"],
},
{ respond },
);
await handlers["plugin.approval.request"](opts);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: expect.stringContaining("invalid plugin.approval.request params"),
}),
);
});
it("expires immediately when no approval route", async () => {
const handlers = createPluginApprovalHandlers(manager);
const opts = createMockOptions(
@@ -463,6 +602,61 @@ describe("createPluginApprovalHandlers", () => {
);
});
it("rejects decisions excluded by plugin approval allowedDecisions", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create(
{ title: "T", description: "D", allowedDecisions: ["allow-once", "deny"] },
60_000,
);
void manager.register(record, 60_000);
const opts = createMockOptions("plugin.approval.resolve", {
id: record.id,
decision: "allow-always",
});
await handlers["plugin.approval.resolve"](opts);
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "decision is not allowed for this plugin approval request",
}),
);
expect(opts.context.broadcast).not.toHaveBeenCalledWith(
"plugin.approval.resolved",
expect.anything(),
expect.anything(),
);
});
it("rejects decisions when plugin approval allowedDecisions is explicit empty", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create({ title: "T", description: "D", allowedDecisions: [] }, 60_000);
void manager.register(record, 60_000);
const opts = createMockOptions("plugin.approval.resolve", {
id: record.id,
decision: "allow-once",
});
await handlers["plugin.approval.resolve"](opts);
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "decision is not allowed for this plugin approval request",
}),
);
expect(opts.context.broadcast).not.toHaveBeenCalledWith(
"plugin.approval.resolved",
expect.anything(),
expect.anything(),
);
});
it("rejects unknown approval id", async () => {
const handlers = createPluginApprovalHandlers(manager);
const opts = createMockOptions("plugin.approval.resolve", {

View File

@@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import {
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalDecision,
} from "../../infra/exec-approvals.js";
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
import {
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
@@ -67,6 +70,7 @@ export function createPluginApprovalHandlers(
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
allowedDecisions?: string[];
timeoutMs?: number;
twoPhase?: boolean;
};
@@ -78,6 +82,26 @@ export function createPluginApprovalHandlers(
const normalizeTrimmedString = (value?: string | null): string | null =>
normalizeOptionalString(value) || null;
const rawAllowedDecisions = p.allowedDecisions;
const hasExplicitAllowedDecisions = Array.isArray(rawAllowedDecisions);
const allowedDecisions = hasExplicitAllowedDecisions
? rawAllowedDecisions.filter(isApprovalDecision)
: [];
if (hasExplicitAllowedDecisions && allowedDecisions.length === 0) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"allowedDecisions must include at least one supported decision",
),
);
return;
}
const effectiveAllowedDecisions =
hasExplicitAllowedDecisions && !allowedDecisions.includes("deny")
? [...allowedDecisions, "deny" as const]
: allowedDecisions;
const request: PluginApprovalRequestPayload = {
pluginId: p.pluginId ?? null,
@@ -92,6 +116,7 @@ export function createPluginApprovalHandlers(
turnSourceTo: normalizeTrimmedString(p.turnSourceTo),
turnSourceAccountId: normalizeTrimmedString(p.turnSourceAccountId),
turnSourceThreadId: p.turnSourceThreadId ?? null,
...(hasExplicitAllowedDecisions ? { allowedDecisions: effectiveAllowedDecisions } : {}),
};
// Always server-generate the ID — never accept plugin-provided IDs.
@@ -166,14 +191,19 @@ export function createPluginApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const decision: ExecApprovalDecision = p.decision;
await handleApprovalResolve({
manager,
inputId: p.id,
decision: p.decision,
decision,
respond,
context,
client,
exposeAmbiguousPrefixError: false,
validateDecision: (snapshot) =>
resolveExecApprovalRequestAllowedDecisions(snapshot.request).includes(decision)
? null
: { message: "decision is not allowed for this plugin approval request" },
resolvedEventName: "plugin.approval.resolved",
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) => ({
id: approvalId,

View File

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

View File

@@ -288,4 +288,64 @@ 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,6 +6,7 @@ import {
attachOpenClawTranscriptMeta,
readRecentSessionMessagesWithStatsAsync,
readSessionMessagesAsync,
stripBlockedOriginalContentMeta,
} from "./session-utils.js";
type SessionHistoryTranscriptMeta = {
@@ -156,6 +157,7 @@ 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;
@@ -167,12 +169,14 @@ 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,
@@ -184,6 +188,7 @@ export class SessionHistorySseState {
maxChars?: number;
limit?: number;
cursor?: string;
includeBlockedOriginalContent?: boolean;
initialRawMessages: unknown[];
rawTranscriptSeq?: number;
totalRawMessages?: number;
@@ -192,6 +197,7 @@ 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"
@@ -229,7 +235,10 @@ export class SessionHistorySseState {
return null;
}
this.rawTranscriptSeq += 1;
const nextMessage = attachOpenClawTranscriptMeta(update.message, {
const projectedMessage = this.includeBlockedOriginalContent
? update.message
: stripBlockedOriginalContentMeta(update.message);
const nextMessage = attachOpenClawTranscriptMeta(projectedMessage, {
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
seq: this.rawTranscriptSeq,
});
@@ -275,7 +284,10 @@ export class SessionHistorySseState {
this.target.sessionId,
this.target.storePath,
this.target.sessionFile,
resolveSessionHistoryTailReadOptions(this.limit),
{
...resolveSessionHistoryTailReadOptions(this.limit),
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
},
);
return {
rawMessages: snapshot.messages,
@@ -291,6 +303,7 @@ export class SessionHistorySseState {
{
mode: "full",
reason: "session history cursor pagination",
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
},
),
};

View File

@@ -286,6 +286,99 @@ describe("session.message websocket events", () => {
}
});
test("strips blocked original content from live session.message events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
storePath,
});
const transcriptPath = path.join(path.dirname(storePath), "sess-main.jsonl");
await fs.writeFile(
transcriptPath,
JSON.stringify({ type: "session", version: 1, id: "sess-main" }) + "\n",
"utf-8",
);
await withOperatorSessionSubscriber(async (ws) => {
const { messageEvent } = await emitTranscriptUpdateAndCollectEvents({
ws,
sessionKey: "agent:main:main",
sessionFile: transcriptPath,
messageId: "blocked-1",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
},
});
const payload = messageEvent.payload as {
message?: { content?: unknown; __openclaw?: { originalBlockedContent?: unknown } };
};
expect(payload.message?.content).toEqual([
{ type: "text", text: "The agent cannot read this message." },
]);
expect(payload.message?.__openclaw?.originalBlockedContent).toBeUndefined();
});
});
test("broadcasts redacted blocked user appends to live session listeners", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
storePath,
});
await withOperatorSessionSubscriber(async (ws) => {
const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:main");
emitSessionTranscriptUpdate({
sessionFile: path.join(path.dirname(storePath), "sess-main.jsonl"),
sessionKey: "agent:main:main",
messageId: "blocked-message",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
blockedBy: "policy-plugin",
reason: "contains protected content",
blockedAt: Date.now(),
},
},
},
});
const messageEvent = await messageEventPromise;
const payload = messageEvent.payload as {
message?: {
role?: unknown;
content?: unknown;
__openclaw?: { originalBlockedContent?: 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();
});
});
test("includes live usage metadata on session.message and sessions.changed transcript events", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({

View File

@@ -29,6 +29,32 @@ import {
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
function buildSessionAssistantMessage(text: string, timestamp: number) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "openai",
provider: "openai",
model: "mock-1",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "stop" as const,
timestamp,
};
}
function registerTempSessionStore(
prefix: string,
assignPaths: (tmpDir: string, storePath: string) => void,
@@ -51,6 +77,33 @@ function writeTranscript(tmpDir: string, sessionId: string, lines: unknown[]): s
return transcriptPath;
}
function appendBlockedUserMessageWithSessionManager(params: {
sessionFile: string;
originalText: string;
redactedText: string;
pluginId: string;
reason: string;
idempotencyKey?: string;
}): string {
const sessionManager = SessionManager.open(params.sessionFile, path.dirname(params.sessionFile));
const messageId = sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: params.redactedText }],
timestamp: Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
__openclaw: {
originalBlockedContent: {
content: params.originalText ? [{ type: "text", text: params.originalText }] : [],
blockedBy: params.pluginId,
reason: params.reason,
blockedAt: Date.now(),
},
},
} as Parameters<typeof sessionManager.appendMessage>[0]);
(sessionManager as unknown as { _rewriteFile?: () => void })._rewriteFile?.();
return messageId;
}
function buildBasicSessionTranscript(
sessionId: string,
userText = "Hello world",
@@ -1047,6 +1100,29 @@ describe("readSessionMessages", () => {
}
});
test("keeps legacy messages when a mixed transcript lacks a complete branch tree", () => {
const sessionId = "mixed-legacy-tree-session";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
{ type: "session", version: 1, id: sessionId },
{ type: "message", id: "legacy-user", message: { role: "user", content: "legacy hello" } },
{
type: "message",
id: "tree-assistant",
parentId: "legacy-user",
message: { role: "assistant", content: "tree hello" },
},
];
fs.writeFileSync(transcriptPath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
const out = readSessionMessages(sessionId, storePath);
expect(out.map((message) => (message as { content?: unknown }).content)).toEqual([
"legacy hello",
"tree hello",
]);
});
test.each([
{
sessionId: "cross-agent-default-root",
@@ -1081,6 +1157,225 @@ describe("readSessionMessages", () => {
expect((out[0] as { __openclaw?: { seq?: number } }).__openclaw?.seq).toBe(1);
},
);
test("reads only the active SessionManager branch after a transcript rewrite", () => {
const sessionId = "branched-session";
const sessionManager = SessionManager.create(tmpDir, tmpDir);
const decoratedPrompt = 'Sender (untrusted metadata):\n```json\n{"label":"ui"}\n```\n\nhello';
const visiblePrompt = "hello";
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: decoratedPrompt }],
timestamp: 1,
});
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
const decoratedUser = sessionManager
.getBranch()
.find((entry) => entry.type === "message" && entry.message.role === "user");
expect(decoratedUser?.type).toBe("message");
if (decoratedUser?.parentId) {
sessionManager.branch(decoratedUser.parentId);
} else {
sessionManager.resetLeaf();
}
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: visiblePrompt }],
timestamp: 1,
});
sessionManager.appendMessage(buildSessionAssistantMessage("old answer", 2));
const sessionFile = sessionManager.getSessionFile();
expect(sessionFile).toBeTruthy();
const out = readSessionMessages(sessionId, storePath, sessionFile ?? undefined);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
content: (message as { content?: unknown }).content,
})),
).toEqual([
{ role: "user", content: [{ type: "text", text: visiblePrompt }] },
{ role: "assistant", content: [{ type: "text", text: "old answer" }] },
]);
});
test("keeps compaction markers when reading only the active SessionManager branch", () => {
const sessionId = "branched-session-with-compaction";
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
{
type: "session",
version: 1,
id: sessionId,
},
{
type: "message",
id: "user-old",
parentId: null,
message: { role: "user", content: "old prompt", timestamp: 1 },
},
{
type: "message",
id: "assistant-old",
parentId: "user-old",
message: { role: "assistant", content: "old answer", timestamp: 2 },
},
{
type: "compaction",
id: "comp-1",
timestamp: "2026-02-07T00:00:00.000Z",
summary: "Compacted history",
},
{
type: "message",
id: "user-active",
parentId: null,
message: { role: "user", content: "active prompt", timestamp: 3 },
},
{
type: "message",
id: "assistant-active",
parentId: "user-active",
message: { role: "assistant", content: "active answer", timestamp: 4 },
},
];
fs.writeFileSync(sessionFile, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
const out = readSessionMessages(sessionId, storePath, sessionFile);
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
content: (message as { content?: unknown }).content,
kind: (message as { __openclaw?: { kind?: string } }).__openclaw?.kind,
})),
).toEqual([
{ role: "system", content: [{ type: "text", text: "Compaction" }], kind: "compaction" },
{ role: "user", content: "active prompt", kind: undefined },
{ role: "assistant", content: "active answer", kind: undefined },
]);
});
test("keeps blocked hook messages on the current active branch", async () => {
const sessionId = "blocked-hook-branch-session";
const sessionKey = "agent:main:explicit:blocked-hook-branch";
const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId,
updatedAt: 1,
sessionFile,
},
}),
"utf-8",
);
fs.writeFileSync(
sessionFile,
[
{ type: "session", version: 1, id: sessionId },
{
type: "message",
id: "user-1",
parentId: null,
message: { role: "user", content: "hello", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
message: { role: "assistant", content: "hi", timestamp: 2 },
},
]
.map((line) => JSON.stringify(line))
.join("\n") + "\n",
"utf-8",
);
const messageId = appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] hello",
redactedText: "Blocked by HITL test hook.",
pluginId: "hitl-test-hooks",
reason: "blocked by test policy",
});
expect(messageId).toBeTruthy();
const out = readSessionMessages(sessionId, storePath, sessionFile, {
includeBlockedOriginalContent: true,
});
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
text: (message as { content?: string | Array<{ text?: string }> }).content,
})),
).toEqual([
{ role: "user", text: "hello" },
{ role: "assistant", text: "hi" },
{ role: "user", text: [{ type: "text", text: "Blocked by HITL test hook." }] },
]);
expect(
(out[2] as { __openclaw?: { originalBlockedContent?: { content?: unknown } } }).__openclaw
?.originalBlockedContent?.content,
).toEqual([{ type: "text", text: "[hitl:block] hello" }]);
});
test("keeps repeated blocked hook messages together in a new session", async () => {
const sessionKey = "agent:main:explicit:repeated-blocked-hook";
const sessionManager = SessionManager.create(tmpDir, tmpDir);
const sessionId = sessionManager.getSessionId();
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) {
throw new Error("expected SessionManager.create to return a session file");
}
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId,
updatedAt: 1,
sessionFile,
},
}),
"utf-8",
);
appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] first",
redactedText: "Blocked by HITL test hook.",
pluginId: "hitl-test-hooks",
reason: "blocked by test policy",
});
appendBlockedUserMessageWithSessionManager({
sessionFile,
originalText: "[hitl:block] second",
redactedText: "Blocked by HITL test hook.",
pluginId: "hitl-test-hooks",
reason: "blocked by test policy",
});
const out = readSessionMessages(sessionId, storePath, sessionFile, {
includeBlockedOriginalContent: true,
});
expect(
out.map((message) => ({
role: (message as { role?: string }).role,
original: (
message as {
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
}
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
})),
).toEqual([
{ role: "user", original: "[hitl:block] first" },
{ role: "user", original: "[hitl:block] second" },
]);
});
});
describe("readSessionPreviewItemsFromTranscript", () => {

View File

@@ -139,10 +139,34 @@ 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;
};
export function readSessionMessages(
sessionId: string,
storePath: string | undefined,
sessionFile?: string,
opts?: SessionMessageProjectionOptions,
): unknown[] {
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile);
@@ -151,20 +175,20 @@ export function readSessionMessages(
return [];
}
return transcriptRecordsToMessages(readSelectedTranscriptRecords(filePath));
return transcriptRecordsToMessages(readSelectedTranscriptRecords(filePath), opts);
}
export type ReadRecentSessionMessagesOptions = {
export type ReadRecentSessionMessagesOptions = SessionMessageProjectionOptions & {
maxMessages: number;
maxBytes?: number;
maxLines?: number;
};
export type ReadSessionMessagesAsyncOptions =
| {
| ({
mode: "full";
reason: string;
}
} & SessionMessageProjectionOptions)
| ({
mode: "recent";
} & ReadRecentSessionMessagesOptions);
@@ -230,7 +254,7 @@ export function readRecentSessionMessages(
.filter((line) => line.trim().length > 0)
.slice(-maxLines);
return parseRecentTranscriptTailMessages(lines, maxMessages);
return parseRecentTranscriptTailMessages(lines, maxMessages, opts);
}) ?? []
);
}
@@ -360,8 +384,10 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
const byId = new Map<string, TailTranscriptRecord>();
let leafId: string | undefined;
for (const entry of entries) {
if (tailRecordHasTreeLink(entry) && entry.id) {
if (entry.id) {
byId.set(entry.id, entry);
}
if (tailRecordHasTreeLink(entry) && entry.id) {
leafId = entry.id;
}
}
@@ -384,7 +410,18 @@ function selectBoundedActiveTailRecords(entries: TailTranscriptRecord[]): TailTr
selected.push(entry);
currentId = entry.parentId ?? undefined;
}
return selected.toReversed();
const activeBranch = selected.toReversed();
const firstActiveRecord = activeBranch[0];
const firstActiveIndex = firstActiveRecord ? entries.indexOf(firstActiveRecord) : -1;
if (firstActiveIndex > 0) {
for (let index = firstActiveIndex - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry?.record.type === "compaction") {
return [entry, ...activeBranch];
}
}
}
return activeBranch;
}
function readTranscriptRecords(filePath: string): TailTranscriptRecord[] {
@@ -413,11 +450,14 @@ function readSelectedTranscriptRecords(filePath: string): TailTranscriptRecord[]
}
}
function transcriptRecordsToMessages(records: TailTranscriptRecord[]): unknown[] {
function transcriptRecordsToMessages(
records: TailTranscriptRecord[],
opts?: SessionMessageProjectionOptions,
): unknown[] {
const messages: unknown[] = [];
let messageSeq = 0;
for (const entry of records) {
const message = parsedSessionEntryToMessage(entry.record, messageSeq + 1);
const message = parsedSessionEntryToMessage(entry.record, messageSeq + 1, opts);
if (message) {
messageSeq += 1;
messages.push(message);
@@ -426,12 +466,18 @@ function transcriptRecordsToMessages(records: TailTranscriptRecord[]): unknown[]
return messages;
}
function parseRecentTranscriptTailMessages(lines: string[], maxMessages: number): unknown[] {
function parseRecentTranscriptTailMessages(
lines: string[],
maxMessages: number,
opts?: SessionMessageProjectionOptions,
): unknown[] {
const entries = lines.flatMap((line) => {
const entry = parseTailTranscriptRecord(line);
return entry ? [entry] : [];
});
return transcriptRecordsToMessages(selectActiveTranscriptRecords(entries)).slice(-maxMessages);
return transcriptRecordsToMessages(selectActiveTranscriptRecords(entries), opts).slice(
-maxMessages,
);
}
function visitTranscriptLines(filePath: string, visit: (line: string) => void): void {
@@ -551,7 +597,7 @@ export async function readSessionMessagesAsync(
return [];
}
const index = await readSessionTranscriptIndex(filePath);
return index?.entries.flatMap((entry) => indexedTranscriptEntryToMessages(entry)) ?? [];
return index?.entries.flatMap((entry) => indexedTranscriptEntryToMessages(entry, opts)) ?? [];
}
export async function visitSessionMessagesAsync(
@@ -559,7 +605,7 @@ export async function visitSessionMessagesAsync(
storePath: string | undefined,
sessionFile: string | undefined,
visit: (message: unknown, seq: number) => void,
_opts: { mode: "full"; reason: string },
opts: { mode: "full"; reason: string; includeBlockedOriginalContent?: boolean },
): Promise<number> {
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
if (!filePath) {
@@ -570,7 +616,7 @@ export async function visitSessionMessagesAsync(
return 0;
}
for (const entry of index.entries) {
const message = indexedTranscriptEntryToMessage(entry);
const message = indexedTranscriptEntryToMessage(entry, opts);
if (message) {
visit(message, entry.seq);
}
@@ -649,7 +695,7 @@ export async function readRecentSessionMessagesAsync(
...opts,
maxMessages,
});
return parseRecentTranscriptTailMessages(lines, maxMessages);
return parseRecentTranscriptTailMessages(lines, maxMessages, opts);
}
export async function readRecentSessionMessagesWithStatsAsync(
@@ -703,15 +749,46 @@ export function readRecentSessionTranscriptLines(params: {
return { lines, totalLines };
}
function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
function parsedSessionEntryToMessage(
parsed: unknown,
seq: number,
opts?: SessionMessageProjectionOptions,
): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const entry = parsed as Record<string, unknown>;
if (entry.message) {
return attachOpenClawTranscriptMeta(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, {
...(typeof entry.id === "string" ? { id: entry.id } : {}),
seq,
...originalBlockedContent,
});
}
@@ -734,12 +811,18 @@ function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
return null;
}
function indexedTranscriptEntryToMessage(entry: IndexedTranscriptEntry): unknown {
return parsedSessionEntryToMessage(entry.record, entry.seq);
function indexedTranscriptEntryToMessage(
entry: IndexedTranscriptEntry,
opts?: SessionMessageProjectionOptions,
): unknown {
return parsedSessionEntryToMessage(entry.record, entry.seq, opts);
}
function indexedTranscriptEntryToMessages(entry: IndexedTranscriptEntry): unknown[] {
const message = indexedTranscriptEntryToMessage(entry);
function indexedTranscriptEntryToMessages(
entry: IndexedTranscriptEntry,
opts?: SessionMessageProjectionOptions,
): unknown[] {
const message = indexedTranscriptEntryToMessage(entry, opts);
return message ? [message] : [];
}

View File

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

View File

@@ -3,7 +3,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
let transcriptUpdateHandler:
| ((update: { sessionFile?: string; message?: unknown; messageId?: string }) => void)
| ((update: {
sessionFile?: string;
message?: unknown;
messageId?: string;
forceHistoryRefresh?: boolean;
}) => void)
| undefined;
let authRevoked = false;
let gatewayConfig: {
@@ -16,6 +21,7 @@ let gatewayConfig: {
webchat: { chatHistoryMaxChars: 2000 },
};
let authCheckCalls = 0;
let currentScopes = ["operator.read"];
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => ({
@@ -43,7 +49,7 @@ vi.mock("./http-utils.js", () => ({
const value = req.headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
},
resolveTrustedHttpOperatorScopes: () => ["operator.read"],
resolveTrustedHttpOperatorScopes: () => currentScopes,
authorizeScopedGatewayHttpRequestOrReply: async () => ({
cfg: { gateway: { webchat: { chatHistoryMaxChars: 2000 } } },
requestAuth: { trustDeclaredOperatorScopes: true },
@@ -100,14 +106,55 @@ vi.mock("./session-history-state.js", () => ({
history: { items: [], nextCursor: null, messages: [] },
}),
SessionHistorySseState: {
fromRawSnapshot: () => ({
fromRawSnapshot: (params: { includeBlockedOriginalContent?: boolean }) => ({
snapshot: () => ({ items: [], nextCursor: null, messages: [] }),
appendInlineMessage: ({ message, messageId }: { message: unknown; messageId?: string }) => ({
message,
message:
params.includeBlockedOriginalContent || !message || typeof message !== "object"
? message
: (() => {
const clone = { ...(message as Record<string, unknown>) };
delete clone.__openclaw;
return clone;
})(),
messageSeq: 1,
messageId,
}),
refreshAsync: async () => ({ items: [], nextCursor: null, messages: [] }),
refreshAsync: async () => ({
items: [
params.includeBlockedOriginalContent
? {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
}
: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
},
],
nextCursor: null,
messages: [
params.includeBlockedOriginalContent
? {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
}
: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
},
],
}),
}),
},
}));
@@ -166,6 +213,7 @@ afterEach(() => {
transcriptUpdateHandler = undefined;
authRevoked = false;
authCheckCalls = 0;
currentScopes = ["operator.read"];
gatewayConfig = {
trustedProxies: ["10.0.0.1"],
allowRealIpFallback: false,
@@ -204,6 +252,112 @@ 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

@@ -23,7 +23,11 @@ import {
getHeader,
resolveTrustedHttpOperatorScopes,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import {
ADMIN_SCOPE,
TALK_SECRETS_SCOPE,
authorizeOperatorScopesForMethod,
} from "./method-scopes.js";
import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./server-methods/chat.js";
import {
buildSessionHistorySnapshot,
@@ -76,6 +80,18 @@ 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) {
@@ -133,7 +149,8 @@ export async function handleSessionHistoryHttpRequest(
if (!authResult) {
return true;
}
const { cfg } = authResult;
const { cfg, requestAuth } = authResult;
const includeBlockedOriginalContent = shouldIncludeBlockedOriginalContent(req, requestAuth);
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
const store = loadSessionStore(target.storePath);
@@ -160,7 +177,10 @@ export async function handleSessionHistoryHttpRequest(
entry.sessionId,
target.storePath,
entry.sessionFile,
resolveSessionHistoryTailReadOptions(limit),
{
...resolveSessionHistoryTailReadOptions(limit),
includeBlockedOriginalContent,
},
)
: undefined;
// Cursor reads still need an arbitrary historical window. The common first
@@ -171,6 +191,7 @@ export async function handleSessionHistoryHttpRequest(
? await readSessionMessagesAsync(entry.sessionId, target.storePath, entry.sessionFile, {
mode: "full",
reason: "session history cursor pagination",
includeBlockedOriginalContent,
})
: []);
const historySnapshot = buildSessionHistorySnapshot({
@@ -217,6 +238,7 @@ export async function handleSessionHistoryHttpRequest(
maxChars: effectiveMaxChars,
limit,
cursor,
includeBlockedOriginalContent,
});
sentHistory = sseState.snapshot();
setSseHeaders(res);
@@ -286,6 +308,13 @@ 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;
};
@@ -322,7 +351,7 @@ export async function handleSessionHistoryHttpRequest(
closeStream();
return;
}
if (update.message !== undefined) {
if (update.message !== undefined && update.forceHistoryRefresh !== true) {
if (limit === undefined && cursor === undefined) {
const nextEvent = sseState.appendInlineMessage({
message: update.message,

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
describeApprovalDeliveryDestination,
resolveApprovalDeliveryFailedNoticeText,
resolveApprovalRoutedElsewhereNoticeText,
} from "./approval-native-route-notice.js";
@@ -49,3 +50,15 @@ describe("resolveApprovalRoutedElsewhereNoticeText", () => {
expect(resolveApprovalRoutedElsewhereNoticeText([])).toBeNull();
});
});
describe("resolveApprovalDeliveryFailedNoticeText", () => {
it("does not invent fallback decisions for explicit empty restrictions", () => {
expect(
resolveApprovalDeliveryFailedNoticeText({
approvalId: "approval-1",
approvalKind: "plugin",
allowedDecisions: [],
}),
).toContain("No reply decisions are currently available");
});
});

View File

@@ -34,11 +34,18 @@ export function resolveApprovalDeliveryFailedNoticeText(params: {
params.approvalKind === "exec" && params.approvalId.length > 8
? params.approvalId.slice(0, 8)
: params.approvalId;
const decisions = (
params.allowedDecisions?.length
? params.allowedDecisions
: ["allow-once", "allow-always", "deny"]
).join("|");
const allowedDecisions = params.allowedDecisions;
const hasExplicitAllowedDecisions = allowedDecisions !== undefined;
const decisions = hasExplicitAllowedDecisions
? allowedDecisions.join("|")
: ["allow-once", "allow-always", "deny"].join("|");
if (!decisions) {
return [
"Approval required. I could not deliver the native approval request.",
"No reply decisions are currently available for this approval.",
"Try again from Control UI or cancel the run.",
].join("\n");
}
return [
"Approval required. I could not deliver the native approval request.",
`Reply with: /approve ${commandId} ${decisions}`,

View File

@@ -105,6 +105,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro
...buildPluginViewBase(pluginRequest, "pending"),
actions: buildExecApprovalActionDescriptors({
approvalCommandId: pluginRequest.id,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(pluginRequest.request),
}),
expiresAtMs: pluginRequest.expiresAtMs,
};

View File

@@ -461,6 +461,7 @@ function buildPluginPendingPayload(params: {
request: params.request,
nowMs: params.nowMs,
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
}),
});
}

View File

@@ -1246,13 +1246,13 @@ export function resolveExecApprovalRequestAllowedDecisions(params?: {
ask?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null;
}): readonly ExecApprovalDecision[] {
const explicit = Array.isArray(params?.allowedDecisions)
? params.allowedDecisions.filter(
(decision): decision is ExecApprovalDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
)
: [];
return explicit.length > 0 ? explicit : resolveExecApprovalAllowedDecisions({ ask: params?.ask });
if (Array.isArray(params?.allowedDecisions)) {
return params.allowedDecisions.filter(
(decision): decision is ExecApprovalDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
);
}
return resolveExecApprovalAllowedDecisions({ ask: params?.ask });
}
export function isExecApprovalDecisionAllowed(params: {

View File

@@ -1,4 +1,7 @@
import type { ExecApprovalDecision } from "./exec-approvals.js";
import {
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalDecision,
} from "./exec-approvals.js";
export type PluginApprovalRequestPayload = {
pluginId?: string | null;
@@ -13,6 +16,7 @@ export type PluginApprovalRequestPayload = {
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
allowedDecisions?: readonly ExecApprovalDecision[];
};
export type PluginApprovalRequest = {
@@ -67,7 +71,8 @@ export function buildPluginApprovalRequestMessage(
lines.push(`ID: ${request.id}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMsValue) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
const decisions = resolveExecApprovalRequestAllowedDecisions(request.request);
lines.push(`Reply with: /approve <id> ${decisions.join("|")}`);
return lines.join("\n");
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
type HookDecision,
type HookDecisionBlock,
mergeHookDecisions,
isHookDecision,
DEFAULT_BLOCK_MESSAGE,
resolveBlockMessage,
} from "./hook-decision-types.js";
describe("HookDecision helpers", () => {
describe("isHookDecision", () => {
it("recognizes supported outcomes", () => {
expect(isHookDecision({ outcome: "pass" })).toBe(true);
expect(isHookDecision({ outcome: "block", reason: "policy" })).toBe(true);
});
it("rejects non-decision values", () => {
expect(isHookDecision(null)).toBe(false);
expect(isHookDecision(undefined)).toBe(false);
expect(isHookDecision("pass")).toBe(false);
expect(isHookDecision({ block: true })).toBe(false);
expect(isHookDecision({ outcome: "ask", reason: "check" })).toBe(false);
expect(isHookDecision({ outcome: "invalid" })).toBe(false);
expect(isHookDecision({ outcome: "pass", message: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "pass", reason: "typo" })).toBe(false);
expect(isHookDecision({ outcome: "block" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: "" })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", message: 3 })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", ask: true })).toBe(false);
expect(isHookDecision({ outcome: "block", reason: "policy", metadata: [] })).toBe(false);
});
});
describe("mergeHookDecisions", () => {
const passDecision: HookDecision = { outcome: "pass" };
const blockDecision: HookDecision = { outcome: "block", reason: "policy" };
it("uses most-restrictive-wins ordering", () => {
expect(mergeHookDecisions(undefined, passDecision)).toBe(passDecision);
expect(mergeHookDecisions(passDecision, blockDecision)).toBe(blockDecision);
expect(mergeHookDecisions(blockDecision, passDecision)).toBe(blockDecision);
});
it("keeps the first decision when outcomes have the same severity", () => {
const secondBlock: HookDecision = { outcome: "block", reason: "second" };
expect(mergeHookDecisions(passDecision, { outcome: "pass" })).toBe(passDecision);
expect(mergeHookDecisions(blockDecision, secondBlock)).toBe(blockDecision);
});
});
describe("resolveBlockMessage", () => {
it("returns explicit or default block messages", () => {
const explicit: HookDecisionBlock = {
outcome: "block",
reason: "policy",
message: "Please rephrase your request.",
};
const fallback: HookDecisionBlock = {
outcome: "block",
reason: "policy",
};
expect(resolveBlockMessage(explicit)).toBe("Please rephrase your request.");
expect(resolveBlockMessage(fallback)).toBe(DEFAULT_BLOCK_MESSAGE);
expect(resolveBlockMessage({ ...explicit, message: " " })).toBe(DEFAULT_BLOCK_MESSAGE);
});
});
});

View File

@@ -0,0 +1,102 @@
/**
* Structured decision returned by gate/policy hooks.
* Core is outcome-agnostic — it handles the mechanics of each outcome
* without knowing *why* the decision was made.
*/
export type HookDecision = HookDecisionPass | HookDecisionBlock;
/** Content is fine. Proceed normally. */
export type HookDecisionPass = {
outcome: "pass";
};
/** Default user-facing replacement message when a `block` decision omits one. */
export const DEFAULT_BLOCK_MESSAGE = "This request was blocked by policy";
/**
* Content is blocked. `reason` is internal; `message` is user-facing.
*/
export type HookDecisionBlock = {
outcome: "block";
/** Internal reason for logging/observability. Never shown to user. */
reason: string;
/** Optional user-facing replacement text. Defaults to `DEFAULT_BLOCK_MESSAGE`. */
message?: string;
/** Plugin-defined category for analytics (e.g. "violence", "pii", "cost_limit"). */
category?: string;
/** Opaque metadata for the plugin's own use. Core does not interpret it. */
metadata?: Record<string, unknown>;
};
export function resolveBlockMessage(decision: HookDecisionBlock): string {
return typeof decision.message === "string" && decision.message.trim()
? decision.message
: DEFAULT_BLOCK_MESSAGE;
}
/** Outcome severity for most-restrictive-wins merging. Higher = more restrictive. */
export const HOOK_DECISION_SEVERITY: Record<HookDecision["outcome"], number> = {
pass: 0,
block: 2,
};
/**
* Merge two HookDecisions using most-restrictive-wins semantics.
* `block > pass`
*/
export function mergeHookDecisions(a: HookDecision | undefined, b: HookDecision): HookDecision {
if (!a) {
return b;
}
return HOOK_DECISION_SEVERITY[b.outcome] > HOOK_DECISION_SEVERITY[a.outcome] ? b : a;
}
/**
* Type guard: does this object look like a HookDecision (has `outcome` field)?
*/
export function isHookDecision(value: unknown): value is HookDecision {
if (typeof value !== "object" || value === null) {
return false;
}
const v = value as Record<string, unknown>;
const keys = Object.keys(v);
if (v.outcome === "pass") {
return keys.length === 1;
}
if (v.outcome !== "block") {
return false;
}
const allowedBlockKeys = new Set(["outcome", "reason", "message", "category", "metadata"]);
if (keys.some((key) => !allowedBlockKeys.has(key))) {
return false;
}
if (typeof v.reason !== "string" || !v.reason.trim()) {
return false;
}
if ("message" in v && (typeof v.message !== "string" || !v.message.trim())) {
return false;
}
if ("category" in v && (typeof v.category !== "string" || !v.category.trim())) {
return false;
}
if (
"metadata" in v &&
(typeof v.metadata !== "object" || v.metadata === null || Array.isArray(v.metadata))
) {
return false;
}
return true;
}
/** Outcomes valid for input gates (before_agent_run). */
export type InputGateDecision = HookDecisionPass | HookDecisionBlock;
/**
* A gate hook decision paired with the pluginId that produced it.
* Returned by gate hook runners so callers can
* attribute blocked entries and audit events to the originating plugin.
*/
export type GateHookResult<TDecision extends HookDecision = HookDecision> = {
decision: TDecision;
pluginId: string;
};

View File

@@ -0,0 +1,350 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GlobalHookRunnerRegistry } from "./hook-registry.types.js";
import type { PluginHookRegistration, PluginHookAgentContext } from "./hook-types.js";
import { createHookRunner } from "./hooks.js";
function makeRegistry(hooks: PluginHookRegistration[] = []): GlobalHookRunnerRegistry {
return {
hooks: [],
typedHooks: hooks,
plugins: [],
};
}
const ctx: PluginHookAgentContext = {
runId: "run-1",
agentId: "agent-1",
sessionKey: "session-1",
sessionId: "sid-1",
};
describe("before_agent_run hook", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns undefined when no handlers registered", async () => {
const runner = createHookRunner(makeRegistry());
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result).toBeUndefined();
});
it("returns pass when handler returns pass", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () => ({ outcome: "pass" as const }),
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result?.decision).toEqual({ outcome: "pass" });
expect(result?.pluginId).toBe("test");
});
it("returns block when handler returns block (with `message`)", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () => ({
outcome: "block" as const,
reason: "unsafe content",
message: "I can't process that.",
category: "violence",
}),
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "bad stuff", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
if (result?.decision.outcome === "block") {
expect(result.decision.reason).toBe("unsafe content");
expect(result.decision.message).toBe("I can't process that.");
}
});
it("merges with most-restrictive-wins: block beats pass", async () => {
const registry = makeRegistry([
{
pluginId: "plugin-a",
hookName: "before_agent_run",
handler: async () => ({ outcome: "pass" as const }),
source: "test",
priority: 10,
},
{
pluginId: "plugin-b",
hookName: "before_agent_run",
handler: async () => ({
outcome: "block" as const,
reason: "blocked",
}),
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
expect(result?.pluginId).toBe("plugin-b");
});
it("short-circuits on block (skips remaining handlers)", async () => {
let secondHandlerCalled = false;
const registry = makeRegistry([
{
pluginId: "plugin-a",
hookName: "before_agent_run",
handler: async () => ({
outcome: "block" as const,
reason: "blocked",
}),
source: "test",
priority: 10,
},
{
pluginId: "plugin-b",
hookName: "before_agent_run",
handler: async () => {
secondHandlerCalled = true;
return { outcome: "pass" as const };
},
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(secondHandlerCalled).toBe(false);
});
it("treats void handler returns as pass (no effect)", async () => {
const registry = makeRegistry([
{
pluginId: "void-plugin",
hookName: "before_agent_run",
handler: async () => undefined,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
// void => undefined result (no decision)
expect(result).toBeUndefined();
});
it("fails closed on invalid handler results", async () => {
const registry = makeRegistry([
{
pluginId: "invalid-plugin",
hookName: "before_agent_run",
handler: async () => ({ block: true }) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "invalid-plugin",
});
});
it("fails closed on null handler results", async () => {
const registry = makeRegistry([
{
pluginId: "null-plugin",
hookName: "before_agent_run",
handler: async () => null as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "null-plugin",
});
});
it("fails closed on malformed block decisions", async () => {
const registry = makeRegistry([
{
pluginId: "malformed-block-plugin",
hookName: "before_agent_run",
handler: async () => ({ outcome: "block" }) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result).toEqual({
decision: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
},
pluginId: "malformed-block-plugin",
});
});
it("fails closed when handlers throw", async () => {
const registry = makeRegistry([
{
pluginId: "throwing-plugin",
hookName: "before_agent_run",
handler: async () => {
throw new Error("policy unavailable");
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await expect(runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx)).rejects.toThrow(
"before_agent_run handler from throwing-plugin failed: policy unavailable",
);
});
it("fails closed when handlers exceed the default timeout", async () => {
vi.useFakeTimers();
const registry = makeRegistry([
{
pluginId: "hanging-plugin",
hookName: "before_agent_run",
handler: async () => await new Promise<never>(() => {}),
source: "test",
},
]);
const runner = createHookRunner(registry);
const resultPromise = runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
const rejection = expect(resultPromise).rejects.toThrow(
"before_agent_run handler from hanging-plugin failed: timed out after 15000ms",
);
await vi.advanceTimersByTimeAsync(15_000);
await rejection;
});
it("receives the correct event payload", async () => {
let receivedEvent: unknown;
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async (event: unknown) => {
receivedEvent = event;
return { outcome: "pass" as const };
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await runner.runBeforeAgentRun(
{
prompt: "hello world",
messages: [{ role: "user", content: "hello" }],
channelId: "discord",
senderId: "user-123",
senderIsOwner: true,
},
ctx,
);
const event = receivedEvent as Record<string, unknown>;
expect(event.prompt).toBe("hello world");
expect(event.channelId).toBe("discord");
expect(event.senderId).toBe("user-123");
expect(event.senderIsOwner).toBe(true);
});
});
describe("before_agent_run invalid ask outcome", () => {
it("fails closed when handler returns ask", async () => {
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_agent_run",
handler: async () =>
({
outcome: "ask",
reason: "needs approval",
title: "Review Required",
description: "This prompt requires human review.",
}) as never,
source: "test",
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "hello", messages: [] }, ctx);
expect(result?.decision).toEqual({
outcome: "block",
reason: "before_agent_run returned an invalid decision",
});
expect(result?.pluginId).toBe("test");
});
it("short-circuits unsupported ask decisions", async () => {
let secondHandlerCalled = false;
const registry = makeRegistry([
{
pluginId: "plugin-a",
hookName: "before_agent_run",
handler: async () =>
({
outcome: "ask" as const,
reason: "check",
title: "Check",
description: "Check this.",
}) as never,
source: "test",
priority: 10,
},
{
pluginId: "plugin-b",
hookName: "before_agent_run",
handler: async () => {
secondHandlerCalled = true;
return { outcome: "pass" as const };
},
source: "test",
priority: 5,
},
]);
const runner = createHookRunner(registry);
const result = await runner.runBeforeAgentRun({ prompt: "test", messages: [] }, ctx);
expect(result?.decision.outcome).toBe("block");
expect(result?.pluginId).toBe("plugin-a");
expect(secondHandlerCalled).toBe(false);
});
});
describe("before_tool_call channelId forwarding", () => {
it("passes channelId through to before_tool_call handlers", async () => {
let receivedCtx: unknown;
const registry = makeRegistry([
{
pluginId: "test",
hookName: "before_tool_call",
handler: async (_event: unknown, ctx: unknown) => {
receivedCtx = ctx;
return undefined;
},
source: "test",
},
]);
const runner = createHookRunner(registry);
await runner.runBeforeToolCall(
{ toolName: "exec", params: {} },
{ toolName: "exec", channelId: "discord", sessionKey: "s1" },
);
expect((receivedCtx as { channelId?: string }).channelId).toBe("discord");
});
});

View File

@@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry):
},
catchErrors: true,
failurePolicyByHook: {
before_agent_run: "fail-closed",
before_tool_call: "fail-closed",
},
});

View File

@@ -21,6 +21,7 @@ import type {
PluginHookBeforePromptBuildEvent,
PluginHookBeforePromptBuildResult,
} from "./hook-before-agent-start.types.js";
import type { InputGateDecision } from "./hook-decision-types.js";
import type {
PluginHookInboundClaimContext,
PluginHookInboundClaimEvent,
@@ -103,7 +104,8 @@ export type PluginHookName =
| "cron_changed"
| "before_dispatch"
| "reply_dispatch"
| "before_install";
| "before_install"
| "before_agent_run";
export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
@@ -141,6 +143,7 @@ export const PLUGIN_HOOK_NAMES = [
"before_dispatch",
"reply_dispatch",
"before_install",
"before_agent_run",
] as const satisfies readonly PluginHookName[];
type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
@@ -168,10 +171,13 @@ export const isPromptInjectionHookName = (hookName: PluginHookName): boolean =>
promptInjectionHookNameSet.has(hookName);
export const CONVERSATION_HOOK_NAMES = [
"before_model_resolve",
"before_agent_reply",
"llm_input",
"llm_output",
"before_agent_finalize",
"agent_end",
"before_agent_run",
] as const satisfies readonly PluginHookName[];
export type ConversationHookName = (typeof CONVERSATION_HOOK_NAMES)[number];
@@ -259,6 +265,8 @@ export type PluginHookLlmOutputEvent = {
* `resolvedRef` so provider/model consumers keep a stable parse contract.
*/
harnessId?: string;
/** The original user prompt that produced this output. */
prompt?: string;
assistantTexts: string[];
lastAssistant?: unknown;
usage?: {
@@ -408,6 +416,7 @@ export type PluginHookToolContext = {
getSessionExtension?: <T extends PluginJsonValue = PluginJsonValue>(
namespace: string,
) => T | undefined;
channelId?: string;
};
export type PluginHookBeforeToolCallEvent = {
@@ -438,6 +447,7 @@ export type PluginHookBeforeToolCallResult = {
severity?: "info" | "warning" | "critical";
timeoutMs?: number;
timeoutBehavior?: "allow" | "deny";
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
pluginId?: string;
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
};
@@ -802,6 +812,31 @@ export type PluginHookBeforeInstallResult = {
blockReason?: string;
};
// ---------------------------------------------------------------------------
// before_agent_run — Lifecycle Gate Hook
// ---------------------------------------------------------------------------
/** Event payload for the before_agent_run gate hook. */
export type PluginHookBeforeAgentRunEvent = {
/** The user's message that triggered this run. */
prompt: string;
/** Loaded session history before the current prompt is submitted. */
messages: unknown[];
/** Active system prompt prepared for this run. */
systemPrompt?: string;
/** Account identity when available. */
accountId?: string;
/** Channel the message came from. */
channelId?: string;
/** Sender identity when available. */
senderId?: string;
/** Whether the sender is an owner. */
senderIsOwner?: boolean;
};
/** Result type for before_agent_run. Returns pass/block or void (= pass). */
export type PluginHookBeforeAgentRunResult = InputGateDecision | void;
export type PluginHookHandlerMap = {
agent_turn_prepare: (
event: PluginAgentTurnPrepareEvent,
@@ -950,6 +985,10 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeInstallEvent,
ctx: PluginHookBeforeInstallContext,
) => Promise<PluginHookBeforeInstallResult | void> | PluginHookBeforeInstallResult | void;
before_agent_run: (
event: PluginHookBeforeAgentRunEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentRunResult> | PluginHookBeforeAgentRunResult;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {

View File

@@ -2,12 +2,17 @@
* Plugin Hook Runner
*
* Provides utilities for executing plugin lifecycle hooks with proper
* error handling, priority ordering, and async support.
* error handling and priority ordering.
*/
import { formatHookErrorForLog } from "../hooks/fire-and-forget.js";
import { formatErrorMessage } from "../infra/errors.js";
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
import {
type GateHookResult,
type InputGateDecision,
isHookDecision,
} from "./hook-decision-types.js";
import type { GlobalHookRunnerRegistry, HookRunnerRegistry } from "./hook-registry.types.js";
import type {
PluginHookAfterCompactionEvent,
@@ -45,6 +50,7 @@ import type {
PluginAgentTurnPrepareResult,
PluginHeartbeatPromptContributionEvent,
PluginHeartbeatPromptContributionResult,
PluginHookBeforeAgentRunEvent,
PluginHookCronChangedEvent,
PluginHookGatewayCronDeliveryStatus,
PluginHookGatewayCronJobState,
@@ -118,6 +124,7 @@ export type {
PluginHookToolContext,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
PluginHookBeforeAgentRunEvent,
PluginHookAfterToolCallEvent,
PluginHookToolResultPersistContext,
PluginHookToolResultPersistEvent,
@@ -184,6 +191,7 @@ const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, numbe
agent_end: 30_000,
};
const DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
before_agent_run: 15_000,
before_prompt_build: 15_000,
};
@@ -193,6 +201,7 @@ type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
next: TResult,
registration: PluginHookRegistration<K>,
) => TResult;
mergeNullResults?: boolean;
shouldStop?: (result: TResult) => boolean;
terminalLabel?: string;
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
@@ -252,7 +261,10 @@ export function createHookRunner(
) {
const logger = options.logger;
const catchErrors = options.catchErrors ?? true;
const failurePolicyByHook = options.failurePolicyByHook ?? {};
const failurePolicyByHook = {
before_agent_run: "fail-closed",
...options.failurePolicyByHook,
} satisfies Partial<Record<PluginHookName, HookFailurePolicy>>;
const voidHookTimeoutMsByHook = {
...DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK,
...options.voidHookTimeoutMsByHook,
@@ -577,7 +589,9 @@ export function createHookRunner(
const timeoutMs = getModifyingHookTimeoutMs(hookName, hook);
const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise;
if (handlerResult !== undefined && handlerResult !== null) {
const shouldMergeResult =
handlerResult !== undefined && (handlerResult !== null || policy.mergeNullResults);
if (shouldMergeResult) {
if (policy.mergeResults) {
result = policy.mergeResults(result, handlerResult, hook);
} else {
@@ -1050,7 +1064,57 @@ export function createHookRunner(
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
/**
* Run before_agent_run gate hook.
* Fires after session resolution and workspace preparation, before model inference.
* Returns the most-restrictive pass/block decision from all handlers.
* Handlers that return void are treated as pass.
*/
async function runBeforeAgentRun(
event: PluginHookBeforeAgentRunEvent,
ctx: PluginHookAgentContext,
): Promise<GateHookResult<InputGateDecision> | undefined> {
let winningPluginId: string | undefined;
const decision = await runModifyingHook<"before_agent_run", InputGateDecision | undefined>(
"before_agent_run",
event,
ctx,
{
mergeResults: (_acc, next, reg) => {
if (next === undefined || next === null) {
const normalized: InputGateDecision = {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
};
winningPluginId = reg.pluginId;
return normalized;
}
const normalized: InputGateDecision = isHookDecision(next)
? next
: {
outcome: "block",
reason: "before_agent_run returned an invalid decision",
};
const merged =
!_acc || (normalized.outcome === "block" && _acc.outcome !== "block")
? normalized
: _acc;
if (merged === normalized) {
winningPluginId = reg.pluginId;
}
return merged;
},
mergeNullResults: true,
shouldStop: (result) => result?.outcome === "block",
terminalLabel: "gate-decision",
},
);
if (!decision) {
return undefined;
}
return { decision, pluginId: winningPluginId ?? "unknown" };
}
// Tool Hooks
// =========================================================================
@@ -1396,9 +1460,6 @@ export function createHookRunner(
// Utility
// =========================================================================
/**
* Check if any hooks are registered for a given hook name.
*/
function hasHooks(hookName: PluginHookName): boolean {
return registry.typedHooks.some((h) => h.hookName === hookName);
}
@@ -1426,6 +1487,8 @@ export function createHookRunner(
runBeforeCompaction,
runAfterCompaction,
runBeforeReset,
// Lifecycle gate hooks
runBeforeAgentRun,
// Message hooks
runInboundClaim,
runInboundClaimForPlugin,

View File

@@ -5350,6 +5350,7 @@ module.exports = {
"hook-policy": {
hooks: {
allowPromptInjection: false,
allowConversationAccess: true,
},
},
},
@@ -5465,6 +5466,7 @@ module.exports = {
entries: {
"hook-timeouts": {
hooks: {
allowConversationAccess: true,
timeoutMs: 250,
timeouts: {
before_model_resolve: 750,
@@ -5490,10 +5492,13 @@ module.exports = {
id: "conversation-hooks",
filename: "conversation-hooks.cjs",
body: `module.exports = { id: "conversation-hooks", register(api) {
api.on("before_model_resolve", () => undefined);
api.on("before_agent_reply", () => undefined);
api.on("llm_input", () => undefined);
api.on("llm_output", () => undefined);
api.on("before_agent_finalize", () => undefined);
api.on("agent_end", () => undefined);
api.on("before_agent_run", () => undefined);
} };`,
});
@@ -5510,7 +5515,7 @@ module.exports = {
"non-bundled plugins must set plugins.entries.conversation-hooks.hooks.allowConversationAccess=true",
),
);
expect(blockedDiagnostics).toHaveLength(4);
expect(blockedDiagnostics).toHaveLength(7);
});
it("allows conversation typed hooks for non-bundled plugins when explicitly enabled", () => {
@@ -5519,10 +5524,13 @@ module.exports = {
id: "conversation-hooks-allowed",
filename: "conversation-hooks-allowed.cjs",
body: `module.exports = { id: "conversation-hooks-allowed", register(api) {
api.on("before_model_resolve", () => undefined);
api.on("before_agent_reply", () => undefined);
api.on("llm_input", () => undefined);
api.on("llm_output", () => undefined);
api.on("before_agent_finalize", () => undefined);
api.on("agent_end", () => undefined);
api.on("before_agent_run", () => undefined);
} };`,
});
@@ -5541,10 +5549,13 @@ module.exports = {
});
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([
"before_model_resolve",
"before_agent_reply",
"llm_input",
"llm_output",
"before_agent_finalize",
"agent_end",
"before_agent_run",
]);
});
@@ -5564,6 +5575,13 @@ module.exports = {
plugin,
pluginConfig: {
allow: ["hook-unknown"],
entries: {
"hook-unknown": {
hooks: {
allowConversationAccess: true,
},
},
},
},
});

View File

@@ -5,6 +5,7 @@ export type SessionTranscriptUpdate = {
sessionKey?: string;
message?: unknown;
messageId?: string;
forceHistoryRefresh?: boolean;
};
type SessionTranscriptListener = (update: SessionTranscriptUpdate) => void;
@@ -27,6 +28,7 @@ export function emitSessionTranscriptUpdate(update: string | SessionTranscriptUp
sessionKey: update.sessionKey,
message: update.message,
messageId: update.messageId,
forceHistoryRefresh: update.forceHistoryRefresh,
};
const trimmed = normalizeOptionalString(normalized.sessionFile);
if (!trimmed) {
@@ -41,6 +43,7 @@ export function emitSessionTranscriptUpdate(update: string | SessionTranscriptUp
...(normalizeOptionalString(normalized.messageId)
? { messageId: normalizeOptionalString(normalized.messageId) }
: {}),
...(normalized.forceHistoryRefresh === true ? { forceHistoryRefresh: true } : {}),
};
for (const listener of SESSION_TRANSCRIPT_LISTENERS) {
try {

View File

@@ -225,6 +225,13 @@
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "JSON"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "The agent cannot read this message."
},
{
"count": 1,
"kind": "html-text",

View File

@@ -388,6 +388,16 @@ img.chat-avatar {
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
}
.chat-blocked-user-note {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--chat-text) 18%, transparent);
color: color-mix(in srgb, var(--chat-text) 68%, transparent);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
}
/* Streaming animation */
.chat-bubble.streaming {
animation: pulsing-border 1.5s ease-out infinite;

View File

@@ -925,6 +925,40 @@ describe("connectGateway", () => {
expect(loadChatHistoryMock).not.toHaveBeenCalled();
});
it("replays deferred blocked user reloads after renderable final assistant payloads", () => {
const { host, client } = connectHostGateway();
host.chatRunId = "main-run-blocked";
loadChatHistoryMock.mockClear();
client.emitEvent({
event: "session.message",
payload: {
sessionKey: "main",
messageId: "blocked-1",
message: {
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
},
},
});
client.emitEvent({
event: "chat",
payload: {
runId: "main-run-blocked",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "The agent cannot read this message." }],
},
},
});
expect(host.chatRunId).toBeNull();
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
});
it("replays deferred session.message reloads after legacy silent final payload", () => {
const { host, client } = connectHostGateway();
host.chatRunId = "main-run-silent";

View File

@@ -112,6 +112,7 @@ type GatewayHost = {
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
pendingSessionMessageReloadSessionKey?: string | null;
pendingSessionMessageReloadNeedsHistory?: boolean;
};
type SessionDefaultsSnapshot = {
@@ -653,9 +654,12 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
);
const shouldReplayDeferredSessionMessageReload =
shouldResolveDeferredSessionMessageReload &&
(state !== "final" || finalEventNeedsHistoryReload);
(state !== "final" ||
finalEventNeedsHistoryReload ||
deferredReloadHost.pendingSessionMessageReloadNeedsHistory === true);
if (shouldResolveDeferredSessionMessageReload) {
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
deferredReloadHost.pendingSessionMessageReloadNeedsHistory = false;
}
if (finalEventNeedsHistoryReload && !historyReloaded && !terminalEventIsForDifferentActiveRun) {
void loadChatHistory(host as unknown as ChatState);
@@ -668,7 +672,7 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
function handleSessionMessageGatewayEvent(
host: GatewayHost,
payload: { sessionKey?: string } | undefined,
payload: { sessionKey?: string; message?: unknown; messageId?: string } | undefined,
) {
applySessionsChangedEvent(host as unknown as SessionsState, payload);
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
@@ -683,9 +687,20 @@ function handleSessionMessageGatewayEvent(
// first LLM delta arrives.
if (host.chatRunId) {
deferredReloadHost.pendingSessionMessageReloadSessionKey = sessionKey;
const messageRecord =
payload?.message && typeof payload.message === "object"
? (payload.message as { role?: unknown })
: undefined;
if (
messageRecord?.role === "user" ||
(typeof payload?.messageId === "string" && payload.messageId.startsWith("blocked-"))
) {
deferredReloadHost.pendingSessionMessageReloadNeedsHistory = true;
}
return;
}
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
deferredReloadHost.pendingSessionMessageReloadNeedsHistory = false;
void loadChatHistory(host as unknown as ChatState);
}

View File

@@ -657,6 +657,28 @@ describe("grouped chat rendering", () => {
expect(avatar?.tagName).toBe("DIV");
});
it("renders blocked user originals with an agent-readable notice", () => {
const container = document.createElement("div");
renderGroupedMessage(
container,
{
role: "user",
content: [{ type: "text", text: "The agent cannot read this message." }],
__openclaw: {
originalBlockedContent: {
content: [{ type: "text", text: "secret blocked prompt" }],
},
},
timestamp: 1000,
},
"user",
);
expect(container.textContent).toContain("secret blocked prompt");
expect(container.textContent).toContain("The agent cannot read this message.");
expect(container.querySelector(".chat-blocked-user-note")).not.toBeNull();
});
it("keeps inline tool cards collapsed by default and renders expanded state", () => {
const container = document.createElement("div");
const message = {

View File

@@ -1433,6 +1433,8 @@ 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 canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
@@ -1608,6 +1610,9 @@ function renderGroupedMessage(
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${isBlockedUserMessage
? html`<div class="chat-blocked-user-note">The agent cannot read this message.</div>`
: nothing}
${hasToolCards
? renderInlineToolCards(toolCards, {
messageKey,

View File

@@ -22,6 +22,30 @@ 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,6 +42,29 @@ 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,12 +240,22 @@ 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;
}
}
// Detect tool messages by common gateway shapes.
// Some tool events come through as assistant role with tool_* items in the content array.
const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const contentRaw = m.content;
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
const hasToolContent =
Array.isArray(contentItems) &&
@@ -266,17 +276,17 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
let audioAsVoice = false;
let replyTarget: NormalizedMessage["replyTarget"] = null;
if (typeof m.content === "string") {
if (typeof contentRaw === "string") {
if (isAssistantMessage) {
const expanded = expandTextContent(m.content);
const expanded = expandTextContent(contentRaw);
content = expanded.content;
audioAsVoice = expanded.audioAsVoice;
replyTarget = expanded.replyTarget;
} else {
content = [{ type: "text", text: m.content }];
content = [{ type: "text", text: contentRaw }];
}
} else if (Array.isArray(m.content)) {
content = m.content.flatMap((item: Record<string, unknown>) => {
} else if (Array.isArray(contentRaw)) {
content = contentRaw.flatMap((item: Record<string, unknown>) => {
if (
item.type === "attachment" &&
item.attachment &&
@@ -386,6 +396,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
timestamp,
id,
senderLabel,
...(isBlockedOriginalContent ? { isBlockedOriginalContent: true } : {}),
...(audioAsVoice ? { audioAsVoice: true } : {}),
...(replyTarget ? { replyTarget } : {}),
};

View File

@@ -29,6 +29,7 @@ describe("parsePluginApprovalRequested", () => {
pluginId: "sage",
agentId: "agent-1",
sessionKey: "sess-1",
allowedDecisions: ["allow-once", "deny"],
},
};
@@ -41,12 +42,33 @@ describe("parsePluginApprovalRequested", () => {
expect(result!.pluginSeverity).toBe("high");
expect(result!.pluginId).toBe("sage");
expect(result!.request.command).toBe("Dangerous command detected");
expect(result!.request.allowedDecisions).toEqual(["allow-once", "deny"]);
expect(result!.request.agentId).toBe("agent-1");
expect(result!.request.sessionKey).toBe("sess-1");
expect(result!.createdAtMs).toBe(1000);
expect(result!.expiresAtMs).toBe(120_000);
});
it("preserves an explicitly empty allowedDecisions list", () => {
const result = parsePluginApprovalRequested({
...validPayload,
request: { ...validPayload.request, allowedDecisions: [] },
});
expect(result).not.toBeNull();
expect(result!.request.allowedDecisions).toEqual([]);
});
it("drops invalid allowedDecisions without falling back to all actions", () => {
const result = parsePluginApprovalRequested({
...validPayload,
request: { ...validPayload.request, allowedDecisions: ["bad-decision"] },
});
expect(result).not.toBeNull();
expect(result!.request.allowedDecisions).toEqual([]);
});
it("returns null when title is missing from request", () => {
const {
request: { title: _, ...restRequest },

View File

@@ -1,11 +1,14 @@
import { normalizeOptionalString } from "../string-coerce.ts";
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalRequestPayload = {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[];
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
@@ -34,6 +37,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseAllowedDecisions(value: unknown): ExecApprovalDecision[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const decisions = value.filter(
(decision): decision is ExecApprovalDecision =>
decision === "allow-once" || decision === "allow-always" || decision === "deny",
);
return decisions;
}
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
if (!isRecord(payload)) {
return null;
@@ -61,6 +75,7 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques
host: typeof request.host === "string" ? request.host : null,
security: typeof request.security === "string" ? request.security : null,
ask: typeof request.ask === "string" ? request.ask : null,
allowedDecisions: parseAllowedDecisions(request.allowedDecisions),
agentId: typeof request.agentId === "string" ? request.agentId : null,
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
@@ -114,6 +129,7 @@ export function parsePluginApprovalRequested(payload: unknown): ExecApprovalRequ
kind: "plugin",
request: {
command: title,
allowedDecisions: parseAllowedDecisions(request.allowedDecisions),
agentId: typeof request.agentId === "string" ? request.agentId : null,
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
},

View File

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

View File

@@ -152,6 +152,32 @@ describe("approval and confirmation modals", () => {
expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny");
});
it("does not map Escape to denial when deny is not allowed", async () => {
const handleExecApprovalDecision = vi.fn(async () => undefined);
render(
renderExecApprovalPrompt(
createExecState({
execApprovalQueue: [
{
...createExecRequest(),
request: {
...createExecRequest().request,
allowedDecisions: ["allow-once"],
},
},
],
handleExecApprovalDecision,
}),
),
container,
);
const { dialog } = await getRenderedDialog();
dispatchEscape(dialog);
expect(handleExecApprovalDecision).not.toHaveBeenCalled();
});
it("does not dispatch an extra exec decision from Escape while busy", async () => {
const handleExecApprovalDecision = vi.fn(async () => undefined);
render(

View File

@@ -83,8 +83,13 @@ export function renderExecApprovalPrompt(state: AppViewState) {
: t("execApproval.execApprovalNeeded");
const titleId = "exec-approval-title";
const descriptionId = "exec-approval-description";
const allowedDecisions = active.request.allowedDecisions ?? [
"allow-once",
"allow-always",
"deny",
];
const handleCancel = () => {
if (!state.execApprovalBusy) {
if (!state.execApprovalBusy && allowedDecisions.includes("deny")) {
void state.handleExecApprovalDecision("deny");
}
};
@@ -107,27 +112,33 @@ export function renderExecApprovalPrompt(state: AppViewState) {
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing}
<div class="exec-approval-actions">
<button
class="btn primary"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-once")}
>
${t("execApproval.allowOnce")}
</button>
<button
class="btn"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-always")}
>
${t("execApproval.alwaysAllow")}
</button>
<button
class="btn danger"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("deny")}
>
${t("execApproval.deny")}
</button>
${allowedDecisions.includes("allow-once")
? html`<button
class="btn primary"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-once")}
>
${t("execApproval.allowOnce")}
</button>`
: nothing}
${allowedDecisions.includes("allow-always")
? html`<button
class="btn"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-always")}
>
${t("execApproval.alwaysAllow")}
</button>`
: nothing}
${allowedDecisions.includes("deny")
? html`<button
class="btn danger"
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("deny")}
>
${t("execApproval.deny")}
</button>`
: nothing}
</div>
</div>
</openclaw-modal-dialog>