mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:00:41 +00:00
fix: add before-agent-run blocking hook
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`):
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = {
|
||||
| "compaction_failure"
|
||||
| "role_ordering"
|
||||
| "image_size"
|
||||
| "retry_limit";
|
||||
| "retry_limit"
|
||||
| "hook_block";
|
||||
message: string;
|
||||
};
|
||||
failureSignal?: EmbeddedRunFailureSignal;
|
||||
|
||||
@@ -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>(() => {}));
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DispatchFromConfigResult = {
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
beforeAgentRunBlocked?: boolean;
|
||||
};
|
||||
|
||||
export type DispatchFromConfigParams = {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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] : [];
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export {
|
||||
readSessionTitleFieldsFromTranscriptAsync,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
readSessionMessagesAsync,
|
||||
stripBlockedOriginalContentMeta,
|
||||
visitSessionMessagesAsync,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -461,6 +461,7 @@ function buildPluginPendingPayload(params: {
|
||||
request: params.request,
|
||||
nowMs: params.nowMs,
|
||||
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
71
src/plugins/hook-decision-types.test.ts
Normal file
71
src/plugins/hook-decision-types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
src/plugins/hook-decision-types.ts
Normal file
102
src/plugins/hook-decision-types.ts
Normal 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;
|
||||
};
|
||||
350
src/plugins/hook-lifecycle-gates.test.ts
Normal file
350
src/plugins/hook-lifecycle-gates.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry):
|
||||
},
|
||||
catchErrors: true,
|
||||
failurePolicyByHook: {
|
||||
before_agent_run: "fail-closed",
|
||||
before_tool_call: "fail-closed",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ export type NormalizedMessage = {
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
senderLabel?: string | null;
|
||||
isBlockedOriginalContent?: boolean;
|
||||
audioAsVoice?: boolean;
|
||||
replyTarget?:
|
||||
| {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user