diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1d9a7bbdf..d290b595f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 17f4d82d55e..b14d30393dc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index f6d08c535c5..822546ef582 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -4,6 +4,7 @@ data class ChatMessage( val id: String, val role: String, val content: List, + val originalBlockedContent: List = emptyList(), val timestampMs: Long?, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index ecf4a7b0590..23a142e91e6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -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), + ) + } + } + } } } diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index d5add00d2db..51ee02ea5f8 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -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, diff --git a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift index d970b7068a3..8c4d747017c 100644 --- a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift +++ b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift @@ -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() diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 60748dacc33..9cba430d32e 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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 { diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 5a10a8a43d7..5b709c725af 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 98fa9e55d29..f7461304e0f 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -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" } } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 261b94b02c4..7f876cbfb4d 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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( diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index a76e925f931..b134bc1c58c 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index d24f92bd42c..e03d16a41b6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -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() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index bf77fe037de..c82e46f4ff1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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) + } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index e647435008f..28433f52dee 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -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, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a76e925f931..b134bc1c58c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index e33c2890c39..4aea35ff208 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -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 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index a604b62cd35..d7b7662955d 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0529d8fb509..8d79f2ac78b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -275,7 +275,7 @@ For runtime hook debugging: - `openclaw plugins inspect --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..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..hooks.allowConversationAccess=true`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ab11b45c76e..45c45069d7e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -195,7 +195,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..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..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..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..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..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..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index bae78123ccf..feff57a3ded 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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 { diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 5ae87a01210..f1e021d90b9 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -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>([ + ["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" + diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 25b1501fc31..cab64853532 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -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"), diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 99a496b2f11..3369f862a3b 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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 => { + 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[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["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, diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts index dca82e48b6b..41bdebb1f1b 100644 --- a/src/agents/cli-runner/session-history.ts +++ b/src/agents/cli-runner/session-history.ts @@ -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; + const openclaw = + record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw) + ? (record.__openclaw as Record) + : 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 { 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 [ { diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index b9e413e0f83..c57fa58501a 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -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( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index e1f79574521..46d3f826d6f 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index c74c060608c..bc73c8d4ba9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -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[0], + ) as Array>; + + 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 { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ecce4cb8638..753a3d5516d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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) + : 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): 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 => { + 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[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["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( { diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 621b3a02a8b..0fced82fc4d 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 3b8401436ab..a669ccda869 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -140,7 +140,8 @@ export type EmbeddedPiRunMeta = { | "compaction_failure" | "role_ordering" | "image_size" - | "retry_limit"; + | "retry_limit" + | "hook_block"; message: string; }; failureSignal?: EmbeddedRunFailureSignal; diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 7d95d1ba5f4..e2f772ee9b7 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -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(() => {})); diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index d810e676a1a..54ec0f05241 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -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( { diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 9141db26bbe..227266b4994 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -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>( clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, + deviceIdentity: opts.deviceIdentity, scopes, }); } diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 18778d6d819..1ae9f9a749a 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -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(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 160fd18cde1..a176f7d6390 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index cd6831cb13c..b7f5e592505 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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) { diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index 91e9f68d95f..1683b33ca98 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -10,6 +10,7 @@ export type DispatchFromConfigResult = { counts: Record; failedCounts?: Partial>; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + beforeAgentRunBlocked?: boolean; }; export type DispatchFromConfigParams = { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index ba24a6faa4d..f26f2d04d6f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1242,7 +1242,7 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/sessions/transcript-append.ts b/src/config/sessions/transcript-append.ts index 9f88b786427..b597dae5f65 100644 --- a/src/config/sessions/transcript-append.ts +++ b/src/config/sessions/transcript-append.ts @@ -26,6 +26,10 @@ type TranscriptLeafInfo = { nonSessionEntryCount: number; }; +export type TranscriptRawAppendParentLink = { + parentId?: string | null; +}; + async function yieldTranscriptAppendScan(): Promise { await new Promise((resolve) => setImmediate(resolve)); } @@ -229,6 +233,32 @@ async function withTranscriptAppendQueue( } } +export async function resolveTranscriptRawAppendParentLink(params: { + transcriptPath: string; + useRawWhenLinear?: boolean; +}): Promise { + 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, }; diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 7ebd91e490e..4e1c77fe3a2 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -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"; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 6025a11b44f..fade95eccad 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -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; diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index 9b710d3bb79..cfcb62a5ac0 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -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 }; } diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index 01468e0c230..301199c2baa 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -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 }, ); diff --git a/src/gateway/protocol/schema/plugin-approvals.ts b/src/gateway/protocol/schema/plugin-approvals.ts index 4aaa8e12a04..04c031c5fa6 100644 --- a/src/gateway/protocol/schema/plugin-approvals.ts +++ b/src/gateway/protocol/schema/plugin-approvals.ts @@ -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()), }, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index a1d076ee1a4..7c9e47a63ec 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -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, 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; +}) { + 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"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e04cd5b7a6b..af6aa34860a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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 | 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)}`, ); diff --git a/src/gateway/server-methods/plugin-approval.test.ts b/src/gateway/server-methods/plugin-approval.test.ts index 0a8e6c95c25..7f6214c9e0a 100644 --- a/src/gateway/server-methods/plugin-approval.test.ts +++ b/src/gateway/server-methods/plugin-approval.test.ts @@ -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)?.status === "accepted", + ); + const approvalId = (acceptedCall?.[1] as Record)?.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)?.status === "accepted", + ); + const approvalId = (acceptedCall?.[1] as Record)?.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", { diff --git a/src/gateway/server-methods/plugin-approval.ts b/src/gateway/server-methods/plugin-approval.ts index 314bfe3d4ba..824c5e051b7 100644 --- a/src/gateway/server-methods/plugin-approval.ts +++ b/src/gateway/server-methods/plugin-approval.ts @@ -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, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 29435f94769..cb79eae8d1d 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -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", () => { diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 8baccf50699..79e996e72cb 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -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 } : {}), }); diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index 4847b8cb0f7..b80f5393e82 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -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"); + }); }); diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 5673bcb9ad7..10e1f62f44e 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -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, }, ), }; diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index a7f13056e80..7f122ae7dfc 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -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({ diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 53f5013e0ca..4e0c62e4e44 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -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[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", () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 5db2edba50a..632dab7a502 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -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; + const existing = + record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw) + ? (record.__openclaw as Record) + : 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(); 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 { 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; if (entry.message) { - return attachOpenClawTranscriptMeta(entry.message, { + const messageRecord = + entry.message && typeof entry.message === "object" && !Array.isArray(entry.message) + ? (entry.message as Record) + : undefined; + const messageOpenClaw = + messageRecord?.__openclaw && + typeof messageRecord.__openclaw === "object" && + !Array.isArray(messageRecord.__openclaw) + ? (messageRecord.__openclaw as Record) + : 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] : []; } diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index be80b252324..4713b42e111 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -118,6 +118,7 @@ export { readSessionTitleFieldsFromTranscriptAsync, readSessionPreviewItemsFromTranscript, readSessionMessagesAsync, + stripBlockedOriginalContentMeta, visitSessionMessagesAsync, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index 5b745c0fd65..8d74a491027 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -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) }; + 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(); diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 31dcf597c45..0805048d347 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -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[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, diff --git a/src/infra/approval-native-route-notice.test.ts b/src/infra/approval-native-route-notice.test.ts index dac2f715fbf..3410596179a 100644 --- a/src/infra/approval-native-route-notice.test.ts +++ b/src/infra/approval-native-route-notice.test.ts @@ -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"); + }); +}); diff --git a/src/infra/approval-native-route-notice.ts b/src/infra/approval-native-route-notice.ts index ec26e563c4b..f166dd0ecc1 100644 --- a/src/infra/approval-native-route-notice.ts +++ b/src/infra/approval-native-route-notice.ts @@ -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}`, diff --git a/src/infra/approval-view-model.ts b/src/infra/approval-view-model.ts index 4121e729ff6..3d31620867e 100644 --- a/src/infra/approval-view-model.ts +++ b/src/infra/approval-view-model.ts @@ -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, }; diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index f5bd68252f1..ebb53d72472 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -461,6 +461,7 @@ function buildPluginPendingPayload(params: { request: params.request, nowMs: params.nowMs, text: buildPluginApprovalRequestMessage(params.request, params.nowMs), + allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request), }), }); } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index ebefb855a05..2f853b74c0e 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -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: { diff --git a/src/infra/plugin-approvals.ts b/src/infra/plugin-approvals.ts index c701dcf3da8..95e3c96cdf1 100644 --- a/src/infra/plugin-approvals.ts +++ b/src/infra/plugin-approvals.ts @@ -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 allow-once|allow-always|deny"); + const decisions = resolveExecApprovalRequestAllowedDecisions(request.request); + lines.push(`Reply with: /approve ${decisions.join("|")}`); return lines.join("\n"); } diff --git a/src/plugins/hook-decision-types.test.ts b/src/plugins/hook-decision-types.test.ts new file mode 100644 index 00000000000..ed7a10f79bc --- /dev/null +++ b/src/plugins/hook-decision-types.test.ts @@ -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); + }); + }); +}); diff --git a/src/plugins/hook-decision-types.ts b/src/plugins/hook-decision-types.ts new file mode 100644 index 00000000000..cedf2371a64 --- /dev/null +++ b/src/plugins/hook-decision-types.ts @@ -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; +}; + +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 = { + 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; + 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 = { + decision: TDecision; + pluginId: string; +}; diff --git a/src/plugins/hook-lifecycle-gates.test.ts b/src/plugins/hook-lifecycle-gates.test.ts new file mode 100644 index 00000000000..07fda2ab895 --- /dev/null +++ b/src/plugins/hook-lifecycle-gates.test.ts @@ -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(() => {}), + 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; + 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"); + }); +}); diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index a25af87c09a..5c5c0a75b94 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -41,6 +41,7 @@ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): }, catchErrors: true, failurePolicyByHook: { + before_agent_run: "fail-closed", before_tool_call: "fail-closed", }, }); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 31509a71e59..d3e7d4935e6 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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; @@ -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?: ( 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; }; @@ -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; + before_agent_run: ( + event: PluginHookBeforeAgentRunEvent, + ctx: PluginHookAgentContext, + ) => Promise | PluginHookBeforeAgentRunResult; }; export type PluginHookRegistration = { diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index fe3035e08ab..2444f1eae92 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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> = { + before_agent_run: 15_000, before_prompt_build: 15_000, }; @@ -193,6 +201,7 @@ type ModifyingHookPolicy = { next: TResult, registration: PluginHookRegistration, ) => 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>; 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 | 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, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index b92c3a6814f..4350363a26a 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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, + }, + }, + }, }, }); diff --git a/src/sessions/transcript-events.ts b/src/sessions/transcript-events.ts index 4c540d209ef..956a79069e7 100644 --- a/src/sessions/transcript-events.ts +++ b/src/sessions/transcript-events.ts @@ -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 { diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 234927e5656..f3480f7dd44 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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", diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 439bf143a0a..664a9b4bb01 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 21b43965fa2..b5271c6f5be 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -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"; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 1bb37041935..a27a935d4ea 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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); } diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 25c91472612..aa2da92d5bb 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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 = { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 581e66fd05e..951291ffafd 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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))} ` : nothing} + ${isBlockedUserMessage + ? html`
The agent cannot read this message.
` + : nothing} ${hasToolCards ? renderInlineToolCards(toolCards, { messageKey, diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index a233a0d7c6e..d14d3c1fd23 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -22,6 +22,30 @@ function processMessageText(text: string, role: string): string { export function extractText(message: unknown): string | null { const m = message as Record; 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 | 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; + 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) { diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index d41bbe1a0ba..e75b04bdb49 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -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", diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index dc090e97daa..1057671da16 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -240,12 +240,22 @@ function expandTextContent(text: string): { export function normalizeMessage(message: unknown): NormalizedMessage { const m = message as Record; 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 | 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) => { + } else if (Array.isArray(contentRaw)) { + content = contentRaw.flatMap((item: Record) => { 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 } : {}), }; diff --git a/ui/src/ui/controllers/exec-approval.test.ts b/ui/src/ui/controllers/exec-approval.test.ts index 5e913185e81..12ede771a30 100644 --- a/ui/src/ui/controllers/exec-approval.test.ts +++ b/ui/src/ui/controllers/exec-approval.test.ts @@ -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 }, diff --git a/ui/src/ui/controllers/exec-approval.ts b/ui/src/ui/controllers/exec-approval.ts index bd44c726939..43cea2f4998 100644 --- a/ui/src/ui/controllers/exec-approval.ts +++ b/ui/src/ui/controllers/exec-approval.ts @@ -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 { 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, }, diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index eb937635cf4..f9df1825bd6 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -58,6 +58,7 @@ export type NormalizedMessage = { timestamp: number; id?: string; senderLabel?: string | null; + isBlockedOriginalContent?: boolean; audioAsVoice?: boolean; replyTarget?: | { diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts index c25542490db..3a4e2e629ad 100644 --- a/ui/src/ui/views/exec-approval.test.ts +++ b/ui/src/ui/views/exec-approval.test.ts @@ -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( diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts index ff5b2fed8ab..44a5c68eb7f 100644 --- a/ui/src/ui/views/exec-approval.ts +++ b/ui/src/ui/views/exec-approval.ts @@ -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`
${state.execApprovalError}
` : nothing}
- - - + ${allowedDecisions.includes("allow-once") + ? html`` + : nothing} + ${allowedDecisions.includes("allow-always") + ? html`` + : nothing} + ${allowedDecisions.includes("deny") + ? html`` + : nothing}