From 15240bdbfe05e6a34e570334248ec7e74498f5ba Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Tue, 3 Feb 2026 23:16:56 +1100 Subject: [PATCH 01/95] feature/OPENCLAW_IMAGE --- docker-setup.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index 8c67dc0962d..7f112c6053e 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -247,12 +247,17 @@ upsert_env "$ENV_FILE" \ OPENCLAW_HOME_VOLUME \ OPENCLAW_DOCKER_APT_PACKAGES -echo "==> Building Docker image: $IMAGE_NAME" -docker build \ - --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ - -t "$IMAGE_NAME" \ - -f "$ROOT_DIR/Dockerfile" \ - "$ROOT_DIR" +if [ "$IMAGE_NAME" == "openclaw:local" ]; then + echo "==> Building Docker image: $IMAGE_NAME" + docker build \ + --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/Dockerfile" \ + "$ROOT_DIR" +else + echo "==> Pulling Docker image: $IMAGE_NAME" + docker pull "$IMAGE_NAME" +fi echo "" echo "==> Onboarding (interactive)" From c7f88e85b7a3b72ac0039b9b884afe49092e0402 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Tue, 3 Feb 2026 23:27:34 +1100 Subject: [PATCH 02/95] feature/OPENCLAW_IMAGE --- docker-setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-setup.sh b/docker-setup.sh index 7f112c6053e..a9d79aac833 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -247,7 +247,7 @@ upsert_env "$ENV_FILE" \ OPENCLAW_HOME_VOLUME \ OPENCLAW_DOCKER_APT_PACKAGES -if [ "$IMAGE_NAME" == "openclaw:local" ]; then +if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" docker build \ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ From 98292331d50949bfb880b341e4ebd1f275da0358 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Wed, 4 Feb 2026 10:43:30 +1100 Subject: [PATCH 03/95] Update docker-setup.sh Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docker-setup.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docker-setup.sh b/docker-setup.sh index a9d79aac833..f9b20949db0 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -254,6 +254,17 @@ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then -t "$IMAGE_NAME" \ -f "$ROOT_DIR/Dockerfile" \ "$ROOT_DIR" +else + echo "==> Pulling Docker image: $IMAGE_NAME" + if ! docker pull "$IMAGE_NAME"; then + echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2 + exit 1 + fi +fi + --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/Dockerfile" \ + "$ROOT_DIR" else echo "==> Pulling Docker image: $IMAGE_NAME" docker pull "$IMAGE_NAME" From a898acbd55c1ef267f1f4cf858d03ee76129d0f1 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Wed, 4 Feb 2026 10:48:28 +1100 Subject: [PATCH 04/95] feature/OPENCLAW_IMAGE --- docker-setup.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index f9b20949db0..c0cd925c4c3 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -261,14 +261,6 @@ else exit 1 fi fi - --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ - -t "$IMAGE_NAME" \ - -f "$ROOT_DIR/Dockerfile" \ - "$ROOT_DIR" -else - echo "==> Pulling Docker image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" -fi echo "" echo "==> Onboarding (interactive)" From 8f3310000a8b0c11eced054c2cdb6fb27803511a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:17:03 +0100 Subject: [PATCH 05/95] refactor(macos): remove anthropic oauth onboarding flow --- CHANGELOG.md | 1 + .../OpenClaw/AnthropicAuthControls.swift | 234 ----------- .../Sources/OpenClaw/AnthropicOAuth.swift | 383 ------------------ .../OpenClaw/AnthropicOAuthCodeState.swift | 59 --- apps/macos/Sources/OpenClaw/Onboarding.swift | 27 -- .../OpenClaw/OnboardingView+Actions.swift | 66 --- .../OpenClaw/OnboardingView+Layout.swift | 2 - .../OpenClaw/OnboardingView+Monitoring.swift | 78 ---- .../OpenClaw/OnboardingView+Pages.swift | 166 -------- .../OpenClaw/OnboardingView+Testing.swift | 9 - .../AnthropicAuthControlsSmokeTests.swift | 29 -- .../AnthropicAuthResolverTests.swift | 52 --- .../AnthropicOAuthCodeStateTests.swift | 31 -- .../OpenClawOAuthStoreTests.swift | 97 ----- docs/start/onboarding.md | 4 +- 15 files changed, 3 insertions(+), 1235 deletions(-) delete mode 100644 apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift delete mode 100644 apps/macos/Sources/OpenClaw/AnthropicOAuth.swift delete mode 100644 apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efa7d35cb3..21d78689220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- macOS/Onboarding: remove Anthropic OAuth sign-in from the Mac onboarding UI and keep Anthropic subscription auth setup-token-only (legacy `oauth.json` OAuth onboarding path removed). - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. diff --git a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift b/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift deleted file mode 100644 index 06f107d6c6e..00000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift +++ /dev/null @@ -1,234 +0,0 @@ -import AppKit -import Combine -import SwiftUI - -@MainActor -struct AnthropicAuthControls: View { - let connectionMode: AppState.ConnectionMode - - @State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus() - @State private var pkce: AnthropicOAuth.PKCE? - @State private var code: String = "" - @State private var busy = false - @State private var statusText: String? - @State private var autoDetectClipboard = true - @State private var autoConnectClipboard = true - @State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount - - private static let clipboardPoll: AnyPublisher = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - if self.connectionMode != .local { - Text("Gateway isn’t running locally; OAuth must be created on the gateway host.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 10) { - Circle() - .fill(self.oauthStatus.isConnected ? Color.green : Color.orange) - .frame(width: 8, height: 8) - Text(self.oauthStatus.shortDescription) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer() - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path)) - - Button("Refresh") { - self.refresh() - } - .buttonStyle(.bordered) - } - - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - - HStack(spacing: 12) { - Button { - self.startOAuth() - } label: { - if self.busy { - ProgressView().controlSize(.small) - } else { - Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.connectionMode != .local || self.busy) - - if self.pkce != nil { - Button("Cancel") { - self.pkce = nil - self.code = "" - self.statusText = nil - } - .buttonStyle(.bordered) - .disabled(self.busy) - } - } - - if self.pkce != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste `code#state`") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - - TextField("code#state", text: self.$code) - .textFieldStyle(.roundedBorder) - .disabled(self.busy) - - Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Button("Connect") { - Task { await self.finishOAuth() } - } - .buttonStyle(.bordered) - .disabled(self.busy || self.connectionMode != .local || self.code - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty) - } - } - - if let statusText, !statusText.isEmpty { - Text(statusText) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .onAppear { - self.refresh() - } - .onReceive(Self.clipboardPoll) { _ in - self.pollClipboardIfNeeded() - } - } - - private func refresh() { - let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus() - if imported != nil { - self.statusText = "Imported existing OAuth credentials." - } - } - - private func startOAuth() { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - self.busy = true - defer { self.busy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.pkce = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.statusText = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.statusText = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - private func finishOAuth() async { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - guard let pkce = self.pkce else { return } - self.busy = true - defer { self.busy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else { - self.statusText = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refresh() - self.pkce = nil - self.code = "" - self.statusText = "Connected. OpenClaw can now use Claude via OAuth." - } catch { - self.statusText = "OAuth failed: \(error.localizedDescription)" - } - } - - private func pollClipboardIfNeeded() { - guard self.connectionMode == .local else { return } - guard self.pkce != nil else { return } - guard !self.busy else { return } - guard self.autoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.lastPasteboardChangeCount else { return } - self.lastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.pkce, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.code != next { - self.code = next - self.statusText = "Detected `code#state` from clipboard." - } - - guard self.autoConnectClipboard else { return } - Task { await self.finishOAuth() } - } -} - -#if DEBUG -extension AnthropicAuthControls { - init( - connectionMode: AppState.ConnectionMode, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus, - pkce: AnthropicOAuth.PKCE? = nil, - code: String = "", - busy: Bool = false, - statusText: String? = nil, - autoDetectClipboard: Bool = true, - autoConnectClipboard: Bool = true) - { - self.connectionMode = connectionMode - self._oauthStatus = State(initialValue: oauthStatus) - self._pkce = State(initialValue: pkce) - self._code = State(initialValue: code) - self._busy = State(initialValue: busy) - self._statusText = State(initialValue: statusText) - self._autoDetectClipboard = State(initialValue: autoDetectClipboard) - self._autoConnectClipboard = State(initialValue: autoConnectClipboard) - self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift deleted file mode 100644 index f594cc04c31..00000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift +++ /dev/null @@ -1,383 +0,0 @@ -import CryptoKit -import Foundation -import OSLog -import Security - -struct AnthropicOAuthCredentials: Codable { - let type: String - let refresh: String - let access: String - let expires: Int64 -} - -enum AnthropicAuthMode: Equatable { - case oauthFile - case oauthEnv - case apiKeyEnv - case missing - - var shortLabel: String { - switch self { - case .oauthFile: "OAuth (OpenClaw token file)" - case .oauthEnv: "OAuth (env var)" - case .apiKeyEnv: "API key (env var)" - case .missing: "Missing credentials" - } - } - - var isConfigured: Bool { - switch self { - case .missing: false - case .oauthFile, .oauthEnv, .apiKeyEnv: true - } - } -} - -enum AnthropicAuthResolver { - static func resolve( - environment: [String: String] = ProcessInfo.processInfo.environment, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore - .anthropicOAuthStatus()) -> AnthropicAuthMode - { - if oauthStatus.isConnected { return .oauthFile } - - if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return .oauthEnv - } - - if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !key.isEmpty - { - return .apiKeyEnv - } - - return .missing - } -} - -enum AnthropicOAuth { - private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth") - - private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! - private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! - private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" - private static let scopes = "org:create_api_key user:profile user:inference" - - struct PKCE { - let verifier: String - let challenge: String - } - - static func generatePKCE() throws -> PKCE { - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - let verifier = Data(bytes).base64URLEncodedString() - let hash = SHA256.hash(data: Data(verifier.utf8)) - let challenge = Data(hash).base64URLEncodedString() - return PKCE(verifier: verifier, challenge: challenge) - } - - static func buildAuthorizeURL(pkce: PKCE) -> URL { - var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! - components.queryItems = [ - URLQueryItem(name: "code", value: "true"), - URLQueryItem(name: "client_id", value: self.clientId), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: self.redirectURI), - URLQueryItem(name: "scope", value: self.scopes), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - // Match legacy flow: state is the verifier. - URLQueryItem(name: "state", value: pkce.verifier), - ] - return components.url! - } - - static func exchangeCode( - code: String, - state: String, - verifier: String) async throws -> AnthropicOAuthCredentials - { - let payload: [String: Any] = [ - "grant_type": "authorization_code", - "client_id": self.clientId, - "code": code, - "state": state, - "redirect_uri": self.redirectURI, - "code_verifier": verifier, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = decoded?["refresh_token"] as? String - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let refresh, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - // Match legacy flow: expiresAt = now + expires_in - 5 minutes. - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } - - static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { - let payload: [String: Any] = [ - "grant_type": "refresh_token", - "client_id": self.clientId, - "refresh_token": refreshToken, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } -} - -enum OpenClawOAuthStore { - static let oauthFilename = "oauth.json" - private static let providerKey = "anthropic" - private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR" - private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" - - enum AnthropicOAuthStatus: Equatable { - case missingFile - case unreadableFile - case invalidJSON - case missingProviderEntry - case missingTokens - case connected(expiresAtMs: Int64?) - - var isConnected: Bool { - if case .connected = self { return true } - return false - } - - var shortDescription: String { - switch self { - case .missingFile: "OpenClaw OAuth token file not found" - case .unreadableFile: "OpenClaw OAuth token file not readable" - case .invalidJSON: "OpenClaw OAuth token file invalid" - case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file" - case .missingTokens: "Anthropic entry missing tokens" - case .connected: "OpenClaw OAuth credentials found" - } - } - } - - static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - let home = FileManager().homeDirectoryForCurrentUser - return home.appendingPathComponent(".openclaw", isDirectory: true) - .appendingPathComponent("credentials", isDirectory: true) - } - - static func oauthURL() -> URL { - self.oauthDir().appendingPathComponent(self.oauthFilename) - } - - static func legacyOAuthURLs() -> [URL] { - var urls: [URL] = [] - let env = ProcessInfo.processInfo.environment - if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) - } - - let home = FileManager().homeDirectoryForCurrentUser - urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) - - var seen = Set() - return urls.filter { url in - let path = url.standardizedFileURL.path - if seen.contains(path) { return false } - seen.insert(path) - return true - } - } - - static func importLegacyAnthropicOAuthIfNeeded() -> URL? { - let dest = self.oauthURL() - guard !FileManager().fileExists(atPath: dest.path) else { return nil } - - for url in self.legacyOAuthURLs() { - guard FileManager().fileExists(atPath: url.path) else { continue } - guard self.anthropicOAuthStatus(at: url).isConnected else { continue } - guard let storage = self.loadStorage(at: url) else { continue } - do { - try self.saveStorage(storage) - return url - } catch { - continue - } - } - - return nil - } - - static func anthropicOAuthStatus() -> AnthropicOAuthStatus { - self.anthropicOAuthStatus(at: self.oauthURL()) - } - - static func hasAnthropicOAuth() -> Bool { - self.anthropicOAuthStatus().isConnected - } - - static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { - guard FileManager().fileExists(atPath: url.path) else { return .missingFile } - - guard let data = try? Data(contentsOf: url) else { return .unreadableFile } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } - guard let storage = json as? [String: Any] else { return .invalidJSON } - guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } - guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } - - let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) - let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) - guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } - - let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] - let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { - ms - } else if let number = expiresAny as? NSNumber { - number.int64Value - } else if let ms = expiresAny as? Double { - Int64(ms) - } else { - nil - } - - return .connected(expiresAtMs: expiresAtMs) - } - - static func loadAnthropicOAuthRefreshToken() -> String? { - let url = self.oauthURL() - guard let storage = self.loadStorage(at: url) else { return nil } - guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } - let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) - return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func firstString(in dict: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String { return value } - } - return nil - } - - private static func loadStorage(at url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } - return json as? [String: Any] - } - - static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { - let url = self.oauthURL() - let existing: [String: Any] = self.loadStorage(at: url) ?? [:] - - var updated = existing - updated[self.providerKey] = [ - "type": creds.type, - "refresh": creds.refresh, - "access": creds.access, - "expires": creds.expires, - ] - - try self.saveStorage(updated) - } - - private static func saveStorage(_ storage: [String: Any]) throws { - let dir = self.oauthDir() - try FileManager().createDirectory( - at: dir, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - - let url = self.oauthURL() - let data = try JSONSerialization.data( - withJSONObject: storage, - options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } -} - -extension Data { - fileprivate func base64URLEncodedString() -> String { - self.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift deleted file mode 100644 index 2a88898c34d..00000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -enum AnthropicOAuthCodeState { - struct Parsed: Equatable { - let code: String - let state: String - } - - /// Extracts a `code#state` payload from arbitrary text. - /// - /// Supports: - /// - raw `code#state` - /// - OAuth callback URLs containing `code=` and `state=` query params - /// - surrounding text/backticks from instructions pages - static func extract(from raw: String) -> String? { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "`")) - if text.isEmpty { return nil } - - if let fromURL = self.extractFromURL(text) { return fromURL } - if let fromToken = self.extractFromToken(text) { return fromToken } - return nil - } - - static func parse(from raw: String) -> Parsed? { - guard let extracted = self.extract(from: raw) else { return nil } - let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init) - let code = parts.first ?? "" - let state = parts.count > 1 ? parts[1] : "" - guard !code.isEmpty, !state.isEmpty else { return nil } - return Parsed(code: code, state: state) - } - - private static func extractFromURL(_ text: String) -> String? { - // Users might copy the callback URL from the browser address bar. - guard let components = URLComponents(string: text), - let items = components.queryItems, - let code = items.first(where: { $0.name == "code" })?.value, - let state = items.first(where: { $0.name == "state" })?.value, - !code.isEmpty, !state.isEmpty - else { return nil } - - return "\(code)#\(state)" - } - - private static func extractFromToken(_ text: String) -> String? { - // Base64url-ish tokens; keep this fairly strict to avoid false positives. - let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"# - guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } - - let range = NSRange(text.startIndex..? @State var needsBootstrap = false @State var didAutoKickoff = false @State var showAdvancedConnection = false @@ -104,19 +87,9 @@ struct OnboardingView: View { let pageWidth: CGFloat = Self.windowWidth let contentHeight: CGFloat = 460 let connectionPageIndex = 1 - let anthropicAuthPageIndex = 2 let wizardPageIndex = 3 let onboardingChatPageIndex = 8 - static let clipboardPoll: AnyPublisher = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - let permissionsPageIndex = 5 static func pageOrder( for mode: AppState.ConnectionMode, diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index bcd5bd6d44d..a521926ddb9 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -78,70 +78,4 @@ extension OnboardingView { self.copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } } - - func startAnthropicOAuth() { - guard !self.anthropicAuthBusy else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.anthropicAuthPKCE = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - func finishAnthropicOAuth() async { - guard !self.anthropicAuthBusy else { return } - guard let pkce = self.anthropicAuthPKCE else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { - self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude." - } catch { - self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" - } - } - - func pollAnthropicClipboardIfNeeded() { - guard self.currentPage == self.anthropicAuthPageIndex else { return } - guard self.anthropicAuthPKCE != nil else { return } - guard !self.anthropicAuthBusy else { return } - guard self.anthropicAuthAutoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } - self.anthropicAuthLastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.anthropicAuthCode != next { - self.anthropicAuthCode = next - self.anthropicAuthStatus = "Detected `code#state` from clipboard." - } - - guard self.anthropicAuthAutoConnectClipboard else { return } - Task { await self.finishAnthropicOAuth() } - } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index ce87e211ce4..9b0e45e205c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -53,7 +53,6 @@ extension OnboardingView { .onDisappear { self.stopPermissionMonitoring() self.stopDiscovery() - self.stopAuthMonitoring() Task { await self.onboardingWizard.cancelIfRunning() } } .task { @@ -61,7 +60,6 @@ extension OnboardingView { self.refreshCLIStatus() await self.loadWorkspaceDefaults() await self.ensureDefaultWorkspace() - self.refreshAnthropicOAuthStatus() self.refreshBootstrapStatus() self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID() } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index dfbdf91d44d..efe37f31673 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -47,7 +47,6 @@ extension OnboardingView { func updateMonitoring(for pageIndex: Int) { self.updatePermissionMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex) - self.updateAuthMonitoring(for: pageIndex) self.maybeKickoffOnboardingChat(for: pageIndex) } @@ -63,33 +62,6 @@ extension OnboardingView { self.gatewayDiscovery.stop() } - func updateAuthMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local - if shouldMonitor, !self.monitoringAuth { - self.monitoringAuth = true - self.startAuthMonitoring() - } else if !shouldMonitor, self.monitoringAuth { - self.stopAuthMonitoring() - } - } - - func startAuthMonitoring() { - self.refreshAnthropicOAuthStatus() - self.authMonitorTask?.cancel() - self.authMonitorTask = Task { - while !Task.isCancelled { - await MainActor.run { self.refreshAnthropicOAuthStatus() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - func stopAuthMonitoring() { - self.monitoringAuth = false - self.authMonitorTask?.cancel() - self.authMonitorTask = nil - } - func installCLI() async { guard !self.installingCLI else { return } self.installingCLI = true @@ -125,54 +97,4 @@ extension OnboardingView { expected: expected) } } - - func refreshAnthropicOAuthStatus() { - _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - let previous = self.anthropicAuthDetectedStatus - let status = OpenClawOAuthStore.anthropicOAuthStatus() - self.anthropicAuthDetectedStatus = status - self.anthropicAuthConnected = status.isConnected - - if previous != status { - self.anthropicAuthVerified = false - self.anthropicAuthVerificationAttempted = false - self.anthropicAuthVerificationFailed = false - self.anthropicAuthVerifiedAt = nil - } - } - - @MainActor - func verifyAnthropicOAuthIfNeeded(force: Bool = false) async { - guard self.state.connectionMode == .local else { return } - guard self.anthropicAuthDetectedStatus.isConnected else { return } - if self.anthropicAuthVerified, !force { return } - if self.anthropicAuthVerifying { return } - if self.anthropicAuthVerificationAttempted, !force { return } - - self.anthropicAuthVerificationAttempted = true - self.anthropicAuthVerifying = true - self.anthropicAuthVerificationFailed = false - defer { self.anthropicAuthVerifying = false } - - guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else { - self.anthropicAuthStatus = "OAuth verification failed: missing refresh token." - self.anthropicAuthVerificationFailed = true - return - } - - do { - let updated = try await AnthropicOAuth.refresh(refreshToken: refresh) - try OpenClawOAuthStore.saveAnthropicOAuth(updated) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthVerified = true - self.anthropicAuthVerifiedAt = Date() - self.anthropicAuthVerificationFailed = false - self.anthropicAuthStatus = "OAuth detected and verified." - } catch { - self.anthropicAuthVerified = false - self.anthropicAuthVerifiedAt = nil - self.anthropicAuthVerificationFailed = true - self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)" - } - } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index ed40bd2ed58..4f942dfe8a4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -12,8 +12,6 @@ extension OnboardingView { self.welcomePage() case 1: self.connectionPage() - case 2: - self.anthropicAuthPage() case 3: self.wizardPage() case 5: @@ -340,170 +338,6 @@ extension OnboardingView { .buttonStyle(.plain) } - func anthropicAuthPage() -> some View { - self.onboardingPage { - Text("Connect Claude") - .font(.largeTitle.weight(.semibold)) - Text("Give your model the token it needs!") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 12, padding: 16) { - HStack(alignment: .center, spacing: 10) { - Circle() - .fill(self.anthropicAuthVerified ? Color.green : Color.orange) - .frame(width: 10, height: 10) - Text( - self.anthropicAuthConnected - ? (self.anthropicAuthVerified - ? "Claude connected (OAuth) — verified" - : "Claude connected (OAuth)") - : "Not connected yet") - .font(.headline) - Spacer() - } - - if self.anthropicAuthConnected, self.anthropicAuthVerifying { - Text("Verifying OAuth…") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if !self.anthropicAuthConnected { - Text(self.anthropicAuthDetectedStatus.shortDescription) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt { - Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Text( - "This lets OpenClaw use Claude immediately. Credentials are stored at " + - "`~/.openclaw/credentials/oauth.json` (owner-only).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 12) { - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - - Button("Refresh") { - self.refreshAnthropicOAuthStatus() - } - .buttonStyle(.bordered) - } - - Divider().padding(.vertical, 2) - - HStack(spacing: 12) { - if !self.anthropicAuthVerified { - if self.anthropicAuthConnected { - Button("Verify") { - Task { await self.verifyAnthropicOAuthIfNeeded(force: true) } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - - if self.anthropicAuthVerificationFailed { - Button("Re-auth (OAuth)") { - self.startAnthropicOAuth() - } - .buttonStyle(.bordered) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - } - } else { - Button { - self.startAnthropicOAuth() - } label: { - if self.anthropicAuthBusy { - ProgressView() - } else { - Text("Open Claude sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy) - } - } - } - - if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste the `code#state` value") - .font(.headline) - TextField("code#state", text: self.$anthropicAuthCode) - .textFieldStyle(.roundedBorder) - - Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Button("Connect") { - Task { await self.finishAnthropicOAuth() } - } - .buttonStyle(.bordered) - .disabled( - self.anthropicAuthBusy || - self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - .onReceive(Self.clipboardPoll) { _ in - self.pollAnthropicClipboardIfNeeded() - } - } - - self.onboardingCard(spacing: 8, padding: 12) { - Text("API key (advanced)") - .font(.headline) - Text( - "You can also use an Anthropic API key, but this UI is instructions-only for now " + - "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .shadow(color: .clear, radius: 0) - .background(Color.clear) - - if let status = self.anthropicAuthStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .task { await self.verifyAnthropicOAuthIfNeeded() } - } - func permissionsPage() -> some View { self.onboardingPage { Text("Grant permissions") diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index cf8c3d0c78f..2bd9c525ad4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -37,18 +37,9 @@ extension OnboardingView { view.cliStatus = "Installed" view.workspacePath = "/tmp/openclaw" view.workspaceStatus = "Saved workspace" - view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - view.anthropicAuthCode = "code#state" - view.anthropicAuthStatus = "Connected" - view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) - view.anthropicAuthConnected = true - view.anthropicAuthAutoDetectClipboard = false - view.anthropicAuthAutoConnectClipboard = false - view.state.connectionMode = .local _ = view.welcomePage() _ = view.connectionPage() - _ = view.anthropicAuthPage() _ = view.wizardPage() _ = view.permissionsPage() _ = view.cliPage() diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift deleted file mode 100644 index 84c61833932..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct AnthropicAuthControlsSmokeTests { - @Test func anthropicAuthControlsBuildsBodyLocal() { - let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - let view = AnthropicAuthControls( - connectionMode: .local, - oauthStatus: .connected(expiresAtMs: 1_700_000_000_000), - pkce: pkce, - code: "code#state", - statusText: "Detected code", - autoDetectClipboard: false, - autoConnectClipboard: false) - _ = view.body - } - - @Test func anthropicAuthControlsBuildsBodyRemote() { - let view = AnthropicAuthControls( - connectionMode: .remote, - oauthStatus: .missingFile, - pkce: nil, - code: "", - statusText: nil) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift deleted file mode 100644 index c41b7f64be4..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicAuthResolverTests { - @Test - func prefersOAuthFileOverEnv() throws { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - let oauthFile = dir.appendingPathComponent("oauth.json") - let payload = [ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ] - let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: oauthFile, options: [.atomic]) - - let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile) - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-ignored", - ], oauthStatus: status) - #expect(mode == .oauthFile) - } - - @Test - func reportsOAuthEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_OAUTH_TOKEN": "token", - ], oauthStatus: .missingFile) - #expect(mode == .oauthEnv) - } - - @Test - func reportsAPIKeyEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-key", - ], oauthStatus: .missingFile) - #expect(mode == .apiKeyEnv) - } - - @Test - func reportsMissingWhenNothingConfigured() { - let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile) - #expect(mode == .missing) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift deleted file mode 100644 index 3d337c2b279..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicOAuthCodeStateTests { - @Test - func parsesRawToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesBacktickedToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesCallbackURL() { - let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876" - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func extractsFromSurroundingText() { - let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return." - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift deleted file mode 100644 index b34e9c3008a..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct OpenClawOAuthStoreTests { - @Test - func returnsMissingWhenFileAbsent() { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)") - .appendingPathComponent("oauth.json") - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) - } - - @Test - func usesEnvOverrideForOpenClawOAuthDir() throws { - let key = "OPENCLAW_OAUTH_DIR" - let previous = ProcessInfo.processInfo.environment[key] - defer { - if let previous { - setenv(key, previous, 1) - } else { - unsetenv(key) - } - } - - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - setenv(key, dir.path, 1) - - #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) - } - - @Test - func acceptsPiFormatTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func acceptsTokenKeyVariants() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh_token": "r1", - "access_token": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func reportsMissingProviderEntry() throws { - let url = try self.writeOAuthFile([ - "other": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) - } - - @Test - func reportsMissingTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) - } - - private func writeOAuthFile(_ json: [String: Any]) throws -> URL { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - - let url = dir.appendingPathComponent("oauth.json") - let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - return url - } -} diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index ab9289b8a11..e9f2edeb363 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -37,9 +37,9 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb Where does the **Gateway** run? -- **This Mac (Local only):** onboarding can run OAuth flows and write credentials +- **This Mac (Local only):** onboarding can configure auth and write credentials locally. -- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally; +- **Remote (over SSH/Tailnet):** onboarding does **not** configure local auth; credentials must exist on the gateway host. - **Configure later:** skip setup and leave the app unconfigured. From d512163d686ad6741783e7119ddb3437f493dbbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:18:24 +0100 Subject: [PATCH 06/95] fix(security): harden nextcloud-talk webhook replay handling --- CHANGELOG.md | 1 + .../src/monitor.backend.test.ts | 91 ++++++++++++++ .../nextcloud-talk/src/monitor.replay.test.ts | 115 ++++++++++++++++++ extensions/nextcloud-talk/src/monitor.ts | 54 ++++++++ .../nextcloud-talk/src/replay-guard.test.ts | 70 +++++++++++ extensions/nextcloud-talk/src/replay-guard.ts | 65 ++++++++++ extensions/nextcloud-talk/src/types.ts | 2 + 7 files changed, 398 insertions(+) create mode 100644 extensions/nextcloud-talk/src/monitor.backend.test.ts create mode 100644 extensions/nextcloud-talk/src/monitor.replay.test.ts create mode 100644 extensions/nextcloud-talk/src/replay-guard.test.ts create mode 100644 extensions/nextcloud-talk/src/replay-guard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d78689220..60b08c0d0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts new file mode 100644 index 00000000000..9fb76093605 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts @@ -0,0 +1,91 @@ +import { type AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; + +type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +async function startWebhookServer(params: { + path: string; + isBackendAllowed: (backend: string) => boolean; + onMessage: () => void | Promise; +}): Promise { + const { server, start } = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: params.path, + secret: "nextcloud-secret", + isBackendAllowed: params.isBackendAllowed, + onMessage: params.onMessage, + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + return { + webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe("createNextcloudTalkWebhookServer backend allowlist", () => { + it("rejects requests from unexpected backend origins", async () => { + const onMessage = vi.fn(async () => {}); + const harness = await startWebhookServer({ + path: "/nextcloud-backend-check", + isBackendAllowed: (backend) => backend === "https://nextcloud.expected", + onMessage, + }); + cleanupFns.push(harness.stop); + + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "hello", + content: "hello", + mediaType: "text/plain", + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = generateNextcloudTalkSignature({ + body, + secret: "nextcloud-secret", + }); + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.unexpected", + }, + body, + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Invalid backend" }); + expect(onMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts new file mode 100644 index 00000000000..9943b4b367d --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -0,0 +1,115 @@ +import { type AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; + +type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +async function startWebhookServer(params: { + path: string; + shouldProcessMessage?: ( + message: Parameters< + NonNullable[0]["onMessage"]> + >[0], + ) => boolean | Promise; + onMessage: (message: { messageId: string }) => void | Promise; +}): Promise { + const { server, start } = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: params.path, + secret: "nextcloud-secret", + shouldProcessMessage: params.shouldProcessMessage, + onMessage: params.onMessage, + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + return { + webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +function createSignedRequest(body: string): { random: string; signature: string } { + return generateNextcloudTalkSignature({ + body, + secret: "nextcloud-secret", + }); +} + +describe("createNextcloudTalkWebhookServer replay handling", () => { + it("acknowledges replayed requests and skips onMessage side effects", async () => { + const seen = new Set(); + const onMessage = vi.fn(async () => {}); + const shouldProcessMessage = vi.fn(async (message: { messageId: string }) => { + if (seen.has(message.messageId)) { + return false; + } + seen.add(message.messageId); + return true; + }); + const harness = await startWebhookServer({ + path: "/nextcloud-replay", + shouldProcessMessage, + onMessage, + }); + cleanupFns.push(harness.stop); + + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "hello", + content: "hello", + mediaType: "text/plain", + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = createSignedRequest(body); + const headers = { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.example", + }; + + const first = await fetch(harness.webhookUrl, { + method: "POST", + headers, + body, + }); + const second = await fetch(harness.webhookUrl, { + method: "POST", + headers, + body, + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(shouldProcessMessage).toHaveBeenCalledTimes(2); + expect(onMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 4b68a3c4d0b..0408070c4a4 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,4 +1,5 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import os from "node:os"; import { createLoggerBackedRuntime, type RuntimeEnv, @@ -8,6 +9,7 @@ import { } from "openclaw/plugin-sdk"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; import type { @@ -31,6 +33,14 @@ function formatError(err: unknown): string { return typeof err === "string" ? err : JSON.stringify(err); } +function normalizeOrigin(value: string): string | null { + try { + return new URL(value).origin.toLowerCase(); + } catch { + return null; + } +} + function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { try { const data = JSON.parse(body); @@ -93,6 +103,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES; const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; + const isBackendAllowed = opts.isBackendAllowed; + const shouldProcessMessage = opts.shouldProcessMessage; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -116,6 +128,11 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe res.end(JSON.stringify({ error: "Missing signature headers" })); return; } + if (isBackendAllowed && !isBackendAllowed(headers.backend)) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid backend" })); + return; + } const body = await readBody(req, maxBodyBytes); @@ -146,6 +163,14 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } const message = payloadToInboundMessage(payload); + if (shouldProcessMessage) { + const shouldProcess = await shouldProcessMessage(message); + if (!shouldProcess) { + res.writeHead(200); + res.end(); + return; + } + } res.writeHead(200); res.end(); @@ -233,12 +258,41 @@ export async function monitorNextcloudTalkProvider( channel: "nextcloud-talk", accountId: account.accountId, }); + const expectedBackendOrigin = normalizeOrigin(account.baseUrl); + const replayGuard = createNextcloudTalkReplayGuard({ + stateDir: core.state.resolveStateDir(process.env, os.homedir), + onDiskError: (error) => { + logger.warn( + `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`, + ); + }, + }); const { start, stop } = createNextcloudTalkWebhookServer({ port, host, path, secret: account.secret, + isBackendAllowed: (backend) => { + if (!expectedBackendOrigin) { + return true; + } + const backendOrigin = normalizeOrigin(backend); + return backendOrigin === expectedBackendOrigin; + }, + shouldProcessMessage: async (message) => { + const shouldProcess = await replayGuard.shouldProcessMessage({ + accountId: account.accountId, + roomToken: message.roomToken, + messageId: message.messageId, + }); + if (!shouldProcess) { + logger.warn( + `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`, + ); + } + return shouldProcess; + }, onMessage: async (message) => { core.channel.activity.record({ channel: "nextcloud-talk", diff --git a/extensions/nextcloud-talk/src/replay-guard.test.ts b/extensions/nextcloud-talk/src/replay-guard.test.ts new file mode 100644 index 00000000000..0bf18acb600 --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-")); + tempDirs.push(dir); + return dir; +} + +describe("createNextcloudTalkReplayGuard", () => { + it("persists replay decisions across guard instances", async () => { + const stateDir = await makeTempDir(); + + const firstGuard = createNextcloudTalkReplayGuard({ stateDir }); + const firstAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + const replayAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + const secondGuard = createNextcloudTalkReplayGuard({ stateDir }); + const restartReplayAttempt = await secondGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + expect(firstAttempt).toBe(true); + expect(replayAttempt).toBe(false); + expect(restartReplayAttempt).toBe(false); + }); + + it("scopes replay state by account namespace", async () => { + const stateDir = await makeTempDir(); + const guard = createNextcloudTalkReplayGuard({ stateDir }); + + const accountAFirst = await guard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-9", + }); + const accountBFirst = await guard.shouldProcessMessage({ + accountId: "account-b", + roomToken: "room-1", + messageId: "msg-9", + }); + + expect(accountAFirst).toBe(true); + expect(accountBFirst).toBe(true); + }); +}); diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts new file mode 100644 index 00000000000..14b074ed2ab --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { createPersistentDedupe } from "openclaw/plugin-sdk"; + +const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MEMORY_MAX_SIZE = 1_000; +const DEFAULT_FILE_MAX_ENTRIES = 10_000; + +function sanitizeSegment(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function buildReplayKey(params: { roomToken: string; messageId: string }): string | null { + const roomToken = params.roomToken.trim(); + const messageId = params.messageId.trim(); + if (!roomToken || !messageId) { + return null; + } + return `${roomToken}:${messageId}`; +} + +export type NextcloudTalkReplayGuardOptions = { + stateDir: string; + ttlMs?: number; + memoryMaxSize?: number; + fileMaxEntries?: number; + onDiskError?: (error: unknown) => void; +}; + +export type NextcloudTalkReplayGuard = { + shouldProcessMessage: (params: { + accountId: string; + roomToken: string; + messageId: string; + }) => Promise; +}; + +export function createNextcloudTalkReplayGuard( + options: NextcloudTalkReplayGuardOptions, +): NextcloudTalkReplayGuard { + const stateDir = options.stateDir.trim(); + const persistentDedupe = createPersistentDedupe({ + ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS, + memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE, + fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES, + resolveFilePath: (namespace) => + path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`), + }); + + return { + shouldProcessMessage: async ({ accountId, roomToken, messageId }) => { + const replayKey = buildReplayKey({ roomToken, messageId }); + if (!replayKey) { + return true; + } + return await persistentDedupe.checkAndRecord(replayKey, { + namespace: accountId, + onDiskError: options.onDiskError, + }); + }, + }; +} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index a9fe49be36d..e7af64a965c 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -170,6 +170,8 @@ export type NextcloudTalkWebhookServerOptions = { secret: string; maxBodyBytes?: number; readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; + isBackendAllowed?: (backend: string) => boolean; + shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; onError?: (error: Error) => void; abortSignal?: AbortSignal; From f60d9591efccc8d201e271061de2b331b9b6f5e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:23:06 +0100 Subject: [PATCH 07/95] docs(changelog): add macOS auth fix note for setup-token path --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b08c0d0d8..92aa7af7917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ Docs: https://docs.openclaw.ai ### Changes -- macOS/Onboarding: remove Anthropic OAuth sign-in from the Mac onboarding UI and keep Anthropic subscription auth setup-token-only (legacy `oauth.json` OAuth onboarding path removed). - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. ### Fixes +- Security/macOS onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. From 5325ed90b294ac01e35e744d900a4db9cefc4c2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:23:26 +0100 Subject: [PATCH 08/95] refactor(nextcloud-talk): extract webhook pipeline and shared test harness --- .../src/monitor.auth-order.test.ts | 51 +----- .../src/monitor.backend.test.ts | 49 +----- .../nextcloud-talk/src/monitor.replay.test.ts | 56 +------ .../src/monitor.test-harness.ts | 59 +++++++ extensions/nextcloud-talk/src/monitor.ts | 154 +++++++++++++----- 5 files changed, 178 insertions(+), 191 deletions(-) create mode 100644 extensions/nextcloud-talk/src/monitor.test-harness.ts diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts index f2b4b65054d..6cc149dde47 100644 --- a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts +++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts @@ -1,50 +1,5 @@ -import { type AddressInfo } from "node:net"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createNextcloudTalkWebhookServer } from "./monitor.js"; - -type WebhookHarness = { - webhookUrl: string; - stop: () => Promise; -}; - -const cleanupFns: Array<() => Promise> = []; - -afterEach(async () => { - while (cleanupFns.length > 0) { - const cleanup = cleanupFns.pop(); - if (cleanup) { - await cleanup(); - } - } -}); - -async function startWebhookServer(params: { - path: string; - maxBodyBytes: number; - readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; -}): Promise { - const { server, start } = createNextcloudTalkWebhookServer({ - port: 0, - host: "127.0.0.1", - path: params.path, - secret: "nextcloud-secret", - maxBodyBytes: params.maxBodyBytes, - readBody: params.readBody, - onMessage: vi.fn(), - }); - await start(); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - return { - webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, - stop: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; describe("createNextcloudTalkWebhookServer auth order", () => { it("rejects missing signature headers before reading request body", async () => { @@ -55,8 +10,8 @@ describe("createNextcloudTalkWebhookServer auth order", () => { path: "/nextcloud-auth-order", maxBodyBytes: 128, readBody, + onMessage: vi.fn(), }); - cleanupFns.push(harness.stop); const response = await fetch(harness.webhookUrl, { method: "POST", diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts index 9fb76093605..aaf9a30a9c8 100644 --- a/extensions/nextcloud-talk/src/monitor.backend.test.ts +++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts @@ -1,51 +1,7 @@ -import { type AddressInfo } from "node:net"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; import { generateNextcloudTalkSignature } from "./signature.js"; -type WebhookHarness = { - webhookUrl: string; - stop: () => Promise; -}; - -const cleanupFns: Array<() => Promise> = []; - -afterEach(async () => { - while (cleanupFns.length > 0) { - const cleanup = cleanupFns.pop(); - if (cleanup) { - await cleanup(); - } - } -}); - -async function startWebhookServer(params: { - path: string; - isBackendAllowed: (backend: string) => boolean; - onMessage: () => void | Promise; -}): Promise { - const { server, start } = createNextcloudTalkWebhookServer({ - port: 0, - host: "127.0.0.1", - path: params.path, - secret: "nextcloud-secret", - isBackendAllowed: params.isBackendAllowed, - onMessage: params.onMessage, - }); - await start(); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - return { - webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, - stop: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - describe("createNextcloudTalkWebhookServer backend allowlist", () => { it("rejects requests from unexpected backend origins", async () => { const onMessage = vi.fn(async () => {}); @@ -54,7 +10,6 @@ describe("createNextcloudTalkWebhookServer backend allowlist", () => { isBackendAllowed: (backend) => backend === "https://nextcloud.expected", onMessage, }); - cleanupFns.push(harness.stop); const payload = { type: "Create", diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 9943b4b367d..387e7a8304f 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -1,54 +1,7 @@ -import { type AddressInfo } from "node:net"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; import { generateNextcloudTalkSignature } from "./signature.js"; - -type WebhookHarness = { - webhookUrl: string; - stop: () => Promise; -}; - -const cleanupFns: Array<() => Promise> = []; - -afterEach(async () => { - while (cleanupFns.length > 0) { - const cleanup = cleanupFns.pop(); - if (cleanup) { - await cleanup(); - } - } -}); - -async function startWebhookServer(params: { - path: string; - shouldProcessMessage?: ( - message: Parameters< - NonNullable[0]["onMessage"]> - >[0], - ) => boolean | Promise; - onMessage: (message: { messageId: string }) => void | Promise; -}): Promise { - const { server, start } = createNextcloudTalkWebhookServer({ - port: 0, - host: "127.0.0.1", - path: params.path, - secret: "nextcloud-secret", - shouldProcessMessage: params.shouldProcessMessage, - onMessage: params.onMessage, - }); - await start(); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - return { - webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, - stop: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} +import type { NextcloudTalkInboundMessage } from "./types.js"; function createSignedRequest(body: string): { random: string; signature: string } { return generateNextcloudTalkSignature({ @@ -61,7 +14,7 @@ describe("createNextcloudTalkWebhookServer replay handling", () => { it("acknowledges replayed requests and skips onMessage side effects", async () => { const seen = new Set(); const onMessage = vi.fn(async () => {}); - const shouldProcessMessage = vi.fn(async (message: { messageId: string }) => { + const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => { if (seen.has(message.messageId)) { return false; } @@ -73,7 +26,6 @@ describe("createNextcloudTalkWebhookServer replay handling", () => { shouldProcessMessage, onMessage, }); - cleanupFns.push(harness.stop); const payload = { type: "Create", diff --git a/extensions/nextcloud-talk/src/monitor.test-harness.ts b/extensions/nextcloud-talk/src/monitor.test-harness.ts new file mode 100644 index 00000000000..f0daf42e8d5 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.test-harness.ts @@ -0,0 +1,59 @@ +import { type AddressInfo } from "node:net"; +import { afterEach } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; +import type { NextcloudTalkWebhookServerOptions } from "./types.js"; + +export type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +export type StartWebhookServerParams = Omit< + NextcloudTalkWebhookServerOptions, + "port" | "host" | "path" | "secret" +> & { + path: string; + secret?: string; + host?: string; + port?: number; +}; + +export async function startWebhookServer( + params: StartWebhookServerParams, +): Promise { + const host = params.host ?? "127.0.0.1"; + const port = params.port ?? 0; + const secret = params.secret ?? "nextcloud-secret"; + const { server, start } = createNextcloudTalkWebhookServer({ + ...params, + port, + host, + secret, + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + + const harness: WebhookHarness = { + webhookUrl: `http://${host}:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; + cleanupFns.push(harness.stop); + return harness; +} diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 0408070c4a4..3fb3da3e75b 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -15,6 +15,7 @@ import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./sig import type { CoreConfig, NextcloudTalkInboundMessage, + NextcloudTalkWebhookHeaders, NextcloudTalkWebhookPayload, NextcloudTalkWebhookServerOptions, } from "./types.js"; @@ -25,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const HEALTH_PATH = "/healthz"; +const WEBHOOK_ERRORS = { + missingSignatureHeaders: "Missing signature headers", + invalidBackend: "Invalid backend", + invalidSignature: "Invalid signature", + invalidPayloadFormat: "Invalid payload format", + payloadTooLarge: "Payload too large", + internalServerError: "Internal server error", +} as const; function formatError(err: unknown): string { if (err instanceof Error) { @@ -61,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { } } +function writeJsonResponse( + res: ServerResponse, + status: number, + body?: Record, +): void { + if (body) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + return; + } + res.writeHead(status); + res.end(); +} + +function writeWebhookError(res: ServerResponse, status: number, error: string): void { + if (res.headersSent) { + return; + } + writeJsonResponse(res, status, { error }); +} + +function validateWebhookHeaders(params: { + req: IncomingMessage; + res: ServerResponse; + isBackendAllowed?: (backend: string) => boolean; +}): NextcloudTalkWebhookHeaders | null { + const headers = extractNextcloudTalkHeaders( + params.req.headers as Record, + ); + if (!headers) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders); + return null; + } + if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend); + return null; + } + return headers; +} + +function verifyWebhookSignature(params: { + headers: NextcloudTalkWebhookHeaders; + body: string; + secret: string; + res: ServerResponse; +}): boolean { + const isValid = verifyNextcloudTalkSignature({ + signature: params.headers.signature, + random: params.headers.random, + body: params.body, + secret: params.secret, + }); + if (!isValid) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature); + return false; + } + return true; +} + +function decodeWebhookCreateMessage(params: { + body: string; + res: ServerResponse; +}): + | { kind: "message"; message: NextcloudTalkInboundMessage } + | { kind: "ignore" } + | { kind: "invalid" } { + const payload = parseWebhookPayload(params.body); + if (!payload) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat); + return { kind: "invalid" }; + } + if (payload.type !== "Create") { + return { kind: "ignore" }; + } + return { kind: "message", message: payloadToInboundMessage(payload) }; +} + function payloadToInboundMessage( payload: NextcloudTalkWebhookPayload, ): NextcloudTalkInboundMessage { @@ -120,60 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const headers = extractNextcloudTalkHeaders( - req.headers as Record, - ); + const headers = validateWebhookHeaders({ + req, + res, + isBackendAllowed, + }); if (!headers) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing signature headers" })); - return; - } - if (isBackendAllowed && !isBackendAllowed(headers.backend)) { - res.writeHead(401, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid backend" })); return; } const body = await readBody(req, maxBodyBytes); - const isValid = verifyNextcloudTalkSignature({ - signature: headers.signature, - random: headers.random, + const hasValidSignature = verifyWebhookSignature({ + headers, body, secret, + res, }); - - if (!isValid) { - res.writeHead(401, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid signature" })); + if (!hasValidSignature) { return; } - const payload = parseWebhookPayload(body); - if (!payload) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid payload format" })); + const decoded = decodeWebhookCreateMessage({ + body, + res, + }); + if (decoded.kind === "invalid") { + return; + } + if (decoded.kind === "ignore") { + writeJsonResponse(res, 200); return; } - if (payload.type !== "Create") { - res.writeHead(200); - res.end(); - return; - } - - const message = payloadToInboundMessage(payload); + const message = decoded.message; if (shouldProcessMessage) { const shouldProcess = await shouldProcessMessage(message); if (!shouldProcess) { - res.writeHead(200); - res.end(); + writeJsonResponse(res, 200); return; } } - res.writeHead(200); - res.end(); + writeJsonResponse(res, 200); try { await onMessage(message); @@ -182,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } } catch (err) { if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { - if (!res.headersSent) { - res.writeHead(413, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Payload too large" })); - } + writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge); return; } if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { - if (!res.headersSent) { - res.writeHead(408, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); - } + writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT")); return; } const error = err instanceof Error ? err : new Error(formatError(err)); onError?.(error); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } + writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError); } }); From 45d59971e66f38651f2f72a6818deb854b1eb85f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:26:49 +0100 Subject: [PATCH 09/95] docs(changelog): clarify macOS beta scope for oauth fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92aa7af7917..09d200dbd96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Security/macOS onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. +- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. From 125f4071bcbc0de32e769940d07967db47f09d3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:30:43 +0100 Subject: [PATCH 10/95] fix(gateway): block agents.files symlink escapes --- CHANGELOG.md | 1 + .../server-methods/agents-mutate.test.ts | 192 +++++++++++++- src/gateway/server-methods/agents.ts | 249 ++++++++++++++++-- 3 files changed, 421 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d200dbd96..aee7af5ad7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. +- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 54c285203f3..a4fddea633a 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -26,7 +26,10 @@ const mocks = vi.hoisted(() => ({ fsMkdir: vi.fn(async () => undefined), fsAppendFile: vi.fn(async () => {}), fsReadFile: vi.fn(async () => ""), - fsStat: vi.fn(async () => null), + fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), + fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), + fsRealpath: vi.fn(async (p: string) => p), + fsOpen: vi.fn(async () => ({}) as unknown), })); vi.mock("../../config/config.js", () => ({ @@ -85,6 +88,9 @@ vi.mock("node:fs/promises", async () => { appendFile: mocks.fsAppendFile, readFile: mocks.fsReadFile, stat: mocks.fsStat, + lstat: mocks.fsLstat, + realpath: mocks.fsRealpath, + open: mocks.fsOpen, }; return { ...patched, default: patched }; }); @@ -125,6 +131,33 @@ function createErrnoError(code: string) { return err; } +function makeFileStat(params?: { + size?: number; + mtimeMs?: number; + dev?: number; + ino?: number; +}): import("node:fs").Stats { + return { + isFile: () => true, + isSymbolicLink: () => false, + size: params?.size ?? 10, + mtimeMs: params?.mtimeMs ?? 1234, + dev: params?.dev ?? 1, + ino: params?.ino ?? 1, + } as unknown as import("node:fs").Stats; +} + +function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats { + return { + isFile: () => false, + isSymbolicLink: () => true, + size: 0, + mtimeMs: 0, + dev: params?.dev ?? 1, + ino: params?.ino ?? 2, + } as unknown as import("node:fs").Stats; +} + function mockWorkspaceStateRead(params: { onboardingCompletedAt?: string; errorCode?: string; @@ -172,6 +205,19 @@ beforeEach(() => { mocks.fsStat.mockImplementation(async () => { throw createEnoentError(); }); + mocks.fsLstat.mockImplementation(async () => { + throw createEnoentError(); + }); + mocks.fsRealpath.mockImplementation(async (p: string) => p); + mocks.fsOpen.mockImplementation( + async () => + ({ + stat: async () => makeFileStat(), + readFile: async () => Buffer.from(""), + writeFile: async () => {}, + close: async () => {}, + }) as unknown, + ); }); /* ------------------------------------------------------------------ */ @@ -459,3 +505,147 @@ describe("agents.files.list", () => { expect(names).toContain("BOOTSTRAP.md"); }); }); + +describe("agents.files.get/set symlink safety", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfigReturn = {}; + mocks.fsMkdir.mockResolvedValue(undefined); + }); + + it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => { + const workspace = "/workspace/test-agent"; + const candidate = `${workspace}/AGENTS.md`; + mocks.fsRealpath.mockImplementation(async (p: string) => { + if (p === workspace) { + return workspace; + } + if (p === candidate) { + return "/outside/secret.txt"; + } + return p; + }); + mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { + const p = typeof args[0] === "string" ? args[0] : ""; + if (p === candidate) { + return makeSymlinkStat(); + } + throw createEnoentError(); + }); + + const { respond, promise } = makeCall("agents.files.get", { + agentId: "main", + name: "AGENTS.md", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); + }); + + it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => { + const workspace = "/workspace/test-agent"; + const candidate = `${workspace}/AGENTS.md`; + mocks.fsRealpath.mockImplementation(async (p: string) => { + if (p === workspace) { + return workspace; + } + if (p === candidate) { + return "/outside/secret.txt"; + } + return p; + }); + mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { + const p = typeof args[0] === "string" ? args[0] : ""; + if (p === candidate) { + return makeSymlinkStat(); + } + throw createEnoentError(); + }); + + const { respond, promise } = makeCall("agents.files.set", { + agentId: "main", + name: "AGENTS.md", + content: "x", + }); + await promise; + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); + expect(mocks.fsOpen).not.toHaveBeenCalled(); + }); + + it("allows in-workspace symlink targets for get/set", async () => { + const workspace = "/workspace/test-agent"; + const candidate = `${workspace}/AGENTS.md`; + const target = `${workspace}/policies/AGENTS.md`; + const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 }); + + mocks.fsRealpath.mockImplementation(async (p: string) => { + if (p === workspace) { + return workspace; + } + if (p === candidate) { + return target; + } + return p; + }); + mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { + const p = typeof args[0] === "string" ? args[0] : ""; + if (p === candidate) { + return makeSymlinkStat({ dev: 9, ino: 41 }); + } + if (p === target) { + return targetStat; + } + throw createEnoentError(); + }); + mocks.fsStat.mockImplementation(async (...args: unknown[]) => { + const p = typeof args[0] === "string" ? args[0] : ""; + if (p === target) { + return targetStat; + } + throw createEnoentError(); + }); + mocks.fsOpen.mockImplementation( + async () => + ({ + stat: async () => targetStat, + readFile: async () => Buffer.from("inside\n"), + writeFile: async () => {}, + close: async () => {}, + }) as unknown, + ); + + const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" }); + await getCall.promise; + expect(getCall.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + file: expect.objectContaining({ missing: false, content: "inside\n" }), + }), + undefined, + ); + + const setCall = makeCall("agents.files.set", { + agentId: "main", + name: "AGENTS.md", + content: "updated\n", + }); + await setCall.promise; + expect(setCall.respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + ok: true, + file: expect.objectContaining({ missing: false, content: "updated\n" }), + }), + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 04a716e077e..413ffddc877 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -1,3 +1,4 @@ +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -27,6 +28,9 @@ import { } from "../../commands/agents.config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; +import { sameFileIdentity } from "../../infra/file-identity.js"; +import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js"; +import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { @@ -97,10 +101,113 @@ type FileMeta = { updatedAtMs: number; }; -async function statFile(filePath: string): Promise { +type ResolvedAgentWorkspaceFilePath = + | { + kind: "ready"; + requestPath: string; + ioPath: string; + workspaceReal: string; + } + | { + kind: "missing"; + requestPath: string; + ioPath: string; + workspaceReal: string; + } + | { + kind: "invalid"; + requestPath: string; + reason: string; + }; + +const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; +const OPEN_WRITE_FLAGS = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); + +async function resolveWorkspaceRealPath(workspaceDir: string): Promise { try { - const stat = await fs.stat(filePath); - if (!stat.isFile()) { + return await fs.realpath(workspaceDir); + } catch { + return path.resolve(workspaceDir); + } +} + +async function resolveAgentWorkspaceFilePath(params: { + workspaceDir: string; + name: string; + allowMissing: boolean; +}): Promise { + const requestPath = path.join(params.workspaceDir, params.name); + const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir); + const candidatePath = path.resolve(workspaceReal, params.name); + if (!isPathInside(workspaceReal, candidatePath)) { + return { kind: "invalid", requestPath, reason: "path escapes workspace root" }; + } + + let candidateLstat: Awaited>; + try { + candidateLstat = await fs.lstat(candidatePath); + } catch (err) { + if (isNotFoundPathError(err)) { + if (params.allowMissing) { + return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal }; + } + return { kind: "invalid", requestPath, reason: "file not found" }; + } + throw err; + } + + if (candidateLstat.isSymbolicLink()) { + let targetReal: string; + try { + targetReal = await fs.realpath(candidatePath); + } catch (err) { + if (isNotFoundPathError(err)) { + if (params.allowMissing) { + return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal }; + } + return { kind: "invalid", requestPath, reason: "symlink target not found" }; + } + throw err; + } + if (!isPathInside(workspaceReal, targetReal)) { + return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" }; + } + try { + const targetStat = await fs.stat(targetReal); + if (!targetStat.isFile()) { + return { kind: "invalid", requestPath, reason: "symlink target is not a file" }; + } + } catch (err) { + if (isNotFoundPathError(err) && params.allowMissing) { + return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal }; + } + throw err; + } + return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal }; + } + + if (!candidateLstat.isFile()) { + return { kind: "invalid", requestPath, reason: "path is not a regular file" }; + } + + const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath); + if (!isPathInside(workspaceReal, candidateReal)) { + return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" }; + } + return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal }; +} + +async function statFileSafely(filePath: string): Promise { + try { + const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]); + if (lstat.isSymbolicLink() || !stat.isFile()) { + return null; + } + if (!sameFileIdentity(stat, lstat)) { return null; } return { @@ -112,6 +219,22 @@ async function statFile(filePath: string): Promise { } } +async function writeFileSafely(filePath: string, content: string): Promise { + const handle = await fs.open(filePath, OPEN_WRITE_FLAGS, 0o600); + try { + const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]); + if (lstat.isSymbolicLink() || !stat.isFile()) { + throw new Error("unsafe file path"); + } + if (!sameFileIdentity(stat, lstat)) { + throw new Error("path changed during write"); + } + await handle.writeFile(content, "utf-8"); + } finally { + await handle.close().catch(() => {}); + } +} + async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) { const files: Array<{ name: string; @@ -125,8 +248,18 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING : BOOTSTRAP_FILE_NAMES; for (const name of bootstrapFileNames) { - const filePath = path.join(workspaceDir, name); - const meta = await statFile(filePath); + const resolved = await resolveAgentWorkspaceFilePath({ + workspaceDir, + name, + allowMissing: true, + }); + const filePath = resolved.requestPath; + const meta = + resolved.kind === "ready" + ? await statFileSafely(resolved.ioPath) + : resolved.kind === "missing" + ? null + : null; if (meta) { files.push({ name, @@ -140,29 +273,43 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: } } - const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME); - const primaryMeta = await statFile(primaryMemoryPath); + const primaryResolved = await resolveAgentWorkspaceFilePath({ + workspaceDir, + name: DEFAULT_MEMORY_FILENAME, + allowMissing: true, + }); + const primaryMeta = + primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null; if (primaryMeta) { files.push({ name: DEFAULT_MEMORY_FILENAME, - path: primaryMemoryPath, + path: primaryResolved.requestPath, missing: false, size: primaryMeta.size, updatedAtMs: primaryMeta.updatedAtMs, }); } else { - const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME); - const altMeta = await statFile(altMemoryPath); + const altMemoryResolved = await resolveAgentWorkspaceFilePath({ + workspaceDir, + name: DEFAULT_MEMORY_ALT_FILENAME, + allowMissing: true, + }); + const altMeta = + altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null; if (altMeta) { files.push({ name: DEFAULT_MEMORY_ALT_FILENAME, - path: altMemoryPath, + path: altMemoryResolved.requestPath, missing: false, size: altMeta.size, updatedAtMs: altMeta.updatedAtMs, }); } else { - files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true }); + files.push({ + name: DEFAULT_MEMORY_FILENAME, + path: primaryResolved.requestPath, + missing: true, + }); } } @@ -453,8 +600,23 @@ export const agentsHandlers: GatewayRequestHandlers = { } const { agentId, workspaceDir, name } = resolved; const filePath = path.join(workspaceDir, name); - const meta = await statFile(filePath); - if (!meta) { + const resolvedPath = await resolveAgentWorkspaceFilePath({ + workspaceDir, + name, + allowMissing: true, + }); + if (resolvedPath.kind === "invalid") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsafe workspace file "${name}" (${resolvedPath.reason})`, + ), + ); + return; + } + if (resolvedPath.kind === "missing") { respond( true, { @@ -466,7 +628,29 @@ export const agentsHandlers: GatewayRequestHandlers = { ); return; } - const content = await fs.readFile(filePath, "utf-8"); + let safeRead: Awaited>; + try { + safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath }); + } catch (err) { + if (err instanceof SafeOpenError && err.code === "not-found") { + respond( + true, + { + agentId, + workspace: workspaceDir, + file: { name, path: filePath, missing: true }, + }, + undefined, + ); + return; + } + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`), + ); + return; + } respond( true, { @@ -476,9 +660,9 @@ export const agentsHandlers: GatewayRequestHandlers = { name, path: filePath, missing: false, - size: meta.size, - updatedAtMs: meta.updatedAtMs, - content, + size: safeRead.stat.size, + updatedAtMs: Math.floor(safeRead.stat.mtimeMs), + content: safeRead.buffer.toString("utf-8"), }, }, undefined, @@ -505,9 +689,34 @@ export const agentsHandlers: GatewayRequestHandlers = { const { agentId, workspaceDir, name } = resolved; await fs.mkdir(workspaceDir, { recursive: true }); const filePath = path.join(workspaceDir, name); + const resolvedPath = await resolveAgentWorkspaceFilePath({ + workspaceDir, + name, + allowMissing: true, + }); + if (resolvedPath.kind === "invalid") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsafe workspace file "${name}" (${resolvedPath.reason})`, + ), + ); + return; + } const content = String(params.content ?? ""); - await fs.writeFile(filePath, content, "utf-8"); - const meta = await statFile(filePath); + try { + await writeFileSafely(resolvedPath.ioPath, content); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`), + ); + return; + } + const meta = await statFileSafely(resolvedPath.ioPath); respond( true, { From 2011edc9e505ffa59949fb63f2c54a50f6400671 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 23:30:40 +0000 Subject: [PATCH 11/95] fix(gateway): preserve agentId through gateway send path Landed from #23249 by @Sid-Qin. Includes extra regression tests for agentId precedence + blank fallback. Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com> --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 4 + .../OpenClawProtocol/GatewayModels.swift | 4 + src/gateway/protocol/schema/agent.ts | 2 + src/gateway/server-methods/send.test.ts | 90 +++++++++++++++++++ src/gateway/server-methods/send.ts | 23 +++-- src/infra/outbound/message.channels.test.ts | 29 ++++++ src/infra/outbound/message.ts | 1 + 8 files changed, 146 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee7af5ad7e..58c250c5c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. +- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 4e766514def..95565a68c4f 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let agentid: String? public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + agentid: String?, threadid: String?, sessionkey: String?, idempotencykey: String) @@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.agentid = agentid self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey @@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case agentid = "agentId" case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 4e766514def..95565a68c4f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let agentid: String? public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + agentid: String?, threadid: String?, sessionkey: String?, idempotencykey: String) @@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.agentid = agentid self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey @@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case agentid = "agentId" case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index b8c883f7f53..1508c38f70e 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -22,6 +22,8 @@ export const SendParamsSchema = Type.Object( gifPlayback: Type.Optional(Type.Boolean()), channel: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), + /** Optional agent id for per-agent media root resolution on gateway sends. */ + agentId: Type.Optional(Type.String()), /** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */ threadId: Type.Optional(Type.String()), /** Optional session key for mirroring delivered output back into the transcript. */ diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7209d3e6176..7734de8e911 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -342,6 +342,96 @@ describe("gateway send mirroring", () => { ); }); + it("uses explicit agentId for delivery when sessionKey is not provided", async () => { + mockDeliverySuccess("m-agent"); + + await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + agentId: "work", + idempotencyKey: "idem-agent-explicit", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + mirror: expect.objectContaining({ + sessionKey: "agent:work:slack:channel:resolved", + agentId: "work", + }), + }), + ); + }); + + it("uses sessionKey agentId when explicit agentId is omitted", async () => { + mockDeliverySuccess("m-session-agent"); + + await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + sessionKey: "agent:work:slack:channel:c1", + idempotencyKey: "idem-session-agent", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + mirror: expect.objectContaining({ + sessionKey: "agent:work:slack:channel:c1", + agentId: "work", + }), + }), + ); + }); + + it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => { + mockDeliverySuccess("m-agent-precedence"); + + await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + agentId: "work", + sessionKey: "agent:main:slack:channel:c1", + idempotencyKey: "idem-agent-precedence", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + mirror: expect.objectContaining({ + sessionKey: "agent:main:slack:channel:c1", + agentId: "work", + }), + }), + ); + }); + + it("ignores blank explicit agentId and falls back to sessionKey agent", async () => { + mockDeliverySuccess("m-agent-blank"); + + await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + agentId: " ", + sessionKey: "agent:work:slack:channel:c1", + idempotencyKey: "idem-agent-blank", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + mirror: expect.objectContaining({ + sessionKey: "agent:work:slack:channel:c1", + agentId: "work", + }), + }), + ); + }); + it("forwards threadId to outbound delivery when provided", async () => { mockDeliverySuccess("m-thread"); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index c404a47032a..9e976a79ae1 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -106,6 +106,7 @@ export const sendHandlers: GatewayRequestHandlers = { gifPlayback?: boolean; channel?: string; accountId?: string; + agentId?: string; threadId?: string; sessionKey?: string; idempotencyKey: string; @@ -206,13 +207,21 @@ export const sendHandlers: GatewayRequestHandlers = { typeof request.sessionKey === "string" && request.sessionKey.trim() ? request.sessionKey.trim().toLowerCase() : undefined; - const derivedAgentId = resolveSessionAgentId({ config: cfg }); + const explicitAgentId = + typeof request.agentId === "string" && request.agentId.trim() + ? request.agentId.trim() + : undefined; + const sessionAgentId = providedSessionKey + ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }) + : undefined; + const defaultAgentId = resolveSessionAgentId({ config: cfg }); + const effectiveAgentId = explicitAgentId ?? sessionAgentId ?? defaultAgentId; // If callers omit sessionKey, derive a target session key from the outbound route. const derivedRoute = !providedSessionKey ? await resolveOutboundSessionRoute({ cfg, channel, - agentId: derivedAgentId, + agentId: effectiveAgentId, accountId, target: resolved.to, threadId, @@ -221,7 +230,7 @@ export const sendHandlers: GatewayRequestHandlers = { if (derivedRoute) { await ensureOutboundSessionEntry({ cfg, - agentId: derivedAgentId, + agentId: effectiveAgentId, channel, accountId, route: derivedRoute, @@ -233,23 +242,21 @@ export const sendHandlers: GatewayRequestHandlers = { to: resolved.to, accountId, payloads: [{ text: message, mediaUrl, mediaUrls }], - agentId: providedSessionKey - ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }) - : derivedAgentId, + agentId: effectiveAgentId, gifPlayback: request.gifPlayback, threadId: threadId ?? null, deps: outboundDeps, mirror: providedSessionKey ? { sessionKey: providedSessionKey, - agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }), + agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, } : derivedRoute ? { sessionKey: derivedRoute.sessionKey, - agentId: derivedAgentId, + agentId: effectiveAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, } diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 39e83c8ad70..12b9b120f66 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -194,6 +194,35 @@ describe("gateway url override hardening", () => { }), ); }); + + it("forwards explicit agentId in gateway send params", async () => { + setRegistry( + createTestRegistry([ + { + pluginId: "mattermost", + source: "test", + plugin: { + ...createMattermostLikePlugin({ onSendText: () => {} }), + outbound: { deliveryMode: "gateway" }, + }, + }, + ]), + ); + + callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" }); + await sendMessage({ + cfg: {}, + to: "channel:town-square", + content: "hi", + channel: "mattermost", + agentId: "work", + }); + + const call = callGatewayMock.mock.calls[0]?.[0] as { + params?: Record; + }; + expect(call.params?.agentId).toBe("work"); + }); }); const emptyRegistry = createTestRegistry([]); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 71b36eca6b1..649aabd0ece 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -251,6 +251,7 @@ export async function sendMessage(params: MessageSendParams): Promise Date: Wed, 25 Feb 2026 18:34:31 -0500 Subject: [PATCH 12/95] fix: cron model fallback to agent defaults when payload.model fails (#26717) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 06454bd55b44ea864c10ad828649b293946cea8d Co-authored-by: Youyou972 <50808411+Youyou972@users.noreply.github.com> Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com> Reviewed-by: @shakkernerd --- CHANGELOG.md | 1 + .../isolated-agent/run.skill-filter.test.ts | 103 ++++++++++++++++-- src/cron/isolated-agent/run.ts | 13 ++- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c250c5c47..963892e9ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. +- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 02d986819d9..2b6e4bbf7be 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -6,6 +6,13 @@ import { runWithModelFallback } from "../../agents/model-fallback.js"; const buildWorkspaceSkillSnapshotMock = vi.fn(); const resolveAgentConfigMock = vi.fn(); const resolveAgentSkillsFilterMock = vi.fn(); +const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false }); +const isCliProviderMock = vi.fn().mockReturnValue(false); +const resolveAllowedModelRefMock = vi.fn(); +const resolveConfiguredModelRefMock = vi.fn(); +const resolveHooksGmailModelMock = vi.fn(); +const resolveThinkingDefaultMock = vi.fn(); +const logWarnMock = vi.fn(); vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentConfig: resolveAgentConfigMock, @@ -36,14 +43,12 @@ vi.mock("../../agents/model-selection.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), - isCliProvider: vi.fn().mockReturnValue(false), - resolveAllowedModelRef: vi - .fn() - .mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }), - resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }), - resolveHooksGmailModel: vi.fn().mockReturnValue(null), - resolveThinkingDefault: vi.fn().mockReturnValue(undefined), + getModelRefStatus: getModelRefStatusMock, + isCliProvider: isCliProviderMock, + resolveAllowedModelRef: resolveAllowedModelRefMock, + resolveConfiguredModelRef: resolveConfiguredModelRefMock, + resolveHooksGmailModel: resolveHooksGmailModelMock, + resolveThinkingDefault: resolveThinkingDefaultMock, }; }); @@ -138,7 +143,7 @@ vi.mock("../../infra/skills-remote.js", () => ({ })); vi.mock("../../logger.js", () => ({ - logWarn: vi.fn(), + logWarn: (...args: unknown[]) => logWarnMock(...args), })); vi.mock("../../security/external-content.js", () => ({ @@ -222,6 +227,13 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { }); resolveAgentConfigMock.mockReturnValue(undefined); resolveAgentSkillsFilterMock.mockReturnValue(undefined); + resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" }); + resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }); + resolveHooksGmailModelMock.mockReturnValue(null); + resolveThinkingDefaultMock.mockReturnValue(undefined); + getModelRefStatusMock.mockReturnValue({ allowed: false }); + isCliProviderMock.mockReturnValue(false); + logWarnMock.mockReset(); // Fresh session object per test — prevents mutation leaking between tests resolveCronSessionMock.mockReturnValue({ storePath: "/tmp/store.json", @@ -408,5 +420,78 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { it("preserves defaults when agent overrides primary in object form", async () => { await expectPrimaryOverridePreservesDefaults({ primary: "anthropic/claude-sonnet-4-5" }); }); + + it("applies payload.model override when model is allowed", async () => { + resolveAllowedModelRefMock.mockReturnValueOnce({ + ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + job: makeJob({ + payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(logWarnMock).not.toHaveBeenCalled(); + expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); + const runParams = runWithModelFallbackMock.mock.calls[0][0]; + expect(runParams.provider).toBe("anthropic"); + expect(runParams.model).toBe("claude-sonnet-4-6"); + }); + + it("falls back to agent defaults when payload.model is not allowed", async () => { + resolveAllowedModelRefMock.mockReturnValueOnce({ + error: "model not allowed: anthropic/claude-sonnet-4-6", + }); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { + agents: { + defaults: { + model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks }, + }, + }, + }, + job: makeJob({ + payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" }, + }), + }), + ); + + expect(result.status).toBe("ok"); + expect(logWarnMock).toHaveBeenCalledWith( + "cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults", + ); + expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); + const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg; + const model = callCfg?.agents?.defaults?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; + expect(model?.primary).toBe("openai-codex/gpt-5.3-codex"); + expect(model?.fallbacks).toEqual(defaultFallbacks); + }); + + it("returns an error when payload.model is invalid", async () => { + resolveAllowedModelRefMock.mockReturnValueOnce({ + error: "invalid model: openai/", + }); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + job: makeJob({ + payload: { kind: "agentTurn", message: "test", model: "openai/" }, + }), + }), + ); + + expect(result.status).toBe("error"); + expect(result.error).toBe("invalid model: openai/"); + expect(logWarnMock).not.toHaveBeenCalled(); + expect(runWithModelFallbackMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index dd5c28ae616..a4a14bc26b8 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -198,10 +198,17 @@ export async function runCronIsolatedAgentTurn(params: { defaultModel: resolvedDefault.model, }); if ("error" in resolvedOverride) { - return { status: "error", error: resolvedOverride.error }; + if (resolvedOverride.error.startsWith("model not allowed:")) { + logWarn( + `cron: payload.model '${modelOverride}' not allowed, falling back to agent defaults`, + ); + } else { + return { status: "error", error: resolvedOverride.error }; + } + } else { + provider = resolvedOverride.ref.provider; + model = resolvedOverride.ref.model; } - provider = resolvedOverride.ref.provider; - model = resolvedOverride.ref.model; } const now = Date.now(); const cronSession = resolveCronSession({ From ef326f5cd0f761e02d5a02339be64ccfe1b96102 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:40:43 +0100 Subject: [PATCH 13/95] fix(browser): revalidate upload paths at use time --- CHANGELOG.md | 1 + src/browser/paths.test.ts | 24 ++++ src/browser/paths.ts | 25 +++- src/browser/pw-tools-core.downloads.ts | 16 ++- ...-core.interactions.set-input-files.test.ts | 111 ++++++++++++++++++ src/browser/pw-tools-core.interactions.ts | 12 +- ...ls-core.last-file-chooser-arm-wins.test.ts | 51 +++++--- ...-core.screenshots-element-selector.test.ts | 48 +++++++- 8 files changed, 263 insertions(+), 25 deletions(-) create mode 100644 src/browser/pw-tools-core.interactions.set-input-files.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 963892e9ff5..c8d520827bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. - Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. - Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. diff --git a/src/browser/paths.test.ts b/src/browser/paths.test.ts index 441ee05b869..1599c3895b2 100644 --- a/src/browser/paths.test.ts +++ b/src/browser/paths.test.ts @@ -6,6 +6,7 @@ import { resolveExistingPathsWithinRoot, resolvePathsWithinRoot, resolvePathWithinRoot, + resolveStrictExistingPathsWithinRoot, } from "./paths.js"; async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> { @@ -194,6 +195,29 @@ describe("resolveExistingPathsWithinRoot", () => { ); }); +describe("resolveStrictExistingPathsWithinRoot", () => { + function expectInvalidResult( + result: Awaited>, + expectedSnippet: string, + ) { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain(expectedSnippet); + } + } + + it("rejects missing files instead of returning lexical fallbacks", async () => { + await withFixtureRoot(async ({ uploadsDir }) => { + const result = await resolveStrictExistingPathsWithinRoot({ + rootDir: uploadsDir, + requestedPaths: ["missing.txt"], + scopeLabel: "uploads directory", + }); + expectInvalidResult(result, "regular non-symlink file"); + }); + }); +}); + describe("resolvePathWithinRoot", () => { it("uses default file name when requested path is blank", () => { const result = resolvePathWithinRoot({ diff --git a/src/browser/paths.ts b/src/browser/paths.ts index 0b458e44dec..88a541b75dc 100644 --- a/src/browser/paths.ts +++ b/src/browser/paths.ts @@ -54,6 +54,29 @@ export async function resolveExistingPathsWithinRoot(params: { rootDir: string; requestedPaths: string[]; scopeLabel: string; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + return await resolveCheckedPathsWithinRoot({ + ...params, + allowMissingFallback: true, + }); +} + +export async function resolveStrictExistingPathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + return await resolveCheckedPathsWithinRoot({ + ...params, + allowMissingFallback: false, + }); +} + +async function resolveCheckedPathsWithinRoot(params: { + rootDir: string; + requestedPaths: string[]; + scopeLabel: string; + allowMissingFallback: boolean; }): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { const rootDir = path.resolve(params.rootDir); let rootRealPath: string | undefined; @@ -119,7 +142,7 @@ export async function resolveExistingPathsWithinRoot(params: { }); resolvedPaths.push(opened.realPath); } catch (err) { - if (err instanceof SafeOpenError && err.code === "not-found") { + if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") { // Preserve historical behavior for paths that do not exist yet. resolvedPaths.push(pathResult.fallbackPath); continue; diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts index 12be321653b..4933c78b5e4 100644 --- a/src/browser/pw-tools-core.downloads.ts +++ b/src/browser/pw-tools-core.downloads.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { Page } from "playwright-core"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; import { ensurePageState, getPageForTargetId, @@ -166,7 +167,20 @@ export async function armFileUploadViaPlaywright(opts: { } return; } - await fileChooser.setFiles(opts.paths); + const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({ + rootDir: DEFAULT_UPLOAD_DIR, + requestedPaths: opts.paths, + scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, + }); + if (!uploadPathsResult.ok) { + try { + await page.keyboard.press("Escape"); + } catch { + // Best-effort. + } + return; + } + await fileChooser.setFiles(uploadPathsResult.paths); try { const input = typeof fileChooser.element === "function" diff --git a/src/browser/pw-tools-core.interactions.set-input-files.test.ts b/src/browser/pw-tools-core.interactions.set-input-files.test.ts new file mode 100644 index 00000000000..dfbd6f58563 --- /dev/null +++ b/src/browser/pw-tools-core.interactions.set-input-files.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +let page: Record | null = null; +let locator: Record | null = null; + +const getPageForTargetId = vi.fn(async () => { + if (!page) { + throw new Error("test: page not set"); + } + return page; +}); +const ensurePageState = vi.fn(() => ({})); +const restoreRoleRefsForTarget = vi.fn(() => {}); +const refLocator = vi.fn(() => { + if (!locator) { + throw new Error("test: locator not set"); + } + return locator; +}); +const forceDisconnectPlaywrightForTarget = vi.fn(async () => {}); + +const resolveStrictExistingPathsWithinRoot = + vi.fn(); + +vi.mock("./pw-session.js", () => { + return { + ensurePageState, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + refLocator, + restoreRoleRefsForTarget, + }; +}); + +vi.mock("./paths.js", () => { + return { + DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads", + resolveStrictExistingPathsWithinRoot, + }; +}); + +let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright; + +describe("setInputFilesViaPlaywright", () => { + beforeAll(async () => { + ({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + page = null; + locator = null; + resolveStrictExistingPathsWithinRoot.mockResolvedValue({ + ok: true, + paths: ["/private/tmp/openclaw/uploads/ok.txt"], + }); + }); + + it("revalidates upload paths and uses resolved canonical paths for inputRef", async () => { + const setInputFiles = vi.fn(async () => {}); + locator = { + setInputFiles, + elementHandle: vi.fn(async () => null), + }; + page = { + locator: vi.fn(() => ({ first: () => locator })), + }; + + await setInputFilesViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/ok.txt"], + }); + + expect(resolveStrictExistingPathsWithinRoot).toHaveBeenCalledWith({ + rootDir: "/tmp/openclaw/uploads", + requestedPaths: ["/tmp/openclaw/uploads/ok.txt"], + scopeLabel: "uploads directory (/tmp/openclaw/uploads)", + }); + expect(refLocator).toHaveBeenCalledWith(page, "e7"); + expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]); + }); + + it("throws and skips setInputFiles when use-time validation fails", async () => { + resolveStrictExistingPathsWithinRoot.mockResolvedValueOnce({ + ok: false, + error: "Invalid path: must stay within uploads directory", + }); + + const setInputFiles = vi.fn(async () => {}); + locator = { + setInputFiles, + elementHandle: vi.fn(async () => null), + }; + page = { + locator: vi.fn(() => ({ first: () => locator })), + }; + + await expect( + setInputFilesViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + element: "input[type=file]", + paths: ["/tmp/openclaw/uploads/missing.txt"], + }), + ).rejects.toThrow("Invalid path: must stay within uploads directory"); + + expect(setInputFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index 55e130c580e..cd6ad0e165c 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -1,4 +1,5 @@ import type { BrowserFormField } from "./client-actions-core.js"; +import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; import { ensurePageState, forceDisconnectPlaywrightForTarget, @@ -626,9 +627,18 @@ export async function setInputFilesViaPlaywright(opts: { } const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first(); + const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({ + rootDir: DEFAULT_UPLOAD_DIR, + requestedPaths: opts.paths, + scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, + }); + if (!uploadPathsResult.ok) { + throw new Error(uploadPathsResult.error); + } + const resolvedPaths = uploadPathsResult.paths; try { - await locator.setInputFiles(opts.paths); + await locator.setInputFiles(resolvedPaths); } catch (err) { throw toAIFriendlyError(err, inputRef || element); } diff --git a/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts b/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts index 3afbb2b9d40..16264ba9eb3 100644 --- a/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts +++ b/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts @@ -1,4 +1,8 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_UPLOAD_DIR } from "./paths.js"; import { installPwToolsCoreTestHooks, setPwToolsCoreCurrentPage, @@ -9,6 +13,15 @@ const mod = await import("./pw-tools-core.js"); describe("pw-tools-core", () => { it("last file-chooser arm wins", async () => { + const firstPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-1-${crypto.randomUUID()}.txt`); + const secondPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-2-${crypto.randomUUID()}.txt`); + await fs.mkdir(DEFAULT_UPLOAD_DIR, { recursive: true }); + await Promise.all([ + fs.writeFile(firstPath, "1", "utf8"), + fs.writeFile(secondPath, "2", "utf8"), + ]); + const secondCanonicalPath = await fs.realpath(secondPath); + let resolve1: ((value: unknown) => void) | null = null; let resolve2: ((value: unknown) => void) | null = null; @@ -35,24 +48,30 @@ describe("pw-tools-core", () => { keyboard: { press: vi.fn(async () => {}) }, }); - await mod.armFileUploadViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - paths: ["/tmp/1"], - }); - await mod.armFileUploadViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - paths: ["/tmp/2"], - }); + try { + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + paths: [firstPath], + }); + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + paths: [secondPath], + }); - if (!resolve1 || !resolve2) { - throw new Error("file chooser handlers were not registered"); + if (!resolve1 || !resolve2) { + throw new Error("file chooser handlers were not registered"); + } + (resolve1 as (value: unknown) => void)(fc1); + (resolve2 as (value: unknown) => void)(fc2); + await Promise.resolve(); + + expect(fc1.setFiles).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(fc2.setFiles).toHaveBeenCalledWith([secondCanonicalPath]); + }); + } finally { + await Promise.all([fs.rm(firstPath, { force: true }), fs.rm(secondPath, { force: true })]); } - (resolve1 as (value: unknown) => void)(fc1); - (resolve2 as (value: unknown) => void)(fc2); - await Promise.resolve(); - - expect(fc1.setFiles).not.toHaveBeenCalled(); - expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]); }); it("arms the next dialog and accepts/dismisses (default timeout)", async () => { const accept = vi.fn(async () => {}); diff --git a/src/browser/pw-tools-core.screenshots-element-selector.test.ts b/src/browser/pw-tools-core.screenshots-element-selector.test.ts index 843d07050fb..1894d65912f 100644 --- a/src/browser/pw-tools-core.screenshots-element-selector.test.ts +++ b/src/browser/pw-tools-core.screenshots-element-selector.test.ts @@ -1,4 +1,8 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_UPLOAD_DIR } from "./paths.js"; import { getPwToolsCoreSessionMocks, installPwToolsCoreTestHooks, @@ -81,6 +85,10 @@ describe("pw-tools-core", () => { ).rejects.toThrow(/fullPage is not supported/i); }); it("arms the next file chooser and sets files (default timeout)", async () => { + const uploadPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-upload-${crypto.randomUUID()}.txt`); + await fs.mkdir(path.dirname(uploadPath), { recursive: true }); + await fs.writeFile(uploadPath, "fixture", "utf8"); + const canonicalUploadPath = await fs.realpath(uploadPath); const fileChooser = { setFiles: vi.fn(async () => {}) }; const waitForEvent = vi.fn(async (_event: string, _opts: unknown) => fileChooser); setPwToolsCoreCurrentPage({ @@ -88,19 +96,47 @@ describe("pw-tools-core", () => { keyboard: { press: vi.fn(async () => {}) }, }); + try { + await mod.armFileUploadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + paths: [uploadPath], + }); + + // waitForEvent is awaited immediately; handler continues async. + await Promise.resolve(); + + expect(waitForEvent).toHaveBeenCalledWith("filechooser", { + timeout: 120_000, + }); + await vi.waitFor(() => { + expect(fileChooser.setFiles).toHaveBeenCalledWith([canonicalUploadPath]); + }); + } finally { + await fs.rm(uploadPath, { force: true }); + } + }); + it("revalidates file-chooser paths at use-time and cancels missing files", async () => { + const missingPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-missing-${crypto.randomUUID()}.txt`); + const fileChooser = { setFiles: vi.fn(async () => {}) }; + const press = vi.fn(async () => {}); + const waitForEvent = vi.fn(async () => fileChooser); + setPwToolsCoreCurrentPage({ + waitForEvent, + keyboard: { press }, + }); + await mod.armFileUploadViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", targetId: "T1", - paths: ["/tmp/a.txt"], + paths: [missingPath], }); - - // waitForEvent is awaited immediately; handler continues async. await Promise.resolve(); - expect(waitForEvent).toHaveBeenCalledWith("filechooser", { - timeout: 120_000, + await vi.waitFor(() => { + expect(press).toHaveBeenCalledWith("Escape"); }); - expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]); + expect(fileChooser.setFiles).not.toHaveBeenCalled(); }); it("arms the next file chooser and escapes if no paths provided", async () => { const fileChooser = { setFiles: vi.fn(async () => {}) }; From 2aa7842adeedef423be7ce283a9144b9f1a0a669 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:44:36 +0100 Subject: [PATCH 14/95] fix(signal): enforce auth before reaction notification enqueue --- CHANGELOG.md | 1 + ...ends-tool-summaries-responseprefix.test.ts | 59 ++++++++++++++ src/signal/monitor/event-handler.ts | 78 ++++++++++++------- 3 files changed, 110 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d520827bf..d5cbd2384e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 429f9e3896c..cc927fe2b36 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -378,6 +378,65 @@ describe("monitorSignalProvider tool results", () => { expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); }); + it("blocks reaction notifications from unauthorized senders when dmPolicy is allowlist", async () => { + setReactionNotificationConfig("all", { + dmPolicy: "allowlist", + allowFrom: ["+15550007777"], + }); + await receiveSingleEnvelope({ + ...makeBaseEnvelope(), + reactionMessage: { + emoji: "✅", + targetAuthor: "+15550002222", + targetSentTimestamp: 2, + }, + }); + + const events = getDirectSignalEventsFor("+15550001111"); + expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false); + expect(sendMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + }); + + it("blocks reaction notifications from unauthorized senders when dmPolicy is pairing", async () => { + setReactionNotificationConfig("own", { + dmPolicy: "pairing", + allowFrom: [], + account: "+15550009999", + }); + await receiveSingleEnvelope({ + ...makeBaseEnvelope(), + reactionMessage: { + emoji: "✅", + targetAuthor: "+15550009999", + targetSentTimestamp: 2, + }, + }); + + const events = getDirectSignalEventsFor("+15550001111"); + expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false); + expect(sendMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + }); + + it("allows reaction notifications for allowlisted senders when dmPolicy is allowlist", async () => { + setReactionNotificationConfig("all", { + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + }); + await receiveSingleEnvelope({ + ...makeBaseEnvelope(), + reactionMessage: { + emoji: "✅", + targetAuthor: "+15550002222", + targetSentTimestamp: 2, + }, + }); + + const events = getDirectSignalEventsFor("+15550001111"); + expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); + }); + it("notifies on own reactions when target includes uuid + phone", async () => { setReactionNotificationConfig("own", { account: "+15550002222" }); await receiveSingleEnvelope({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index b095626ab46..3cdd8cf5e9d 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -36,6 +36,10 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { + resolveDmGroupAccessDecision, + resolveEffectiveAllowFromLists, +} from "../../security/dm-policy-shared.js"; import { normalizeE164 } from "../../utils.js"; import { formatSignalPairingIdLine, @@ -366,15 +370,45 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const quoteText = dataMessage?.quote?.text?.trim() ?? ""; const hasBodyContent = Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length); + const senderDisplay = formatSignalSenderDisplay(sender); + const storeAllowFrom = + deps.dmPolicy === "allowlist" + ? [] + : await readChannelAllowFromStore("signal").catch(() => []); + const { effectiveAllowFrom: effectiveDmAllow } = resolveEffectiveAllowFromLists({ + allowFrom: deps.allowFrom, + groupAllowFrom: deps.groupAllowFrom, + storeAllowFrom, + dmPolicy: deps.dmPolicy, + }); + const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom]; + const resolveAccessDecision = (isGroup: boolean) => + resolveDmGroupAccessDecision({ + isGroup, + dmPolicy: deps.dmPolicy, + groupPolicy: deps.groupPolicy, + effectiveAllowFrom: effectiveDmAllow, + effectiveGroupAllowFrom: effectiveGroupAllow, + isSenderAllowed: (allowFrom) => isSignalSenderAllowed(sender, allowFrom), + }); + const dmAccess = resolveAccessDecision(false); + const dmAllowed = dmAccess.decision === "allow"; if (reaction && !hasBodyContent) { if (reaction.isRemove) { return; } // Ignore reaction removals const emojiLabel = reaction.emoji?.trim() || "emoji"; - const senderDisplay = formatSignalSenderDisplay(sender); const senderName = envelope.sourceName ?? senderDisplay; logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`); + const groupId = reaction.groupInfo?.groupId ?? undefined; + const groupName = reaction.groupInfo?.groupName ?? undefined; + const isGroup = Boolean(groupId); + const reactionAccess = resolveAccessDecision(isGroup); + if (reactionAccess.decision !== "allow") { + logVerbose(`Blocked signal reaction sender ${senderDisplay} (${reactionAccess.reason})`); + return; + } const targets = deps.resolveSignalReactionTargets(reaction); const shouldNotify = deps.shouldEmitSignalReactionNotification({ mode: deps.reactionMode, @@ -387,9 +421,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { return; } - const groupId = reaction.groupInfo?.groupId ?? undefined; - const groupName = reaction.groupInfo?.groupName ?? undefined; - const isGroup = Boolean(groupId); const senderPeerId = resolveSignalPeerId(sender); const route = resolveAgentRoute({ cfg: deps.cfg, @@ -430,7 +461,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { return; } - const senderDisplay = formatSignalSenderDisplay(sender); const senderRecipient = resolveSignalRecipient(sender); const senderPeerId = resolveSignalPeerId(sender); const senderAllowId = formatSignalSenderId(sender); @@ -441,20 +471,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); - const storeAllowFrom = - deps.dmPolicy === "allowlist" - ? [] - : await readChannelAllowFromStore("signal").catch(() => []); - const effectiveDmAllow = [...deps.allowFrom, ...storeAllowFrom]; - const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom]; - const dmAllowed = - deps.dmPolicy === "open" ? true : isSignalSenderAllowed(sender, effectiveDmAllow); if (!isGroup) { - if (deps.dmPolicy === "disabled") { + if (dmAccess.decision === "block") { + if (deps.dmPolicy !== "disabled") { + logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`); + } return; } - if (!dmAllowed) { + if (dmAccess.decision === "pairing") { if (deps.dmPolicy === "pairing") { const senderId = senderAllowId; const { code, created } = await upsertChannelPairingRequest({ @@ -483,23 +508,20 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { logVerbose(`signal pairing reply failed for ${senderId}: ${String(err)}`); } } - } else { - logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`); } return; } } - if (isGroup && deps.groupPolicy === "disabled") { - logVerbose("Blocked signal group message (groupPolicy: disabled)"); - return; - } - if (isGroup && deps.groupPolicy === "allowlist") { - if (effectiveGroupAllow.length === 0) { - logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)"); - return; - } - if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) { - logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`); + if (isGroup) { + const groupAccess = resolveAccessDecision(true); + if (groupAccess.decision !== "allow") { + if (groupAccess.reason === "groupPolicy=disabled") { + logVerbose("Blocked signal group message (groupPolicy: disabled)"); + } else if (groupAccess.reason === "groupPolicy=allowlist (empty allowlist)") { + logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)"); + } else { + logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`); + } return; } } From 8d1481cb4a9d31bd617e52dc8c392c35689d9dea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:51:34 +0100 Subject: [PATCH 15/95] fix(gateway): require pairing for unpaired operator device auth --- CHANGELOG.md | 1 + src/gateway/server.auth.test.ts | 65 ++++++++++++------- .../server/ws-connection/message-handler.ts | 12 ++-- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cbd2384e8..c2df44e3950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. +- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 8da0e18ef31..83a97644d19 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -1065,7 +1065,7 @@ describe("gateway server auth/connect", () => { } }); - test("skips pairing for operator scope upgrades when shared token auth is valid", async () => { + test("requires pairing for remote operator device identity with shared token auth", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1102,21 +1102,29 @@ describe("gateway server auth/connect", () => { nonce, }; }; - const initialNonce = await readConnectChallengeNonce(ws); - const initial = await connectReq(ws, { + ws.close(); + + const wsRemoteRead = await openWs(port, { host: "gateway.example" }); + const initialNonce = await readConnectChallengeNonce(wsRemoteRead); + const initial = await connectReq(wsRemoteRead, { token: "secret", scopes: ["operator.read"], client, device: buildDevice(["operator.read"], initialNonce), }); - expect(initial.ok).toBe(true); + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); let pairing = await listDevicePairing(); - expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + const pendingAfterRead = pairing.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingAfterRead).toHaveLength(1); + expect(pendingAfterRead[0]?.role).toBe("operator"); + expect(pendingAfterRead[0]?.scopes ?? []).toContain("operator.read"); expect(await getPairedDevice(identity.deviceId)).toBeNull(); + wsRemoteRead.close(); - ws.close(); - - const ws2 = await openWs(port); + const ws2 = await openWs(port, { host: "gateway.example" }); const nonce2 = await readConnectChallengeNonce(ws2); const res = await connectReq(ws2, { token: "secret", @@ -1124,9 +1132,16 @@ describe("gateway server auth/connect", () => { client, device: buildDevice(["operator.admin"], nonce2), }); - expect(res.ok).toBe(true); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); pairing = await listDevicePairing(); - expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + const pendingAfterAdmin = pairing.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingAfterAdmin).toHaveLength(1); + expect(pendingAfterAdmin[0]?.scopes ?? []).toEqual( + expect.arrayContaining(["operator.read", "operator.admin"]), + ); expect(await getPairedDevice(identity.deviceId)).toBeNull(); ws2.close(); await server.close(); @@ -1199,7 +1214,7 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); - test("still requires node pairing while operator shared auth succeeds for the same device", async () => { + test("merges remote node/operator pairing requests for the same unpaired device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1266,23 +1281,25 @@ describe("gateway server auth/connect", () => { expect(nodeConnect.error?.message ?? "").toContain("pairing required"); const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]); - expect(operatorConnect.ok).toBe(true); + expect(operatorConnect.ok).toBe(false); + expect(operatorConnect.error?.message ?? "").toContain("pairing required"); const pending = await listDevicePairing(); const pendingForTestDevice = pending.pending.filter( (entry) => entry.deviceId === identity.deviceId, ); expect(pendingForTestDevice).toHaveLength(1); - expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node"])); - expect(pendingForTestDevice[0]?.roles ?? []).not.toContain("operator"); + expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + expect(pendingForTestDevice[0]?.scopes ?? []).toEqual( + expect.arrayContaining(["operator.read", "operator.write"]), + ); if (!pendingForTestDevice[0]) { throw new Error("expected pending pairing request"); } await approveDevicePairing(pendingForTestDevice[0].requestId); const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(expect.arrayContaining(["node"])); - expect(paired?.roles ?? []).not.toContain("operator"); + expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]); expect(approvedOperatorConnect.ok).toBe(true); @@ -1438,8 +1455,8 @@ describe("gateway server auth/connect", () => { expect(reconnect.ok).toBe(true); const repaired = await getPairedDevice(deviceId); - expect(repaired?.roles).toBeUndefined(); - expect(repaired?.scopes).toBeUndefined(); + expect(repaired?.roles ?? []).toContain("operator"); + expect(repaired?.scopes ?? []).toContain("operator.read"); const list = await listDevicePairing(); expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]); } finally { @@ -1450,7 +1467,7 @@ describe("gateway server auth/connect", () => { } }); - test("allows shared-auth scope escalation even when paired metadata is legacy-shaped", async () => { + test("auto-approves local scope upgrades even when paired metadata is legacy-shaped", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); @@ -1539,9 +1556,13 @@ describe("gateway server auth/connect", () => { expect(pendingUpgrade).toBeUndefined(); const repaired = await getPairedDevice(identity.deviceId); expect(repaired?.role).toBe("operator"); - expect(repaired?.roles).toBeUndefined(); - expect(repaired?.scopes).toBeUndefined(); - expect(repaired?.approvedScopes).not.toContain("operator.admin"); + expect(repaired?.roles ?? []).toContain("operator"); + expect(repaired?.scopes ?? []).toEqual( + expect.arrayContaining(["operator.read", "operator.admin"]), + ); + expect(repaired?.approvedScopes ?? []).toEqual( + expect.arrayContaining(["operator.read", "operator.admin"]), + ); } finally { ws.close(); ws2?.close(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 191278275ee..9708325009f 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -565,18 +565,16 @@ export function attachGatewayWsMessageHandler(params: { return; } - // Shared token/password auth is already gateway-level trust for operator clients. - // In that case, don't force device pairing on first connect. - const skipPairingForOperatorSharedAuth = - role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; const trustedProxyAuthOk = isControlUi && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy"; - const skipPairing = - shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) || - skipPairingForOperatorSharedAuth; + const skipPairing = shouldSkipControlUiPairing( + controlUiAuthPolicy, + sharedAuthOk, + trustedProxyAuthOk, + ); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { From eb73e87f18d1e94ff240c419ffcff6776f551a3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 23:53:43 +0000 Subject: [PATCH 16/95] fix(session): prevent silent overflow on parent thread forks (#26912) Lands #26912 from @markshields-tl with configurable session.parentForkMaxTokens and docs/tests/changelog updates. Co-authored-by: Mark Shields <239231357+markshields-tl@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 4 + .../session-management-compaction.md | 1 + .../reply/agent-runner-execution.ts | 16 +++ src/auto-reply/reply/session.test.ts | 124 ++++++++++++++++++ src/auto-reply/reply/session.ts | 56 ++++++-- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.base.ts | 6 + ...ema.session-maintenance-extensions.test.ts | 13 ++ src/config/zod-schema.session.ts | 1 + 11 files changed, 211 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2df44e3950..1bbd75dac04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 01ad82b6098..9d164fc4ea0 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1250,6 +1250,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden }, resetTriggers: ["/new", "/reset"], store: "~/.openclaw/agents/{agentId}/sessions/sessions.json", + parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables) maintenance: { mode: "warn", // warn | enforce pruneAfter: "30d", @@ -1283,6 +1284,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. - **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. +- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`). + - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history. + - Set `0` to disable this guard and always allow parent forking. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. - **`maintenance`**: session-store cleanup + retention controls. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index aff09a303e8..d258eeb6722 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -128,6 +128,7 @@ Rules of thumb: - **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`. - **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary. - **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins. +- **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index eb8605ccfe1..32022f95453 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -572,6 +572,22 @@ export async function runAgentTurnWithFallback(params: { } } + // If the run completed but with an embedded context overflow error that + // wasn't recovered from (e.g. compaction reset already attempted), surface + // the error to the user instead of silently returning an empty response. + // See #26905: Slack DM sessions silently swallowed messages when context + // overflow errors were returned as embedded error payloads. + const finalEmbeddedError = runResult?.meta?.error; + const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim()); + if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) { + return { + kind: "final", + payload: { + text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.", + }, + }; + } + return { kind: "success", runId, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 8e9c99667b1..cdd8b5310c0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -205,6 +205,130 @@ describe("initSessionState thread forking", () => { warn.mockRestore(); }); + it("skips fork and creates fresh session when parent tokens exceed threshold", async () => { + const root = await makeCaseDir("openclaw-thread-session-overflow-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const parentSessionId = "parent-overflow"; + const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const header = { + type: "session", + version: 3, + id: parentSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + const message = { + type: "message", + id: "m1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Parent prompt" }, + }; + await fs.writeFile( + parentSessionFile, + `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, + "utf-8", + ); + + const storePath = path.join(root, "sessions.json"); + const parentSessionKey = "agent:main:slack:channel:c1"; + // Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000) + await saveSessionStore(storePath, { + [parentSessionKey]: { + sessionId: parentSessionId, + sessionFile: parentSessionFile, + updatedAt: Date.now(), + totalTokens: 170_000, + }, + }); + + const cfg = { + session: { store: storePath }, + } as OpenClawConfig; + + const threadSessionKey = "agent:main:slack:channel:c1:thread:456"; + const result = await initSessionState({ + ctx: { + Body: "Thread reply", + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + // Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent + expect(result.sessionEntry.forkedFromParent).toBe(true); + // Session ID should NOT match the parent — it should be a fresh UUID + expect(result.sessionEntry.sessionId).not.toBe(parentSessionId); + // Session file should NOT be the parent's file (it was not forked) + expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile); + }); + + it("respects session.parentForkMaxTokens override", async () => { + const root = await makeCaseDir("openclaw-thread-session-overflow-override-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const parentSessionId = "parent-override"; + const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const header = { + type: "session", + version: 3, + id: parentSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + const message = { + type: "message", + id: "m1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Parent prompt" }, + }; + await fs.writeFile( + parentSessionFile, + `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, + "utf-8", + ); + + const storePath = path.join(root, "sessions.json"); + const parentSessionKey = "agent:main:slack:channel:c1"; + await saveSessionStore(storePath, { + [parentSessionKey]: { + sessionId: parentSessionId, + sessionFile: parentSessionFile, + updatedAt: Date.now(), + totalTokens: 170_000, + }, + }); + + const cfg = { + session: { + store: storePath, + parentForkMaxTokens: 200_000, + }, + } as OpenClawConfig; + + const threadSessionKey = "agent:main:slack:channel:c1:thread:789"; + const result = await initSessionState({ + ctx: { + Body: "Thread reply", + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.forkedFromParent).toBe(true); + expect(result.sessionEntry.sessionFile).toBeTruthy(); + const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8"); + expect(forkedContent).toContain(parentSessionFile); + }); + it("records topic-specific session files when MessageThreadId is present", async () => { const root = await makeCaseDir("openclaw-topic-session-"); const storePath = path.join(root, "sessions.json"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6494192c58b..59b0c7ba379 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -105,6 +105,21 @@ export type SessionInitResult = { triggerBodyNormalized: string; }; +/** + * Default max parent token count beyond which thread/session parent forking is skipped. + * This prevents new thread sessions from inheriting near-full parent context. + * See #26905. + */ +const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000; + +function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { + const configured = cfg.session?.parentForkMaxTokens; + if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) { + return Math.floor(configured); + } + return DEFAULT_PARENT_FORK_MAX_TOKENS; +} + function forkSessionFromParent(params: { parentEntry: SessionEntry; agentId: string; @@ -171,6 +186,7 @@ export async function initSessionState(params: { const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; + const parentForkMaxTokens = resolveParentForkMaxTokens(cfg); const sessionScope = sessionCfg?.scope ?? "per-sender"; const storePath = resolveStorePath(sessionCfg?.store, { agentId }); @@ -399,21 +415,33 @@ export async function initSessionState(params: { sessionStore[parentSessionKey] && !alreadyForked ) { - log.warn( - `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + - `parentTokens=${sessionStore[parentSessionKey].totalTokens ?? "?"}`, - ); - const forked = forkSessionFromParent({ - parentEntry: sessionStore[parentSessionKey], - agentId, - sessionsDir: path.dirname(storePath), - }); - if (forked) { - sessionId = forked.sessionId; - sessionEntry.sessionId = forked.sessionId; - sessionEntry.sessionFile = forked.sessionFile; + const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0; + if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) { + // Parent context is too large — forking would create a thread session + // that immediately overflows the model's context window. Start fresh + // instead and mark as forked to prevent re-attempts. See #26905. + log.warn( + `skipping parent fork (parent too large): parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + + `parentTokens=${parentTokens} maxTokens=${parentForkMaxTokens}`, + ); sessionEntry.forkedFromParent = true; - log.warn(`forked session created: file=${forked.sessionFile}`); + } else { + log.warn( + `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + + `parentTokens=${parentTokens}`, + ); + const forked = forkSessionFromParent({ + parentEntry: sessionStore[parentSessionKey], + agentId, + sessionsDir: path.dirname(storePath), + }); + if (forked) { + sessionId = forked.sessionId; + sessionEntry.sessionId = forked.sessionId; + sessionEntry.sessionFile = forked.sessionFile; + sessionEntry.forkedFromParent = true; + log.warn(`forked session created: file=${forked.sessionFile}`); + } } } const fallbackSessionFile = !sessionEntry.sessionFile diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e5fcb3aa6b7..bf917461f56 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -973,6 +973,8 @@ export const FIELD_HELP: Record = { "Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.", "session.typingMode": 'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.', + "session.parentForkMaxTokens": + "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.", "session.mainKey": 'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.', "session.sendPolicy": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7a12e9293ba..cd28b1fafb8 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -455,6 +455,7 @@ export const FIELD_LABELS: Record = { "session.store": "Session Store Path", "session.typingIntervalSeconds": "Session Typing Interval (seconds)", "session.typingMode": "Session Typing Mode", + "session.parentForkMaxTokens": "Session Parent Fork Max Tokens", "session.mainKey": "Session Main Key", "session.sendPolicy": "Session Send Policy", "session.sendPolicy.default": "Session Send Policy Default Action", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index cb1b926b53f..676767fc901 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -112,6 +112,12 @@ export type SessionConfig = { store?: string; typingIntervalSeconds?: number; typingMode?: TypingMode; + /** + * Max parent transcript token count allowed for thread/session forking. + * If parent totalTokens is above this value, OpenClaw skips parent fork and + * starts a fresh thread session instead. Set to 0 to disable this guard. + */ + parentForkMaxTokens?: number; mainKey?: string; sendPolicy?: SessionSendPolicyConfig; agentToAgent?: { diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index 6efe8b39907..deb86999934 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -14,6 +14,19 @@ describe("SessionSchema maintenance extensions", () => { ).not.toThrow(); }); + it("accepts parentForkMaxTokens including 0 to disable the guard", () => { + expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow(); + expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow(); + }); + + it("rejects negative parentForkMaxTokens", () => { + expect(() => + SessionSchema.parse({ + parentForkMaxTokens: -1, + }), + ).toThrow(/parentForkMaxTokens/i); + }); + it("accepts disabling reset archive cleanup", () => { expect(() => SessionSchema.parse({ diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 5af707b2804..de23c50846e 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -52,6 +52,7 @@ export const SessionSchema = z store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: TypingModeSchema.optional(), + parentForkMaxTokens: z.number().int().nonnegative().optional(), mainKey: z.string().optional(), sendPolicy: SessionSendPolicySchema.optional(), agentToAgent: z From 42f455739f03c553bb0d7014e8152078b85d8e54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:55:25 +0100 Subject: [PATCH 17/95] fix(security): clarify denyCommands exact-match guidance --- docs/cli/security.md | 2 +- docs/gateway/security/index.md | 2 +- src/config/schema.help.ts | 2 +- src/node-host/invoke-system-run.test.ts | 25 +++++++++++++++++++++++++ src/security/audit-extra.sync.ts | 4 ++-- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/cli/security.md b/docs/cli/security.md index fe8af41ec25..cc705b31a30 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -29,7 +29,7 @@ It also emits `security.trust_model.multi_user_heuristic` when config suggests l For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. -It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. +It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 3824d1d283e..a61a81eab1e 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -188,7 +188,7 @@ If more than one person can DM your bot: - **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints). - **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths). - **Plugins** (extensions exist without an explicit allowlist). -- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy). +- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns because matching is exact command-name only (for example `system.run`) and does not inspect shell text; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy). - **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host). - **Model hygiene** (warn when configured models look legacy; not a hard block). diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bf917461f56..a479ec0a853 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -330,7 +330,7 @@ export const FIELD_HELP: Record = { "gateway.nodes.allowCommands": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "gateway.nodes.denyCommands": - "Commands to block even if present in node claims or default allowlist.", + "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).", nodeHost: "Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.", "nodeHost.browserProxy": diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 2d939c7726e..d1917199067 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -365,6 +365,31 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); }); + it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => { + const payloads = ["openclaw status; id", "openclaw status; cat /etc/passwd"]; + for (const payload of payloads) { + const command = + process.platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", payload] + : ["/bin/sh", "-lc", payload]; + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "allowlist", + ask: "on-miss", + command, + }); + expect(runCommand, payload).not.toHaveBeenCalled(); + expect(sendInvokeResult, payload).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + } + }); + it("denies nested env shell payloads when wrapper depth is exceeded", async () => { if (process.platform === "win32") { return; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index daa60aed73f..a3f81d40870 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -955,11 +955,11 @@ export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): Secu severity: "warn", title: "Some gateway.nodes.denyCommands entries are ineffective", detail: - "gateway.nodes.denyCommands uses exact command-name matching only.\n" + + "gateway.nodes.denyCommands uses exact node command-name matching only (for example `system.run`), not shell-text filtering inside a command payload.\n" + detailParts.map((entry) => `- ${entry}`).join("\n"), remediation: `Use exact command names (for example: ${examples.join(", ")}). ` + - "If you need broader restrictions, remove risky commands from allowCommands/default workflows.", + "If you need broader restrictions, remove risky command IDs from allowCommands/default workflows and tighten tools.exec policy.", }); return findings; From b090d6019b9e540c280ea30900fb4bf71a4fafca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 23:57:50 +0000 Subject: [PATCH 18/95] test(agent-runner): add overflow empty-payload regression coverage (#26905) --- .../reply/agent-runner.runreplyagent.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 52d1e4550c2..ee8ddc25179 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -1188,6 +1188,54 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); + it("surfaces overflow fallback when embedded run returns empty payloads", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("conversation is too large"), + }); + if (!payload) { + throw new Error("expected payload"); + } + expect(payload.text).toContain("/new"); + }); + + it("surfaces overflow fallback when embedded payload text is whitespace-only", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: " \n\t ", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("conversation is too large"), + }); + if (!payload) { + throw new Error("expected payload"); + } + expect(payload.text).toContain("/new"); + }); + it("resets the session after role ordering payloads", async () => { await withTempStateDir(async (stateDir) => { const sessionId = "session"; From 39cc547f74f137b563cbfb46cf027768ad2fd2a4 Mon Sep 17 00:00:00 2001 From: User Date: Thu, 26 Feb 2026 07:19:30 +0800 Subject: [PATCH 19/95] fix(discord): include embed title in fallback text (#26907) --- src/discord/monitor/message-utils.test.ts | 44 +++++++++++++++++++++++ src/discord/monitor/message-utils.ts | 20 +++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index de8976ce5d2..fd3f2c4d077 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -323,6 +323,50 @@ describe("resolveDiscordMessageText", () => { expect(text).toBe(" (1 sticker)"); }); + + it("uses embed title when content is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + embeds: [{ title: "Breaking" }], + }), + ); + + expect(text).toBe("Breaking"); + }); + + it("uses embed description when content is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + embeds: [{ description: "Details" }], + }), + ); + + expect(text).toBe("Details"); + }); + + it("joins embed title and description when content is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + embeds: [{ title: "Breaking", description: "Details" }], + }), + ); + + expect(text).toBe("Breaking\nDetails"); + }); + + it("prefers message content over embed fallback text", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "hello from content", + embeds: [{ title: "Breaking", description: "Details" }], + }), + ); + + expect(text).toBe("hello from content"); + }); }); describe("resolveDiscordChannelInfo", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 3c523d277ef..ac07f1e70e5 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -403,17 +403,32 @@ function buildDiscordMediaPlaceholder(params: { return attachmentText || stickerText || ""; } +function resolveDiscordEmbedText( + embed?: { title?: string | null; description?: string | null } | null, +): string { + const title = embed?.title?.trim() || ""; + const description = embed?.description?.trim() || ""; + if (title && description) { + return `${title}\n${description}`; + } + return title || description || ""; +} + export function resolveDiscordMessageText( message: Message, options?: { fallbackText?: string; includeForwarded?: boolean }, ): string { + const embedText = resolveDiscordEmbedText( + (message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ?? + null, + ); const baseText = message.content?.trim() || buildDiscordMediaPlaceholder({ attachments: message.attachments ?? undefined, stickers: resolveDiscordMessageStickers(message), }) || - message.embeds?.[0]?.description || + embedText || options?.fallbackText?.trim() || ""; if (!options?.includeForwarded) { @@ -477,8 +492,7 @@ function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): st attachments: snapshot.attachments ?? undefined, stickers: resolveDiscordSnapshotStickers(snapshot), }); - const embed = snapshot.embeds?.[0]; - const embedText = embed?.description?.trim() || embed?.title?.trim() || ""; + const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]); return content || attachmentText || embedText || ""; } From a0a229a3bb92e57fae8be82706187fdd11448693 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 25 Feb 2026 23:50:41 +0000 Subject: [PATCH 20/95] Discord: align embed fallback in thread starter parsing --- src/discord/monitor/message-utils.test.ts | 28 ++++++++++ src/discord/monitor/message-utils.ts | 2 +- src/discord/monitor/threading.starter.test.ts | 55 +++++++++++++++++++ src/discord/monitor/threading.ts | 12 +++- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 src/discord/monitor/threading.starter.test.ts diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index fd3f2c4d077..28dd142a1e4 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -367,6 +367,34 @@ describe("resolveDiscordMessageText", () => { expect(text).toBe("hello from content"); }); + + it("joins forwarded snapshot embed title and description when content is empty", () => { + const text = resolveDiscordMessageText( + asMessage({ + content: "", + rawData: { + message_snapshots: [ + { + message: { + content: "", + embeds: [{ title: "Forwarded title", description: "Forwarded details" }], + attachments: [], + author: { + id: "u2", + username: "Bob", + discriminator: "0", + }, + }, + }, + ], + }, + }), + { includeForwarded: true }, + ); + + expect(text).toContain("[Forwarded message from @Bob]"); + expect(text).toContain("Forwarded title\nForwarded details"); + }); }); describe("resolveDiscordChannelInfo", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index ac07f1e70e5..b18e877b1ce 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -403,7 +403,7 @@ function buildDiscordMediaPlaceholder(params: { return attachmentText || stickerText || ""; } -function resolveDiscordEmbedText( +export function resolveDiscordEmbedText( embed?: { title?: string | null; description?: string | null } | null, ): string { const title = embed?.title?.trim() || ""; diff --git a/src/discord/monitor/threading.starter.test.ts b/src/discord/monitor/threading.starter.test.ts new file mode 100644 index 00000000000..07268d7fae9 --- /dev/null +++ b/src/discord/monitor/threading.starter.test.ts @@ -0,0 +1,55 @@ +import { ChannelType, type Client } from "@buape/carbon"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetDiscordThreadStarterCacheForTest, + resolveDiscordThreadStarter, +} from "./threading.js"; + +describe("resolveDiscordThreadStarter", () => { + beforeEach(() => { + __resetDiscordThreadStarterCacheForTest(); + }); + + it("falls back to joined embed title and description when content is empty", async () => { + const get = vi.fn().mockResolvedValue({ + content: " ", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + timestamp: "2026-02-24T12:00:00.000Z", + }); + const client = { rest: { get } } as unknown as Client; + + const result = await resolveDiscordThreadStarter({ + channel: { id: "thread-1" }, + client, + parentId: "parent-1", + parentType: ChannelType.GuildText, + resolveTimestampMs: () => 123, + }); + + expect(result).toEqual({ + text: "Alert\nDetails", + author: "Alice", + timestamp: 123, + }); + }); + + it("prefers starter content over embed fallback text", async () => { + const get = vi.fn().mockResolvedValue({ + content: "starter content", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + }); + const client = { rest: { get } } as unknown as Client; + + const result = await resolveDiscordThreadStarter({ + channel: { id: "thread-1" }, + client, + parentId: "parent-1", + parentType: ChannelType.GuildText, + resolveTimestampMs: () => undefined, + }); + + expect(result?.text).toBe("starter content"); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 877329c2995..14377d8e644 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -7,7 +7,11 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { truncateUtf16Safe } from "../../utils.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; import type { DiscordMessageEvent } from "./listeners.js"; -import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js"; +import { + resolveDiscordChannelInfo, + resolveDiscordEmbedText, + resolveDiscordMessageChannelId, +} from "./message-utils.js"; export type DiscordThreadChannel = { id: string; @@ -172,7 +176,7 @@ export async function resolveDiscordThreadStarter(params: { Routes.channelMessage(messageChannelId, params.channel.id), )) as { content?: string | null; - embeds?: Array<{ description?: string | null }>; + embeds?: Array<{ title?: string | null; description?: string | null }>; member?: { nick?: string | null; displayName?: string | null }; author?: { id?: string | null; @@ -184,7 +188,9 @@ export async function resolveDiscordThreadStarter(params: { if (!starter) { return null; } - const text = starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? ""; + const content = starter.content?.trim() ?? ""; + const embedText = resolveDiscordEmbedText(starter.embeds?.[0]); + const text = content || embedText; if (!text) { return null; } From f83719937afbade398284a06752270d73d8f5efc Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 25 Feb 2026 23:51:11 +0000 Subject: [PATCH 21/95] Changelog: note Discord embed fallback coverage --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbd75dac04..fb4c5ec1afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. +- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. From c6dfa26f037977b82e91648bee47d99360d604d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:01:58 +0100 Subject: [PATCH 22/95] refactor(signal): unify reaction auth flow and table-drive tests --- ...ends-tool-summaries-responseprefix.test.ts | 76 +++----- src/signal/monitor/event-handler.ts | 171 +++++++++++------- 2 files changed, 133 insertions(+), 114 deletions(-) diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index cc927fe2b36..a06d17d61d9 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -378,65 +378,49 @@ describe("monitorSignalProvider tool results", () => { expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); }); - it("blocks reaction notifications from unauthorized senders when dmPolicy is allowlist", async () => { - setReactionNotificationConfig("all", { - dmPolicy: "allowlist", - allowFrom: ["+15550007777"], - }); + it.each([ + { + name: "blocks reaction notifications from unauthorized senders when dmPolicy is allowlist", + mode: "all" as const, + extra: { dmPolicy: "allowlist", allowFrom: ["+15550007777"] } as Record, + targetAuthor: "+15550002222", + shouldEnqueue: false, + }, + { + name: "blocks reaction notifications from unauthorized senders when dmPolicy is pairing", + mode: "own" as const, + extra: { + dmPolicy: "pairing", + allowFrom: [], + account: "+15550009999", + } as Record, + targetAuthor: "+15550009999", + shouldEnqueue: false, + }, + { + name: "allows reaction notifications for allowlisted senders when dmPolicy is allowlist", + mode: "all" as const, + extra: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } as Record, + targetAuthor: "+15550002222", + shouldEnqueue: true, + }, + ])("$name", async ({ mode, extra, targetAuthor, shouldEnqueue }) => { + setReactionNotificationConfig(mode, extra); await receiveSingleEnvelope({ ...makeBaseEnvelope(), reactionMessage: { emoji: "✅", - targetAuthor: "+15550002222", + targetAuthor, targetSentTimestamp: 2, }, }); const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false); + expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue); expect(sendMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).not.toHaveBeenCalled(); }); - it("blocks reaction notifications from unauthorized senders when dmPolicy is pairing", async () => { - setReactionNotificationConfig("own", { - dmPolicy: "pairing", - allowFrom: [], - account: "+15550009999", - }); - await receiveSingleEnvelope({ - ...makeBaseEnvelope(), - reactionMessage: { - emoji: "✅", - targetAuthor: "+15550009999", - targetSentTimestamp: 2, - }, - }); - - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(false); - expect(sendMock).not.toHaveBeenCalled(); - expect(upsertPairingRequestMock).not.toHaveBeenCalled(); - }); - - it("allows reaction notifications for allowlisted senders when dmPolicy is allowlist", async () => { - setReactionNotificationConfig("all", { - dmPolicy: "allowlist", - allowFrom: ["+15550001111"], - }); - await receiveSingleEnvelope({ - ...makeBaseEnvelope(), - reactionMessage: { - emoji: "✅", - targetAuthor: "+15550002222", - targetSentTimestamp: 2, - }, - }); - - const events = getDirectSignalEventsFor("+15550001111"); - expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true); - }); - it("notifies on own reactions when target includes uuid + phone", async () => { setReactionNotificationConfig("own", { account: "+15550002222" }); await receiveSingleEnvelope({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 3cdd8cf5e9d..e87158d8d8d 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -49,9 +49,15 @@ import { resolveSignalPeerId, resolveSignalRecipient, resolveSignalSender, + type SignalSender, } from "../identity.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; -import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js"; +import type { + SignalEnvelope, + SignalEventHandlerDeps, + SignalReactionMessage, + SignalReceivePayload, +} from "./event-handler.types.js"; import { renderSignalMentions } from "./mentions.js"; export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" }); @@ -321,6 +327,85 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }, }); + function handleReactionOnlyInbound(params: { + envelope: SignalEnvelope; + sender: SignalSender; + senderDisplay: string; + reaction: SignalReactionMessage; + hasBodyContent: boolean; + resolveAccessDecision: (isGroup: boolean) => { + decision: "allow" | "block" | "pairing"; + reason: string; + }; + }): boolean { + if (params.hasBodyContent) { + return false; + } + if (params.reaction.isRemove) { + return true; // Ignore reaction removals + } + const emojiLabel = params.reaction.emoji?.trim() || "emoji"; + const senderName = params.envelope.sourceName ?? params.senderDisplay; + logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`); + const groupId = params.reaction.groupInfo?.groupId ?? undefined; + const groupName = params.reaction.groupInfo?.groupName ?? undefined; + const isGroup = Boolean(groupId); + const reactionAccess = params.resolveAccessDecision(isGroup); + if (reactionAccess.decision !== "allow") { + logVerbose( + `Blocked signal reaction sender ${params.senderDisplay} (${reactionAccess.reason})`, + ); + return true; + } + const targets = deps.resolveSignalReactionTargets(params.reaction); + const shouldNotify = deps.shouldEmitSignalReactionNotification({ + mode: deps.reactionMode, + account: deps.account, + targets, + sender: params.sender, + allowlist: deps.reactionAllowlist, + }); + if (!shouldNotify) { + return true; + } + + const senderPeerId = resolveSignalPeerId(params.sender); + const route = resolveAgentRoute({ + cfg: deps.cfg, + channel: "signal", + accountId: deps.accountId, + peer: { + kind: isGroup ? "group" : "direct", + id: isGroup ? (groupId ?? "unknown") : senderPeerId, + }, + }); + const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined; + const messageId = params.reaction.targetSentTimestamp + ? String(params.reaction.targetSentTimestamp) + : "unknown"; + const text = deps.buildSignalReactionSystemEventText({ + emojiLabel, + actorLabel: senderName, + messageId, + targetLabel: targets[0]?.display, + groupLabel, + }); + const senderId = formatSignalSenderId(params.sender); + const contextKey = [ + "signal", + "reaction", + "added", + messageId, + senderId, + emojiLabel, + groupId ?? "", + ] + .filter(Boolean) + .join(":"); + enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey }); + return true; + } + return async (event: { event?: string; data?: string }) => { if (event.event !== "receive" || !event.data) { return; @@ -375,13 +460,13 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { deps.dmPolicy === "allowlist" ? [] : await readChannelAllowFromStore("signal").catch(() => []); - const { effectiveAllowFrom: effectiveDmAllow } = resolveEffectiveAllowFromLists({ - allowFrom: deps.allowFrom, - groupAllowFrom: deps.groupAllowFrom, - storeAllowFrom, - dmPolicy: deps.dmPolicy, - }); - const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom]; + const { effectiveAllowFrom: effectiveDmAllow, effectiveGroupAllowFrom: effectiveGroupAllow } = + resolveEffectiveAllowFromLists({ + allowFrom: deps.allowFrom, + groupAllowFrom: deps.groupAllowFrom, + storeAllowFrom, + dmPolicy: deps.dmPolicy, + }); const resolveAccessDecision = (isGroup: boolean) => resolveDmGroupAccessDecision({ isGroup, @@ -394,67 +479,17 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const dmAccess = resolveAccessDecision(false); const dmAllowed = dmAccess.decision === "allow"; - if (reaction && !hasBodyContent) { - if (reaction.isRemove) { - return; - } // Ignore reaction removals - const emojiLabel = reaction.emoji?.trim() || "emoji"; - const senderName = envelope.sourceName ?? senderDisplay; - logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`); - const groupId = reaction.groupInfo?.groupId ?? undefined; - const groupName = reaction.groupInfo?.groupName ?? undefined; - const isGroup = Boolean(groupId); - const reactionAccess = resolveAccessDecision(isGroup); - if (reactionAccess.decision !== "allow") { - logVerbose(`Blocked signal reaction sender ${senderDisplay} (${reactionAccess.reason})`); - return; - } - const targets = deps.resolveSignalReactionTargets(reaction); - const shouldNotify = deps.shouldEmitSignalReactionNotification({ - mode: deps.reactionMode, - account: deps.account, - targets, + if ( + reaction && + handleReactionOnlyInbound({ + envelope, sender, - allowlist: deps.reactionAllowlist, - }); - if (!shouldNotify) { - return; - } - - const senderPeerId = resolveSignalPeerId(sender); - const route = resolveAgentRoute({ - cfg: deps.cfg, - channel: "signal", - accountId: deps.accountId, - peer: { - kind: isGroup ? "group" : "direct", - id: isGroup ? (groupId ?? "unknown") : senderPeerId, - }, - }); - const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined; - const messageId = reaction.targetSentTimestamp - ? String(reaction.targetSentTimestamp) - : "unknown"; - const text = deps.buildSignalReactionSystemEventText({ - emojiLabel, - actorLabel: senderName, - messageId, - targetLabel: targets[0]?.display, - groupLabel, - }); - const senderId = formatSignalSenderId(sender); - const contextKey = [ - "signal", - "reaction", - "added", - messageId, - senderId, - emojiLabel, - groupId ?? "", - ] - .filter(Boolean) - .join(":"); - enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey }); + senderDisplay, + reaction, + hasBodyContent, + resolveAccessDecision, + }) + ) { return; } if (!dataMessage) { From e56b0cf1a04f992ac6ebc775899f48ea31687640 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:02:36 +0100 Subject: [PATCH 23/95] fix: enforce telegram reaction authorization --- CHANGELOG.md | 1 + docs/channels/telegram.md | 1 + src/telegram/bot-handlers.ts | 187 +++++++++++++++++++++++++---------- src/telegram/bot.test.ts | 125 +++++++++++++++++++++++ 4 files changed, 260 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4c5ec1afc..6d5bd81477d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 6a454bd8dcf..46db95202b4 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -553,6 +553,7 @@ curl "https://api.telegram.org/bot/getUpdates" Notes: - `own` means user reactions to bot-sent messages only (best-effort via sent-message cache). + - Reaction events still respect Telegram access controls (`dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`); unauthorized senders are dropped. - Telegram does not provide thread IDs in reaction updates. - non-forum groups route to group chat session - forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index e4d42cd889e..e7660717293 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -507,6 +507,99 @@ export const registerTelegramHandlers = ({ return false; }; + const isTelegramEventSenderAuthorized = async (params: { + chatId: number; + chatTitle?: string; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + senderId: string; + senderUsername: string; + enforceDirectAuthorization: boolean; + enforceGroupAllowlistAuthorization: boolean; + deniedDmReason: string; + deniedGroupReason: string; + groupAllowContext?: Awaited>; + }) => { + const { + chatId, + chatTitle, + isGroup, + isForum, + messageThreadId, + senderId, + senderUsername, + enforceDirectAuthorization, + enforceGroupAllowlistAuthorization, + deniedDmReason, + deniedGroupReason, + groupAllowContext: preResolvedGroupAllowContext, + } = params; + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; + const groupAllowContext = + preResolvedGroupAllowContext ?? + (await resolveTelegramGroupAllowFromContext({ + chatId, + accountId, + dmPolicy, + isForum, + messageThreadId, + groupAllowFrom, + resolveTelegramGroupConfig, + })); + const { + resolvedThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + effectiveGroupAllow, + hasGroupAllowOverride, + } = groupAllowContext; + if ( + shouldSkipGroupMessage({ + isGroup, + chatId, + chatTitle, + resolvedThreadId, + senderId, + senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + }) + ) { + return false; + } + + if (!isGroup && enforceDirectAuthorization) { + if (dmPolicy === "disabled") { + logVerbose( + `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`, + ); + return false; + } + if (dmPolicy !== "open") { + const effectiveDmAllow = normalizeAllowFromWithStore({ + allowFrom, + storeAllowFrom, + dmPolicy, + }); + if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) { + logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`); + return false; + } + } + } + if (isGroup && enforceGroupAllowlistAuthorization) { + if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) { + logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`); + return false; + } + } + return true; + }; + // Handle emoji reactions to messages. bot.on("message_reaction", async (ctx) => { try { @@ -521,6 +614,10 @@ export const registerTelegramHandlers = ({ const chatId = reaction.chat.id; const messageId = reaction.message_id; const user = reaction.user; + const senderId = user?.id != null ? String(user.id) : ""; + const senderUsername = user?.username ?? ""; + const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const isForum = reaction.chat.is_forum === true; // Resolve reaction notification mode (default: "own"). const reactionMode = telegramCfg.reactionNotifications ?? "own"; @@ -533,6 +630,21 @@ export const registerTelegramHandlers = ({ if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) { return; } + const senderAuthorized = await isTelegramEventSenderAuthorized({ + chatId, + chatTitle: reaction.chat.title, + isGroup, + isForum, + senderId, + senderUsername, + enforceDirectAuthorization: true, + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "reaction unauthorized by dm policy/allowlist", + deniedGroupReason: "reaction unauthorized by group allowlist", + }); + if (!senderAuthorized) { + return; + } // Detect added reactions. const oldEmojis = new Set( @@ -552,12 +664,12 @@ export const registerTelegramHandlers = ({ const senderName = user ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username : undefined; - const senderUsername = user?.username ? `@${user.username}` : undefined; + const senderUsernameLabel = user?.username ? `@${user.username}` : undefined; let senderLabel = senderName; - if (senderName && senderUsername) { - senderLabel = `${senderName} (${senderUsername})`; - } else if (!senderName && senderUsername) { - senderLabel = senderUsername; + if (senderName && senderUsernameLabel) { + senderLabel = `${senderName} (${senderUsernameLabel})`; + } else if (!senderName && senderUsernameLabel) { + senderLabel = senderUsernameLabel; } if (!senderLabel && user?.id) { senderLabel = `id:${user.id}`; @@ -567,8 +679,6 @@ export const registerTelegramHandlers = ({ // Reactions target a specific message_id; the Telegram Bot API does not include // message_thread_id on MessageReactionUpdated, so we route to the chat-level // session (forum topic routing is not available for reactions). - const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; - const isForum = reaction.chat.is_forum === true; const resolvedThreadId = isForum ? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined }) : undefined; @@ -864,58 +974,27 @@ export const registerTelegramHandlers = ({ groupAllowFrom, resolveTelegramGroupConfig, }); - const { - resolvedThreadId, - storeAllowFrom, - groupConfig, - topicConfig, - effectiveGroupAllow, - hasGroupAllowOverride, - } = groupAllowContext; - const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; - const effectiveDmAllow = normalizeAllowFromWithStore({ - allowFrom: telegramCfg.allowFrom, - storeAllowFrom, - dmPolicy, - }); + const { resolvedThreadId, storeAllowFrom } = groupAllowContext; const senderId = callback.from?.id ? String(callback.from.id) : ""; const senderUsername = callback.from?.username ?? ""; - if ( - shouldSkipGroupMessage({ - isGroup, - chatId, - chatTitle: callbackMessage.chat.title, - resolvedThreadId, - senderId, - senderUsername, - effectiveGroupAllow, - hasGroupAllowOverride, - groupConfig, - topicConfig, - }) - ) { + const senderAuthorized = await isTelegramEventSenderAuthorized({ + chatId, + chatTitle: callbackMessage.chat.title, + isGroup, + isForum, + messageThreadId, + senderId, + senderUsername, + enforceDirectAuthorization: inlineButtonsScope === "allowlist", + enforceGroupAllowlistAuthorization: inlineButtonsScope === "allowlist", + deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist", + deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist", + groupAllowContext, + }); + if (!senderAuthorized) { return; } - if (inlineButtonsScope === "allowlist") { - if (!isGroup) { - if (dmPolicy === "disabled") { - return; - } - if (dmPolicy !== "open") { - const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername); - if (!allowed) { - return; - } - } - } else { - const allowed = isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername); - if (!allowed) { - return; - } - } - } - const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); if (paginationMatch) { const pageValue = paginationMatch[1]; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 03380dbbf62..4a605abb170 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -832,6 +832,131 @@ describe("createTelegramBot", () => { ); }); + it("blocks reaction when dmPolicy is disabled", async () => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "disabled", reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 510 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("blocks reaction in allowlist mode for unauthorized direct sender", async () => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "allowlist", allowFrom: ["12345"], reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 511 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("allows reaction in allowlist mode for authorized direct sender", async () => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 512 }, + messageReaction: { + chat: { id: 1234, type: "private" }, + message_id: 42, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + }); + + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + }); + + it("blocks reaction in group allowlist mode for unauthorized sender", async () => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + groupPolicy: "allowlist", + groupAllowFrom: ["12345"], + reactionNotifications: "all", + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: 513 }, + messageReaction: { + chat: { id: 9999, type: "supergroup" }, + message_id: 77, + user: { id: 9, first_name: "Ada" }, + date: 1736380800, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "🔥" }], + }, + }); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + it("skips reaction when reactionNotifications is off", async () => { onSpy.mockClear(); enqueueSystemEventSpy.mockClear(); From 496a76c03ba85e15ea715e5a583e498ae04d36e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:01:17 +0100 Subject: [PATCH 24/95] fix(security): harden browser trace/download temp path handling --- CHANGELOG.md | 1 + src/browser/paths.test.ts | 40 ++++++ src/browser/paths.ts | 62 +++++++++ src/browser/routes/agent.act.ts | 16 ++- src/browser/routes/agent.debug.ts | 4 +- ...-contract-form-layout-act-commands.test.ts | 73 ++++++++++- src/infra/tmp-openclaw-dir.test.ts | 119 +++++++++++++----- src/infra/tmp-openclaw-dir.ts | 47 ++++++- 8 files changed, 322 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5bd81477d..521368ffd16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. +- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. diff --git a/src/browser/paths.test.ts b/src/browser/paths.test.ts index 1599c3895b2..0fe27fe1e4e 100644 --- a/src/browser/paths.test.ts +++ b/src/browser/paths.test.ts @@ -7,6 +7,7 @@ import { resolvePathsWithinRoot, resolvePathWithinRoot, resolveStrictExistingPathsWithinRoot, + resolveWritablePathWithinRoot, } from "./paths.js"; async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> { @@ -245,6 +246,45 @@ describe("resolvePathWithinRoot", () => { }); }); +describe("resolveWritablePathWithinRoot", () => { + it("accepts a writable path under root when parent is a real directory", async () => { + await withFixtureRoot(async ({ uploadsDir }) => { + const result = await resolveWritablePathWithinRoot({ + rootDir: uploadsDir, + requestedPath: "safe.txt", + scopeLabel: "uploads directory", + }); + expect(result).toEqual({ + ok: true, + path: path.resolve(uploadsDir, "safe.txt"), + }); + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects write paths routed through a symlinked parent directory", + async () => { + await withFixtureRoot(async ({ baseDir, uploadsDir }) => { + const outsideDir = path.join(baseDir, "outside"); + await fs.mkdir(outsideDir, { recursive: true }); + const symlinkDir = path.join(uploadsDir, "escape-link"); + await fs.symlink(outsideDir, symlinkDir); + + const result = await resolveWritablePathWithinRoot({ + rootDir: uploadsDir, + requestedPath: "escape-link/pwned.txt", + scopeLabel: "uploads directory", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("must stay within uploads directory"); + } + }); + }, + ); +}); + describe("resolvePathsWithinRoot", () => { it("resolves all valid in-root paths", () => { const result = resolvePathsWithinRoot({ diff --git a/src/browser/paths.ts b/src/browser/paths.ts index 88a541b75dc..34e927f8c5b 100644 --- a/src/browser/paths.ts +++ b/src/browser/paths.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js"; +import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir(); @@ -30,6 +31,67 @@ export function resolvePathWithinRoot(params: { return { ok: true, path: resolved }; } +export async function resolveWritablePathWithinRoot(params: { + rootDir: string; + requestedPath: string; + scopeLabel: string; + defaultFileName?: string; +}): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + const lexical = resolvePathWithinRoot(params); + if (!lexical.ok) { + return lexical; + } + + const invalid = (): { ok: false; error: string } => ({ + ok: false, + error: `Invalid path: must stay within ${params.scopeLabel}`, + }); + + const rootDir = path.resolve(params.rootDir); + let rootRealPath: string; + try { + const rootLstat = await fs.lstat(rootDir); + if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) { + return invalid(); + } + rootRealPath = await fs.realpath(rootDir); + } catch { + return invalid(); + } + + const requestedPath = lexical.path; + const parentDir = path.dirname(requestedPath); + try { + const parentLstat = await fs.lstat(parentDir); + if (!parentLstat.isDirectory() || parentLstat.isSymbolicLink()) { + return invalid(); + } + const parentRealPath = await fs.realpath(parentDir); + if (!isPathInside(rootRealPath, parentRealPath)) { + return invalid(); + } + } catch { + return invalid(); + } + + try { + const targetLstat = await fs.lstat(requestedPath); + if (targetLstat.isSymbolicLink() || !targetLstat.isFile()) { + return invalid(); + } + const targetRealPath = await fs.realpath(requestedPath); + if (!isPathInside(rootRealPath, targetRealPath)) { + return invalid(); + } + } catch (err) { + if (!isNotFoundPathError(err)) { + return invalid(); + } + } + + return lexical; +} + export function resolvePathsWithinRoot(params: { rootDir: string; requestedPaths: string[]; diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 78fa2f6856c..42ea8444f53 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import type { BrowserFormField } from "../client-actions-core.js"; import type { BrowserRouteContext } from "../server-context.js"; import { @@ -15,14 +16,17 @@ import { import { DEFAULT_DOWNLOAD_DIR, DEFAULT_UPLOAD_DIR, - resolvePathWithinRoot, + resolveWritablePathWithinRoot, resolveExistingPathsWithinRoot, } from "./path-output.js"; import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js"; import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; -function resolveDownloadPathOrRespond(res: BrowserResponse, requestedPath: string): string | null { - const downloadPathResult = resolvePathWithinRoot({ +async function resolveDownloadPathOrRespond( + res: BrowserResponse, + requestedPath: string, +): Promise { + const downloadPathResult = await resolveWritablePathWithinRoot({ rootDir: DEFAULT_DOWNLOAD_DIR, requestedPath, scopeLabel: "downloads directory", @@ -466,9 +470,10 @@ export function registerBrowserAgentActRoutes( targetId, feature: "wait for download", run: async ({ cdpUrl, tab, pw }) => { + await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); let downloadPath: string | undefined; if (out.trim()) { - const resolvedDownloadPath = resolveDownloadPathOrRespond(res, out); + const resolvedDownloadPath = await resolveDownloadPathOrRespond(res, out); if (!resolvedDownloadPath) { return; } @@ -504,7 +509,8 @@ export function registerBrowserAgentActRoutes( targetId, feature: "download", run: async ({ cdpUrl, tab, pw }) => { - const downloadPath = resolveDownloadPathOrRespond(res, out); + await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); + const downloadPath = await resolveDownloadPathOrRespond(res, out); if (!downloadPath) { return; } diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts index fab517d9589..b3b6ee0946c 100644 --- a/src/browser/routes/agent.debug.ts +++ b/src/browser/routes/agent.debug.ts @@ -8,7 +8,7 @@ import { resolveTargetIdFromQuery, withPlaywrightRouteContext, } from "./agent.shared.js"; -import { DEFAULT_TRACE_DIR, resolvePathWithinRoot } from "./path-output.js"; +import { DEFAULT_TRACE_DIR, resolveWritablePathWithinRoot } from "./path-output.js"; import type { BrowserRouteRegistrar } from "./types.js"; import { toBoolean, toStringOrEmpty } from "./utils.js"; @@ -122,7 +122,7 @@ export function registerBrowserAgentDebugRoutes( const id = crypto.randomUUID(); const dir = DEFAULT_TRACE_DIR; await fs.mkdir(dir, { recursive: true }); - const tracePathResult = resolvePathWithinRoot({ + const tracePathResult = await resolveWritablePathWithinRoot({ rootDir: dir, requestedPath: out, scopeLabel: "trace directory", diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 0328736eade..e96193e5995 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -1,7 +1,9 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fetch as realFetch } from "undici"; import { describe, expect, it } from "vitest"; -import { DEFAULT_UPLOAD_DIR } from "./paths.js"; +import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js"; import { installAgentContractHooks, postJson, @@ -16,6 +18,23 @@ import { const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); +async function withSymlinkPathEscape(params: { + rootDir: string; + run: (relativePath: string) => Promise; +}): Promise { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-route-escape-")); + const linkName = `escape-link-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const linkPath = path.join(params.rootDir, linkName); + await fs.mkdir(params.rootDir, { recursive: true }); + await fs.symlink(outsideDir, linkPath); + try { + return await params.run(`${linkName}/pwned.zip`); + } finally { + await fs.unlink(linkPath).catch(() => {}); + await fs.rm(outsideDir, { recursive: true, force: true }).catch(() => {}); + } +} + describe("browser control server", () => { installAgentContractHooks(); @@ -268,6 +287,58 @@ describe("browser control server", () => { expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled(); }); + it.runIf(process.platform !== "win32")( + "trace stop rejects symlinked write path escape under trace dir", + async () => { + const base = await startServerAndBase(); + await withSymlinkPathEscape({ + rootDir: DEFAULT_TRACE_DIR, + run: async (pathEscape) => { + const res = await postJson<{ error?: string }>(`${base}/trace/stop`, { + path: pathEscape, + }); + expect(res.error).toContain("Invalid path"); + expect(pwMocks.traceStopViaPlaywright).not.toHaveBeenCalled(); + }, + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "wait/download rejects symlinked write path escape under downloads dir", + async () => { + const base = await startServerAndBase(); + await withSymlinkPathEscape({ + rootDir: DEFAULT_DOWNLOAD_DIR, + run: async (pathEscape) => { + const res = await postJson<{ error?: string }>(`${base}/wait/download`, { + path: pathEscape, + }); + expect(res.error).toContain("Invalid path"); + expect(pwMocks.waitForDownloadViaPlaywright).not.toHaveBeenCalled(); + }, + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "download rejects symlinked write path escape under downloads dir", + async () => { + const base = await startServerAndBase(); + await withSymlinkPathEscape({ + rootDir: DEFAULT_DOWNLOAD_DIR, + run: async (pathEscape) => { + const res = await postJson<{ error?: string }>(`${base}/download`, { + ref: "e12", + path: pathEscape, + }); + expect(res.error).toContain("Invalid path"); + expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled(); + }, + }); + }, + ); + it("wait/download accepts in-root relative output path", async () => { const base = await startServerAndBase(); const res = await postJson<{ ok?: boolean; download?: { path?: string } }>( diff --git a/src/infra/tmp-openclaw-dir.test.ts b/src/infra/tmp-openclaw-dir.test.ts index 0424e5e0223..f3e3fe36299 100644 --- a/src/infra/tmp-openclaw-dir.test.ts +++ b/src/infra/tmp-openclaw-dir.test.ts @@ -8,24 +8,54 @@ function fallbackTmp(uid = 501) { return path.join("/var/fallback", `openclaw-${uid}`); } +function nodeErrorWithCode(code: string) { + const err = new Error(code) as Error & { code?: string }; + err.code = code; + return err; +} + +function secureDirStat(uid = 501) { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + uid, + mode: 0o40700, + }; +} + function resolveWithMocks(params: { lstatSync: NonNullable; + fallbackLstatSync?: NonNullable; accessSync?: NonNullable; uid?: number; tmpdirPath?: string; }) { + const uid = params.uid ?? 501; + const fallbackPath = fallbackTmp(uid); const accessSync = params.accessSync ?? vi.fn(); + const wrappedLstatSync = vi.fn((target: string) => { + if (target === POSIX_OPENCLAW_TMP_DIR) { + return params.lstatSync(target); + } + if (target === fallbackPath) { + if (params.fallbackLstatSync) { + return params.fallbackLstatSync(target); + } + return secureDirStat(uid); + } + return secureDirStat(uid); + }) as NonNullable; const mkdirSync = vi.fn(); - const getuid = vi.fn(() => params.uid ?? 501); + const getuid = vi.fn(() => uid); const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback"); const resolved = resolvePreferredOpenClawTmpDir({ accessSync, - lstatSync: params.lstatSync, + lstatSync: wrappedLstatSync, mkdirSync, getuid, tmpdir, }); - return { resolved, accessSync, lstatSync: params.lstatSync, mkdirSync, tmpdir }; + return { resolved, accessSync, lstatSync: wrappedLstatSync, mkdirSync, tmpdir }; } describe("resolvePreferredOpenClawTmpDir", () => { @@ -45,24 +75,12 @@ describe("resolvePreferredOpenClawTmpDir", () => { }); it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => { - const lstatSyncMock = vi.fn>(() => { - const err = new Error("missing") as Error & { code?: string }; - err.code = "ENOENT"; - throw err; - }); - - // second lstat call (after mkdir) should succeed - lstatSyncMock.mockImplementationOnce(() => { - const err = new Error("missing") as Error & { code?: string }; - err.code = "ENOENT"; - throw err; - }); - lstatSyncMock.mockImplementationOnce(() => ({ - isDirectory: () => true, - isSymbolicLink: () => false, - uid: 501, - mode: 0o40700, - })); + const lstatSyncMock = vi + .fn>() + .mockImplementationOnce(() => { + throw nodeErrorWithCode("ENOENT"); + }) + .mockImplementationOnce(() => secureDirStat(501)); const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({ lstatSync: lstatSyncMock, @@ -84,7 +102,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { const { resolved, tmpdir } = resolveWithMocks({ lstatSync }); expect(resolved).toBe(fallbackTmp()); - expect(tmpdir).toHaveBeenCalledTimes(1); + expect(tmpdir).toHaveBeenCalled(); }); it("falls back to os.tmpdir()/openclaw when /tmp is not writable", () => { @@ -94,9 +112,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { } }); const lstatSync = vi.fn(() => { - const err = new Error("missing") as Error & { code?: string }; - err.code = "ENOENT"; - throw err; + throw nodeErrorWithCode("ENOENT"); }); const { resolved, tmpdir } = resolveWithMocks({ accessSync, @@ -104,7 +120,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { }); expect(resolved).toBe(fallbackTmp()); - expect(tmpdir).toHaveBeenCalledTimes(1); + expect(tmpdir).toHaveBeenCalled(); }); it("falls back when /tmp/openclaw is a symlink", () => { @@ -118,7 +134,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { const { resolved, tmpdir } = resolveWithMocks({ lstatSync }); expect(resolved).toBe(fallbackTmp()); - expect(tmpdir).toHaveBeenCalledTimes(1); + expect(tmpdir).toHaveBeenCalled(); }); it("falls back when /tmp/openclaw is not owned by the current user", () => { @@ -132,7 +148,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { const { resolved, tmpdir } = resolveWithMocks({ lstatSync }); expect(resolved).toBe(fallbackTmp()); - expect(tmpdir).toHaveBeenCalledTimes(1); + expect(tmpdir).toHaveBeenCalled(); }); it("falls back when /tmp/openclaw is group/other writable", () => { @@ -145,6 +161,51 @@ describe("resolvePreferredOpenClawTmpDir", () => { const { resolved, tmpdir } = resolveWithMocks({ lstatSync }); expect(resolved).toBe(fallbackTmp()); - expect(tmpdir).toHaveBeenCalledTimes(1); + expect(tmpdir).toHaveBeenCalled(); + }); + + it("throws when fallback path is a symlink", () => { + const lstatSync = vi.fn(() => ({ + isDirectory: () => true, + isSymbolicLink: () => true, + uid: 501, + mode: 0o120777, + })); + const fallbackLstatSync = vi.fn(() => ({ + isDirectory: () => true, + isSymbolicLink: () => true, + uid: 501, + mode: 0o120777, + })); + + expect(() => + resolveWithMocks({ + lstatSync, + fallbackLstatSync, + }), + ).toThrow(/Unsafe fallback OpenClaw temp dir/); + }); + + it("creates fallback directory when missing, then validates ownership and mode", () => { + const lstatSync = vi.fn(() => ({ + isDirectory: () => true, + isSymbolicLink: () => true, + uid: 501, + mode: 0o120777, + })); + const fallbackLstatSync = vi + .fn>() + .mockImplementationOnce(() => { + throw nodeErrorWithCode("ENOENT"); + }) + .mockImplementationOnce(() => secureDirStat(501)); + + const { resolved, mkdirSync } = resolveWithMocks({ + lstatSync, + fallbackLstatSync, + }); + + expect(resolved).toBe(fallbackTmp()); + expect(mkdirSync).toHaveBeenCalledWith(fallbackTmp(), { recursive: true, mode: 0o700 }); }); }); diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index 1e8250b3210..2897d69e48a 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -95,12 +95,53 @@ export function resolvePreferredOpenClawTmpDir( } }; + const resolveFallbackState = ( + fallbackPath: string, + requireWritableAccess: boolean, + ): "available" | "missing" | "invalid" => { + try { + const candidate = lstatSync(fallbackPath); + if (!isTrustedPreferredDir(candidate)) { + return "invalid"; + } + if (requireWritableAccess) { + accessSync(fallbackPath, fs.constants.W_OK | fs.constants.X_OK); + } + return "available"; + } catch (err) { + if (isNodeErrorWithCode(err, "ENOENT")) { + return "missing"; + } + return "invalid"; + } + }; + + const ensureTrustedFallbackDir = (): string => { + const fallbackPath = fallback(); + const state = resolveFallbackState(fallbackPath, true); + if (state === "available") { + return fallbackPath; + } + if (state === "invalid") { + throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`); + } + try { + mkdirSync(fallbackPath, { recursive: true, mode: 0o700 }); + } catch { + throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`); + } + if (resolveFallbackState(fallbackPath, true) !== "available") { + throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`); + } + return fallbackPath; + }; + const existingPreferredState = resolvePreferredState(true); if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; } if (existingPreferredState === "invalid") { - return fallback(); + return ensureTrustedFallbackDir(); } try { @@ -108,10 +149,10 @@ export function resolvePreferredOpenClawTmpDir( // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); if (resolvePreferredState(true) !== "available") { - return fallback(); + return ensureTrustedFallbackDir(); } return POSIX_OPENCLAW_TMP_DIR; } catch { - return fallback(); + return ensureTrustedFallbackDir(); } } From 046feb6b0eee7ec4984a9840ae3c7da982f4c081 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:13:57 +0100 Subject: [PATCH 25/95] refactor: simplify telegram event authorization flow --- src/telegram/bot-handlers.ts | 153 ++++++++++++++++++++--------------- src/telegram/bot.test.ts | 146 +++++++++++++-------------------- 2 files changed, 143 insertions(+), 156 deletions(-) diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index e7660717293..a3b4d46a677 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -17,6 +17,7 @@ import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js import { loadConfig } from "../config/config.js"; import { writeConfigFile } from "../config/io.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import type { DmPolicy } from "../config/types.base.js"; import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { danger, logVerbose, warn } from "../globals.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -507,54 +508,87 @@ export const registerTelegramHandlers = ({ return false; }; - const isTelegramEventSenderAuthorized = async (params: { + type TelegramGroupAllowContext = Awaited>; + type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist"; + type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string }; + type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy }; + + const TELEGRAM_EVENT_AUTH_RULES: Record< + TelegramEventAuthorizationMode, + { + enforceDirectAuthorization: boolean; + enforceGroupAllowlistAuthorization: boolean; + deniedDmReason: string; + deniedGroupReason: string; + } + > = { + reaction: { + enforceDirectAuthorization: true, + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "reaction unauthorized by dm policy/allowlist", + deniedGroupReason: "reaction unauthorized by group allowlist", + }, + "callback-scope": { + enforceDirectAuthorization: false, + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "callback unauthorized by inlineButtonsScope", + deniedGroupReason: "callback unauthorized by inlineButtonsScope", + }, + "callback-allowlist": { + enforceDirectAuthorization: true, + enforceGroupAllowlistAuthorization: true, + deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist", + deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist", + }, + }; + + const resolveTelegramEventAuthorizationContext = async (params: { chatId: number; - chatTitle?: string; - isGroup: boolean; isForum: boolean; messageThreadId?: number; - senderId: string; - senderUsername: string; - enforceDirectAuthorization: boolean; - enforceGroupAllowlistAuthorization: boolean; - deniedDmReason: string; - deniedGroupReason: string; - groupAllowContext?: Awaited>; - }) => { - const { - chatId, - chatTitle, - isGroup, - isForum, - messageThreadId, - senderId, - senderUsername, - enforceDirectAuthorization, - enforceGroupAllowlistAuthorization, - deniedDmReason, - deniedGroupReason, - groupAllowContext: preResolvedGroupAllowContext, - } = params; + groupAllowContext?: TelegramGroupAllowContext; + }): Promise => { const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; const groupAllowContext = - preResolvedGroupAllowContext ?? + params.groupAllowContext ?? (await resolveTelegramGroupAllowFromContext({ - chatId, + chatId: params.chatId, accountId, dmPolicy, - isForum, - messageThreadId, + isForum: params.isForum, + messageThreadId: params.messageThreadId, groupAllowFrom, resolveTelegramGroupConfig, })); + return { dmPolicy, ...groupAllowContext }; + }; + + const authorizeTelegramEventSender = (params: { + chatId: number; + chatTitle?: string; + isGroup: boolean; + senderId: string; + senderUsername: string; + mode: TelegramEventAuthorizationMode; + context: TelegramEventAuthorizationContext; + }): TelegramEventAuthorizationResult => { + const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params; const { + dmPolicy, resolvedThreadId, storeAllowFrom, groupConfig, topicConfig, effectiveGroupAllow, hasGroupAllowOverride, - } = groupAllowContext; + } = context; + const authRules = TELEGRAM_EVENT_AUTH_RULES[mode]; + const { + enforceDirectAuthorization, + enforceGroupAllowlistAuthorization, + deniedDmReason, + deniedGroupReason, + } = authRules; if ( shouldSkipGroupMessage({ isGroup, @@ -569,7 +603,7 @@ export const registerTelegramHandlers = ({ topicConfig, }) ) { - return false; + return { allowed: false, reason: "group-policy" }; } if (!isGroup && enforceDirectAuthorization) { @@ -577,7 +611,7 @@ export const registerTelegramHandlers = ({ logVerbose( `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`, ); - return false; + return { allowed: false, reason: "direct-disabled" }; } if (dmPolicy !== "open") { const effectiveDmAllow = normalizeAllowFromWithStore({ @@ -587,17 +621,17 @@ export const registerTelegramHandlers = ({ }); if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) { logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`); - return false; + return { allowed: false, reason: "direct-unauthorized" }; } } } if (isGroup && enforceGroupAllowlistAuthorization) { if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) { logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`); - return false; + return { allowed: false, reason: "group-unauthorized" }; } } - return true; + return { allowed: true }; }; // Handle emoji reactions to messages. @@ -630,19 +664,20 @@ export const registerTelegramHandlers = ({ if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) { return; } - const senderAuthorized = await isTelegramEventSenderAuthorized({ + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + chatId, + isForum, + }); + const senderAuthorization = authorizeTelegramEventSender({ chatId, chatTitle: reaction.chat.title, isGroup, - isForum, senderId, senderUsername, - enforceDirectAuthorization: true, - enforceGroupAllowlistAuthorization: false, - deniedDmReason: "reaction unauthorized by dm policy/allowlist", - deniedGroupReason: "reaction unauthorized by group allowlist", + mode: "reaction", + context: eventAuthContext, }); - if (!senderAuthorized) { + if (!senderAuthorization.allowed) { return; } @@ -965,33 +1000,26 @@ export const registerTelegramHandlers = ({ const messageThreadId = callbackMessage.message_thread_id; const isForum = callbackMessage.chat.is_forum === true; - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ chatId, - accountId, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", isForum, messageThreadId, - groupAllowFrom, - resolveTelegramGroupConfig, }); - const { resolvedThreadId, storeAllowFrom } = groupAllowContext; + const { resolvedThreadId, storeAllowFrom } = eventAuthContext; const senderId = callback.from?.id ? String(callback.from.id) : ""; const senderUsername = callback.from?.username ?? ""; - const senderAuthorized = await isTelegramEventSenderAuthorized({ + const authorizationMode: TelegramEventAuthorizationMode = + inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope"; + const senderAuthorization = authorizeTelegramEventSender({ chatId, chatTitle: callbackMessage.chat.title, isGroup, - isForum, - messageThreadId, senderId, senderUsername, - enforceDirectAuthorization: inlineButtonsScope === "allowlist", - enforceGroupAllowlistAuthorization: inlineButtonsScope === "allowlist", - deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist", - deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist", - groupAllowContext, + mode: authorizationMode, + context: eventAuthContext, }); - if (!senderAuthorized) { + if (!senderAuthorization.allowed) { return; } @@ -1230,25 +1258,20 @@ export const registerTelegramHandlers = ({ if (shouldSkipUpdate(event.ctxForDedupe)) { return; } - const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; - - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ chatId: event.chatId, - accountId, - dmPolicy, isForum: event.isForum, messageThreadId: event.messageThreadId, - groupAllowFrom, - resolveTelegramGroupConfig, }); const { + dmPolicy, resolvedThreadId, storeAllowFrom, groupConfig, topicConfig, effectiveGroupAllow, hasGroupAllowOverride, - } = groupAllowContext; + } = eventAuthContext; const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 4a605abb170..e7e326d0e36 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -832,24 +832,12 @@ describe("createTelegramBot", () => { ); }); - it("blocks reaction when dmPolicy is disabled", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "disabled", reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 510 }, - messageReaction: { + it.each([ + { + name: "blocks reaction when dmPolicy is disabled", + updateId: 510, + channelConfig: { dmPolicy: "disabled", reactionNotifications: "all" }, + reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, @@ -857,29 +845,17 @@ describe("createTelegramBot", () => { old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); - - it("blocks reaction in allowlist mode for unauthorized direct sender", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "allowlist", allowFrom: ["12345"], reactionNotifications: "all" }, + expectedEnqueueCalls: 0, + }, + { + name: "blocks reaction in allowlist mode for unauthorized direct sender", + updateId: 511, + channelConfig: { + dmPolicy: "allowlist", + allowFrom: ["12345"], + reactionNotifications: "all", }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 511 }, - messageReaction: { + reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, @@ -887,29 +863,13 @@ describe("createTelegramBot", () => { old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); - - it("allows reaction in allowlist mode for authorized direct sender", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 512 }, - messageReaction: { + expectedEnqueueCalls: 0, + }, + { + name: "allows reaction in allowlist mode for authorized direct sender", + updateId: 512, + channelConfig: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" }, + reaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, @@ -917,34 +877,18 @@ describe("createTelegramBot", () => { old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, - }); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); - }); - - it("blocks reaction in group allowlist mode for unauthorized sender", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - dmPolicy: "open", - groupPolicy: "allowlist", - groupAllowFrom: ["12345"], - reactionNotifications: "all", - }, + expectedEnqueueCalls: 1, + }, + { + name: "blocks reaction in group allowlist mode for unauthorized sender", + updateId: 513, + channelConfig: { + dmPolicy: "open", + groupPolicy: "allowlist", + groupAllowFrom: ["12345"], + reactionNotifications: "all", }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 513 }, - messageReaction: { + reaction: { chat: { id: 9999, type: "supergroup" }, message_id: 77, user: { id: 9, first_name: "Ada" }, @@ -952,9 +896,29 @@ describe("createTelegramBot", () => { old_reaction: [], new_reaction: [{ type: "emoji", emoji: "🔥" }], }, + expectedEnqueueCalls: 0, + }, + ])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: channelConfig, + }, }); - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler({ + update: { update_id: updateId }, + messageReaction: reaction, + }); + + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls); }); it("skips reaction when reactionNotifications is off", async () => { From f41715a18f74d44154c78df2447dc34a347eeee7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:21:20 +0100 Subject: [PATCH 26/95] refactor(browser): split act route modules and dedupe path guards --- src/browser/paths.ts | 116 +++++++------ src/browser/routes/agent.act.download.ts | 97 +++++++++++ src/browser/routes/agent.act.hooks.ts | 100 ++++++++++++ src/browser/routes/agent.act.ts | 198 +---------------------- src/browser/routes/agent.debug.ts | 16 +- src/browser/routes/output-paths.ts | 31 ++++ src/infra/tmp-openclaw-dir.ts | 36 +---- 7 files changed, 319 insertions(+), 275 deletions(-) create mode 100644 src/browser/routes/agent.act.download.ts create mode 100644 src/browser/routes/agent.act.hooks.ts create mode 100644 src/browser/routes/output-paths.ts diff --git a/src/browser/paths.ts b/src/browser/paths.ts index 34e927f8c5b..e171f40c732 100644 --- a/src/browser/paths.ts +++ b/src/browser/paths.ts @@ -9,6 +9,58 @@ export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR; export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads"); export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads"); +type InvalidPathResult = { ok: false; error: string }; + +function invalidPath(scopeLabel: string): InvalidPathResult { + return { + ok: false, + error: `Invalid path: must stay within ${scopeLabel}`, + }; +} + +async function resolveRealPathIfExists(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return undefined; + } +} + +async function resolveTrustedRootRealPath(rootDir: string): Promise { + try { + const rootLstat = await fs.lstat(rootDir); + if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) { + return undefined; + } + return await fs.realpath(rootDir); + } catch { + return undefined; + } +} + +async function validateCanonicalPathWithinRoot(params: { + rootRealPath: string; + candidatePath: string; + expect: "directory" | "file"; +}): Promise<"ok" | "not-found" | "invalid"> { + try { + const candidateLstat = await fs.lstat(params.candidatePath); + if (candidateLstat.isSymbolicLink()) { + return "invalid"; + } + if (params.expect === "directory" && !candidateLstat.isDirectory()) { + return "invalid"; + } + if (params.expect === "file" && !candidateLstat.isFile()) { + return "invalid"; + } + const candidateRealPath = await fs.realpath(params.candidatePath); + return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid"; + } catch (err) { + return isNotFoundPathError(err) ? "not-found" : "invalid"; + } +} + export function resolvePathWithinRoot(params: { rootDir: string; requestedPath: string; @@ -42,51 +94,30 @@ export async function resolveWritablePathWithinRoot(params: { return lexical; } - const invalid = (): { ok: false; error: string } => ({ - ok: false, - error: `Invalid path: must stay within ${params.scopeLabel}`, - }); - const rootDir = path.resolve(params.rootDir); - let rootRealPath: string; - try { - const rootLstat = await fs.lstat(rootDir); - if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) { - return invalid(); - } - rootRealPath = await fs.realpath(rootDir); - } catch { - return invalid(); + const rootRealPath = await resolveTrustedRootRealPath(rootDir); + if (!rootRealPath) { + return invalidPath(params.scopeLabel); } const requestedPath = lexical.path; const parentDir = path.dirname(requestedPath); - try { - const parentLstat = await fs.lstat(parentDir); - if (!parentLstat.isDirectory() || parentLstat.isSymbolicLink()) { - return invalid(); - } - const parentRealPath = await fs.realpath(parentDir); - if (!isPathInside(rootRealPath, parentRealPath)) { - return invalid(); - } - } catch { - return invalid(); + const parentStatus = await validateCanonicalPathWithinRoot({ + rootRealPath, + candidatePath: parentDir, + expect: "directory", + }); + if (parentStatus !== "ok") { + return invalidPath(params.scopeLabel); } - try { - const targetLstat = await fs.lstat(requestedPath); - if (targetLstat.isSymbolicLink() || !targetLstat.isFile()) { - return invalid(); - } - const targetRealPath = await fs.realpath(requestedPath); - if (!isPathInside(rootRealPath, targetRealPath)) { - return invalid(); - } - } catch (err) { - if (!isNotFoundPathError(err)) { - return invalid(); - } + const targetStatus = await validateCanonicalPathWithinRoot({ + rootRealPath, + candidatePath: requestedPath, + expect: "file", + }); + if (targetStatus === "invalid") { + return invalidPath(params.scopeLabel); } return lexical; @@ -141,13 +172,8 @@ async function resolveCheckedPathsWithinRoot(params: { allowMissingFallback: boolean; }): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { const rootDir = path.resolve(params.rootDir); - let rootRealPath: string | undefined; - try { - rootRealPath = await fs.realpath(rootDir); - } catch { - // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks. - rootRealPath = undefined; - } + // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks. + const rootRealPath = await resolveRealPathIfExists(rootDir); const isInRoot = (relativePath: string) => Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); diff --git a/src/browser/routes/agent.act.download.ts b/src/browser/routes/agent.act.download.ts new file mode 100644 index 00000000000..d08287fea59 --- /dev/null +++ b/src/browser/routes/agent.act.download.ts @@ -0,0 +1,97 @@ +import type { BrowserRouteContext } from "../server-context.js"; +import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } from "./agent.shared.js"; +import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js"; +import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toNumber, toStringOrEmpty } from "./utils.js"; + +function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) { + return { + cdpUrl, + targetId, + timeoutMs: timeoutMs ?? undefined, + }; +} + +export function registerBrowserAgentActDownloadRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post("/wait/download", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const out = toStringOrEmpty(body.path) || ""; + const timeoutMs = toNumber(body.timeoutMs); + + await withPlaywrightRouteContext({ + req, + res, + ctx, + targetId, + feature: "wait for download", + run: async ({ cdpUrl, tab, pw }) => { + await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); + let downloadPath: string | undefined; + if (out.trim()) { + const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({ + res, + rootDir: DEFAULT_DOWNLOAD_DIR, + requestedPath: out, + scopeLabel: "downloads directory", + }); + if (!resolvedDownloadPath) { + return; + } + downloadPath = resolvedDownloadPath; + } + const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); + const result = await pw.waitForDownloadViaPlaywright({ + ...requestBase, + path: downloadPath, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + }, + }); + }); + + app.post("/download", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const ref = toStringOrEmpty(body.ref); + const out = toStringOrEmpty(body.path); + const timeoutMs = toNumber(body.timeoutMs); + if (!ref) { + return jsonError(res, 400, "ref is required"); + } + if (!out) { + return jsonError(res, 400, "path is required"); + } + + await withPlaywrightRouteContext({ + req, + res, + ctx, + targetId, + feature: "download", + run: async ({ cdpUrl, tab, pw }) => { + await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR); + const downloadPath = await resolveWritableOutputPathOrRespond({ + res, + rootDir: DEFAULT_DOWNLOAD_DIR, + requestedPath: out, + scopeLabel: "downloads directory", + }); + if (!downloadPath) { + return; + } + const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); + const result = await pw.downloadViaPlaywright({ + ...requestBase, + ref, + path: downloadPath, + }); + res.json({ ok: true, targetId: tab.targetId, download: result }); + }, + }); + }); +} diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts new file mode 100644 index 00000000000..56d97bb03d3 --- /dev/null +++ b/src/browser/routes/agent.act.hooks.ts @@ -0,0 +1,100 @@ +import type { BrowserRouteContext } from "../server-context.js"; +import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } from "./agent.shared.js"; +import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js"; +import type { BrowserRouteRegistrar } from "./types.js"; +import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; + +export function registerBrowserAgentActHookRoutes( + app: BrowserRouteRegistrar, + ctx: BrowserRouteContext, +) { + app.post("/hooks/file-chooser", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const ref = toStringOrEmpty(body.ref) || undefined; + const inputRef = toStringOrEmpty(body.inputRef) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const paths = toStringArray(body.paths) ?? []; + const timeoutMs = toNumber(body.timeoutMs); + if (!paths.length) { + return jsonError(res, 400, "paths are required"); + } + + await withPlaywrightRouteContext({ + req, + res, + ctx, + targetId, + feature: "file chooser hook", + run: async ({ cdpUrl, tab, pw }) => { + const uploadPathsResult = await resolveExistingPathsWithinRoot({ + rootDir: DEFAULT_UPLOAD_DIR, + requestedPaths: paths, + scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, + }); + if (!uploadPathsResult.ok) { + res.status(400).json({ error: uploadPathsResult.error }); + return; + } + const resolvedPaths = uploadPathsResult.paths; + + if (inputRef || element) { + if (ref) { + return jsonError(res, 400, "ref cannot be combined with inputRef/element"); + } + await pw.setInputFilesViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + inputRef, + element, + paths: resolvedPaths, + }); + } else { + await pw.armFileUploadViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + paths: resolvedPaths, + timeoutMs: timeoutMs ?? undefined, + }); + if (ref) { + await pw.clickViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + ref, + }); + } + } + res.json({ ok: true }); + }, + }); + }); + + app.post("/hooks/dialog", async (req, res) => { + const body = readBody(req); + const targetId = resolveTargetIdFromBody(body); + const accept = toBoolean(body.accept); + const promptText = toStringOrEmpty(body.promptText) || undefined; + const timeoutMs = toNumber(body.timeoutMs); + if (accept === undefined) { + return jsonError(res, 400, "accept is required"); + } + + await withPlaywrightRouteContext({ + req, + res, + ctx, + targetId, + feature: "dialog hook", + run: async ({ cdpUrl, tab, pw }) => { + await pw.armDialogViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + accept, + promptText, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true }); + }, + }); + }); +} diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 42ea8444f53..7bbd29de42e 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -1,6 +1,7 @@ -import fs from "node:fs/promises"; import type { BrowserFormField } from "../client-actions-core.js"; import type { BrowserRouteContext } from "../server-context.js"; +import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js"; +import { registerBrowserAgentActHookRoutes } from "./agent.act.hooks.js"; import { type ActKind, isActKind, @@ -13,43 +14,9 @@ import { withPlaywrightRouteContext, SELECTOR_UNSUPPORTED_MESSAGE, } from "./agent.shared.js"; -import { - DEFAULT_DOWNLOAD_DIR, - DEFAULT_UPLOAD_DIR, - resolveWritablePathWithinRoot, - resolveExistingPathsWithinRoot, -} from "./path-output.js"; -import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js"; +import type { BrowserRouteRegistrar } from "./types.js"; import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js"; -async function resolveDownloadPathOrRespond( - res: BrowserResponse, - requestedPath: string, -): Promise { - const downloadPathResult = await resolveWritablePathWithinRoot({ - rootDir: DEFAULT_DOWNLOAD_DIR, - requestedPath, - scopeLabel: "downloads directory", - }); - if (!downloadPathResult.ok) { - res.status(400).json({ error: downloadPathResult.error }); - return null; - } - return downloadPathResult.path; -} - -function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) { - return { - cdpUrl, - targetId, - timeoutMs: timeoutMs ?? undefined, - }; -} - -function respondWithDownloadResult(res: BrowserResponse, targetId: string, result: unknown) { - res.json({ ok: true, targetId, download: result }); -} - export function registerBrowserAgentActRoutes( app: BrowserRouteRegistrar, ctx: BrowserRouteContext, @@ -367,163 +334,8 @@ export function registerBrowserAgentActRoutes( }); }); - app.post("/hooks/file-chooser", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const ref = toStringOrEmpty(body.ref) || undefined; - const inputRef = toStringOrEmpty(body.inputRef) || undefined; - const element = toStringOrEmpty(body.element) || undefined; - const paths = toStringArray(body.paths) ?? []; - const timeoutMs = toNumber(body.timeoutMs); - if (!paths.length) { - return jsonError(res, 400, "paths are required"); - } - - await withPlaywrightRouteContext({ - req, - res, - ctx, - targetId, - feature: "file chooser hook", - run: async ({ cdpUrl, tab, pw }) => { - const uploadPathsResult = await resolveExistingPathsWithinRoot({ - rootDir: DEFAULT_UPLOAD_DIR, - requestedPaths: paths, - scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`, - }); - if (!uploadPathsResult.ok) { - res.status(400).json({ error: uploadPathsResult.error }); - return; - } - const resolvedPaths = uploadPathsResult.paths; - - if (inputRef || element) { - if (ref) { - return jsonError(res, 400, "ref cannot be combined with inputRef/element"); - } - await pw.setInputFilesViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - inputRef, - element, - paths: resolvedPaths, - }); - } else { - await pw.armFileUploadViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - paths: resolvedPaths, - timeoutMs: timeoutMs ?? undefined, - }); - if (ref) { - await pw.clickViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - ref, - }); - } - } - res.json({ ok: true }); - }, - }); - }); - - app.post("/hooks/dialog", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const accept = toBoolean(body.accept); - const promptText = toStringOrEmpty(body.promptText) || undefined; - const timeoutMs = toNumber(body.timeoutMs); - if (accept === undefined) { - return jsonError(res, 400, "accept is required"); - } - - await withPlaywrightRouteContext({ - req, - res, - ctx, - targetId, - feature: "dialog hook", - run: async ({ cdpUrl, tab, pw }) => { - await pw.armDialogViaPlaywright({ - cdpUrl, - targetId: tab.targetId, - accept, - promptText, - timeoutMs: timeoutMs ?? undefined, - }); - res.json({ ok: true }); - }, - }); - }); - - app.post("/wait/download", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const out = toStringOrEmpty(body.path) || ""; - const timeoutMs = toNumber(body.timeoutMs); - - await withPlaywrightRouteContext({ - req, - res, - ctx, - targetId, - feature: "wait for download", - run: async ({ cdpUrl, tab, pw }) => { - await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); - let downloadPath: string | undefined; - if (out.trim()) { - const resolvedDownloadPath = await resolveDownloadPathOrRespond(res, out); - if (!resolvedDownloadPath) { - return; - } - downloadPath = resolvedDownloadPath; - } - const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); - const result = await pw.waitForDownloadViaPlaywright({ - ...requestBase, - path: downloadPath, - }); - respondWithDownloadResult(res, tab.targetId, result); - }, - }); - }); - - app.post("/download", async (req, res) => { - const body = readBody(req); - const targetId = resolveTargetIdFromBody(body); - const ref = toStringOrEmpty(body.ref); - const out = toStringOrEmpty(body.path); - const timeoutMs = toNumber(body.timeoutMs); - if (!ref) { - return jsonError(res, 400, "ref is required"); - } - if (!out) { - return jsonError(res, 400, "path is required"); - } - - await withPlaywrightRouteContext({ - req, - res, - ctx, - targetId, - feature: "download", - run: async ({ cdpUrl, tab, pw }) => { - await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); - const downloadPath = await resolveDownloadPathOrRespond(res, out); - if (!downloadPath) { - return; - } - const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs); - const result = await pw.downloadViaPlaywright({ - ...requestBase, - ref, - path: downloadPath, - }); - respondWithDownloadResult(res, tab.targetId, result); - }, - }); - }); + registerBrowserAgentActHookRoutes(app, ctx); + registerBrowserAgentActDownloadRoutes(app, ctx); app.post("/response/body", async (req, res) => { const body = readBody(req); diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts index b3b6ee0946c..f5c0d7b2030 100644 --- a/src/browser/routes/agent.debug.ts +++ b/src/browser/routes/agent.debug.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import type { BrowserRouteContext } from "../server-context.js"; import { @@ -8,7 +7,8 @@ import { resolveTargetIdFromQuery, withPlaywrightRouteContext, } from "./agent.shared.js"; -import { DEFAULT_TRACE_DIR, resolveWritablePathWithinRoot } from "./path-output.js"; +import { resolveWritableOutputPathOrRespond } from "./output-paths.js"; +import { DEFAULT_TRACE_DIR } from "./path-output.js"; import type { BrowserRouteRegistrar } from "./types.js"; import { toBoolean, toStringOrEmpty } from "./utils.js"; @@ -120,19 +120,17 @@ export function registerBrowserAgentDebugRoutes( feature: "trace stop", run: async ({ cdpUrl, tab, pw }) => { const id = crypto.randomUUID(); - const dir = DEFAULT_TRACE_DIR; - await fs.mkdir(dir, { recursive: true }); - const tracePathResult = await resolveWritablePathWithinRoot({ - rootDir: dir, + const tracePath = await resolveWritableOutputPathOrRespond({ + res, + rootDir: DEFAULT_TRACE_DIR, requestedPath: out, scopeLabel: "trace directory", defaultFileName: `browser-trace-${id}.zip`, + ensureRootDir: true, }); - if (!tracePathResult.ok) { - res.status(400).json({ error: tracePathResult.error }); + if (!tracePath) { return; } - const tracePath = tracePathResult.path; await pw.traceStopViaPlaywright({ cdpUrl, targetId: tab.targetId, diff --git a/src/browser/routes/output-paths.ts b/src/browser/routes/output-paths.ts new file mode 100644 index 00000000000..4a11d3dc816 --- /dev/null +++ b/src/browser/routes/output-paths.ts @@ -0,0 +1,31 @@ +import fs from "node:fs/promises"; +import { resolveWritablePathWithinRoot } from "./path-output.js"; +import type { BrowserResponse } from "./types.js"; + +export async function ensureOutputRootDir(rootDir: string): Promise { + await fs.mkdir(rootDir, { recursive: true }); +} + +export async function resolveWritableOutputPathOrRespond(params: { + res: BrowserResponse; + rootDir: string; + requestedPath: string; + scopeLabel: string; + defaultFileName?: string; + ensureRootDir?: boolean; +}): Promise { + if (params.ensureRootDir) { + await ensureOutputRootDir(params.rootDir); + } + const pathResult = await resolveWritablePathWithinRoot({ + rootDir: params.rootDir, + requestedPath: params.requestedPath, + scopeLabel: params.scopeLabel, + defaultFileName: params.defaultFileName, + }); + if (!pathResult.ok) { + params.res.status(400).json({ error: pathResult.error }); + return null; + } + return pathResult.path; +} diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index 2897d69e48a..975e25b8a1a 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -75,37 +75,17 @@ export function resolvePreferredOpenClawTmpDir( return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st); }; - const resolvePreferredState = ( + const resolveDirState = ( + candidatePath: string, requireWritableAccess: boolean, ): "available" | "missing" | "invalid" => { try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!isTrustedPreferredDir(preferred)) { - return "invalid"; - } - if (requireWritableAccess) { - accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); - } - return "available"; - } catch (err) { - if (isNodeErrorWithCode(err, "ENOENT")) { - return "missing"; - } - return "invalid"; - } - }; - - const resolveFallbackState = ( - fallbackPath: string, - requireWritableAccess: boolean, - ): "available" | "missing" | "invalid" => { - try { - const candidate = lstatSync(fallbackPath); + const candidate = lstatSync(candidatePath); if (!isTrustedPreferredDir(candidate)) { return "invalid"; } if (requireWritableAccess) { - accessSync(fallbackPath, fs.constants.W_OK | fs.constants.X_OK); + accessSync(candidatePath, fs.constants.W_OK | fs.constants.X_OK); } return "available"; } catch (err) { @@ -118,7 +98,7 @@ export function resolvePreferredOpenClawTmpDir( const ensureTrustedFallbackDir = (): string => { const fallbackPath = fallback(); - const state = resolveFallbackState(fallbackPath, true); + const state = resolveDirState(fallbackPath, true); if (state === "available") { return fallbackPath; } @@ -130,13 +110,13 @@ export function resolvePreferredOpenClawTmpDir( } catch { throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`); } - if (resolveFallbackState(fallbackPath, true) !== "available") { + if (resolveDirState(fallbackPath, true) !== "available") { throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`); } return fallbackPath; }; - const existingPreferredState = resolvePreferredState(true); + const existingPreferredState = resolveDirState(POSIX_OPENCLAW_TMP_DIR, true); if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; } @@ -148,7 +128,7 @@ export function resolvePreferredOpenClawTmpDir( accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); - if (resolvePreferredState(true) !== "available") { + if (resolveDirState(POSIX_OPENCLAW_TMP_DIR, true) !== "available") { return ensureTrustedFallbackDir(); } return POSIX_OPENCLAW_TMP_DIR; From c736f11a16d6bc27ea62a0fe40fffae4cb071fdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:22:28 +0100 Subject: [PATCH 27/95] fix(gateway): harden browser websocket auth chain --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 3 +- src/gateway/server-ws-runtime.ts | 3 + src/gateway/server.auth.test.ts | 69 +++++++++++++++++++ src/gateway/server.impl.ts | 7 ++ src/gateway/server/ws-connection.ts | 4 ++ .../server/ws-connection/message-handler.ts | 25 +++++-- 7 files changed, 105 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 521368ffd16..0e7532dd75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. +- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. - Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9d164fc4ea0..b03a0daa4fc 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2145,8 +2145,9 @@ See [Plugins](/tools/plugin). - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). +- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). -- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds. +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 9c14794a58e..f03235daddf 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: ReturnType; @@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: { canvasHostServerPort: params.canvasHostServerPort, resolvedAuth: params.resolvedAuth, rateLimiter: params.rateLimiter, + browserRateLimiter: params.browserRateLimiter, gatewayMethods: params.gatewayMethods, events: params.events, logGateway: params.logGateway, diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 83a97644d19..38668de7f40 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -672,6 +672,17 @@ describe("gateway server auth/connect", () => { ws.close(); }); + test("rejects non-local browser origins for non-control-ui clients", async () => { + const ws = await openWs(port, { origin: "https://attacker.example" }); + const res = await connectReq(ws, { + token: "secret", + client: TEST_OPERATOR_CLIENT, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("origin not allowed"); + ws.close(); + }); + test("returns control ui hint when token is missing", async () => { const ws = await openWs(port, { origin: originForPort(port) }); const res = await connectReq(ws, { @@ -701,6 +712,27 @@ describe("gateway server auth/connect", () => { ); ws.close(); }); + + test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => { + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, + }; + await withGatewayServer(async ({ port }) => { + const firstWs = await openWs(port, { origin: originForPort(port) }); + const first = await connectReq(firstWs, { token: "wrong" }); + expect(first.ok).toBe(false); + expect(first.error?.message ?? "").not.toContain("retry later"); + firstWs.close(); + + const secondWs = await openWs(port, { origin: originForPort(port) }); + const second = await connectReq(secondWs, { token: "wrong" }); + expect(second.ok).toBe(false); + expect(second.error?.message ?? "").toContain("retry later"); + secondWs.close(); + }); + }); }); describe("explicit none auth", () => { @@ -1214,6 +1246,43 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("does not silently auto-pair non-control-ui browser clients on loopback", async () => { + const { listDevicePairing } = await import("../infra/device-pairing.js"); + const { randomUUID } = await import("node:crypto"); + const os = await import("node:os"); + const path = await import("node:path"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + + const browserWs = await openWs(port, { origin: originForPort(port) }); + const nonce = await readConnectChallengeNonce(browserWs); + const { identity, device } = await createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`), + nonce, + }); + const res = await connectReq(browserWs, { + token: "secret", + scopes: ["operator.admin"], + client: TEST_OPERATOR_CLIENT, + device, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + + const pairing = await listDevicePairing(); + const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); + expect(pending).toBeTruthy(); + expect(pending?.silent).toBe(false); + + browserWs.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + test("merges remote node/operator pairing requests for the same unpaired device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index fdca08c2677..8b368539469 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -316,6 +316,11 @@ export async function startGatewayServer( const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined; + // Always keep a browser-origin fallback limiter for WS auth attempts. + const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({ + ...rateLimitConfig, + exemptLoopback: false, + }); let controlUiRootState: ControlUiRootState | undefined; if (controlUiRootOverride) { @@ -574,6 +579,7 @@ export async function startGatewayServer( canvasHostServerPort, resolvedAuth, rateLimiter: authRateLimiter, + browserRateLimiter: browserAuthRateLimiter, gatewayMethods, events: GATEWAY_EVENTS, logGateway: log, @@ -777,6 +783,7 @@ export async function startGatewayServer( } skillsChangeUnsub(); authRateLimiter?.dispose(); + browserAuthRateLimiter.dispose(); channelHealthMonitor?.stop(); await close(opts); }, diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index e7c9d458f8f..3abc8d6e1b9 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: SubsystemLogger; @@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: { canvasHostServerPort, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, logGateway, @@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: { connectNonce, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, extraHandlers, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9708325009f..0d694d12529 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -99,6 +99,8 @@ export function attachGatewayWsMessageHandler(params: { resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; + /** Browser-origin fallback limiter (loopback is never exempt). */ + browserRateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; extraHandlers: GatewayRequestHandlers; @@ -130,6 +132,7 @@ export function attachGatewayWsMessageHandler(params: { connectNonce, resolvedAuth, rateLimiter, + browserRateLimiter, gatewayMethods, events, extraHandlers, @@ -192,6 +195,12 @@ export function attachGatewayWsMessageHandler(params: { const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); const unauthorizedFloodGuard = new UnauthorizedFloodGuard(); + const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== ""); + const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders; + const browserRateLimitClientIp = + hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp; + const authRateLimiter = + hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter; socket.on("message", async (data) => { if (isClosed()) { @@ -329,7 +338,7 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const isWebchat = isWebchatConnect(connectParams); - if (isControlUi || isWebchat) { + if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) { const originCheck = checkBrowserOrigin({ requestHost, origin: requestOrigin, @@ -377,8 +386,8 @@ export function attachGatewayWsMessageHandler(params: { req: upgradeReq, trustedProxies, allowRealIpFallback, - rateLimiter, - clientIp, + rateLimiter: authRateLimiter, + clientIp: browserRateLimitClientIp, }); const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { markHandshakeFailure("unauthorized", { @@ -556,8 +565,8 @@ export function attachGatewayWsMessageHandler(params: { deviceId: device?.id, role, scopes, - rateLimiter, - clientIp, + rateLimiter: authRateLimiter, + clientIp: browserRateLimitClientIp, verifyDeviceToken, })); if (!authOk) { @@ -613,11 +622,15 @@ export function attachGatewayWsMessageHandler(params: { const requirePairing = async ( reason: "not-paired" | "role-upgrade" | "scope-upgrade", ) => { + const allowSilentLocalPairing = + isLocalClient && + (!hasBrowserOriginHeader || isControlUi || isWebchat) && + (reason === "not-paired" || reason === "scope-upgrade"); const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientAccessMetadata, - silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"), + silent: allowSilentLocalPairing, }); const context = buildRequestContext(); if (pairing.request.silent === true) { From aedf62ac7e669a89c7b299201bf6537dc6b12e0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:26:37 +0100 Subject: [PATCH 28/95] fix: harden discord and slack reaction ingress authorization --- CHANGELOG.md | 1 + src/discord/monitor.test.ts | 98 ++++++++++++- src/discord/monitor/listeners.ts | 115 ++++++++++++++- src/discord/monitor/provider.ts | 12 ++ src/security/dm-policy-shared.test.ts | 32 ++++ src/security/dm-policy-shared.ts | 35 +++++ src/slack/monitor/events/reactions.test.ts | 163 +++++++++++++++++++++ src/slack/monitor/events/reactions.ts | 32 +++- 8 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 src/slack/monitor/events/reactions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e7532dd75b..6db4c8796de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 222911894a9..4e185d96574 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -1,5 +1,5 @@ import { ChannelType, type Guild } from "@buape/carbon"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { typedCases } from "../test-utils/typed-cases.js"; import { allowListMatches, @@ -20,6 +20,12 @@ import { } from "./monitor.js"; import { DiscordMessageListener, DiscordReactionListener } from "./monitor/listeners.js"; +const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; const makeEntries = ( @@ -899,6 +905,12 @@ function makeReactionClient(options?: { function makeReactionListenerParams(overrides?: { botUserId?: string; + dmEnabled?: boolean; + groupDmEnabled?: boolean; + groupDmChannels?: string[]; + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + groupPolicy?: "open" | "allowlist" | "disabled"; allowNameMatching?: boolean; guildEntries?: Record; }) { @@ -907,6 +919,12 @@ function makeReactionListenerParams(overrides?: { accountId: "acc-1", runtime: {} as import("../runtime.js").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", + dmEnabled: overrides?.dmEnabled ?? true, + groupDmEnabled: overrides?.groupDmEnabled ?? true, + groupDmChannels: overrides?.groupDmChannels ?? [], + dmPolicy: overrides?.dmPolicy ?? "open", + allowFrom: overrides?.allowFrom ?? [], + groupPolicy: overrides?.groupPolicy ?? "open", allowNameMatching: overrides?.allowNameMatching ?? false, guildEntries: overrides?.guildEntries, logger: { @@ -919,6 +937,12 @@ function makeReactionListenerParams(overrides?: { } describe("discord DM reaction handling", () => { + beforeEach(() => { + enqueueSystemEventSpy.mockClear(); + resolveAgentRouteMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + }); + it("processes DM reactions with or without guild allowlists", async () => { const cases = [ { name: "no guild allowlist", guildEntries: undefined }, @@ -952,9 +976,77 @@ describe("discord DM reaction handling", () => { } }); + it("blocks DM reactions when dmPolicy is disabled", async () => { + const data = makeReactionEvent({ botAsAuthor: true }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ dmPolicy: "disabled" }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("blocks DM reactions for unauthorized sender in allowlist mode", async () => { + const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ + dmPolicy: "allowlist", + allowFrom: ["user:user-2"], + }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("allows DM reactions for authorized sender in allowlist mode", async () => { + const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" }); + const client = makeReactionClient({ channelType: ChannelType.DM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ + dmPolicy: "allowlist", + allowFrom: ["user:user-1"], + }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).toHaveBeenCalledOnce(); + }); + + it("blocks group DM reactions when group DMs are disabled", async () => { + const data = makeReactionEvent({ botAsAuthor: true }); + const client = makeReactionClient({ channelType: ChannelType.GroupDM }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ groupDmEnabled: false }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + + it("blocks guild reactions when groupPolicy is disabled", async () => { + const data = makeReactionEvent({ + guildId: "guild-123", + botAsAuthor: true, + guild: { id: "guild-123", name: "Guild" }, + }); + const client = makeReactionClient({ channelType: ChannelType.GuildText }); + const listener = new DiscordReactionListener( + makeReactionListenerParams({ groupPolicy: "disabled" }), + ); + + await listener.handle(data, client); + + expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); + }); + it("still processes guild reactions (no regression)", async () => { - enqueueSystemEventSpy.mockClear(); - resolveAgentRouteMock.mockClear(); resolveAgentRouteMock.mockReturnValueOnce({ agentId: "default", channel: "discord", diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 9bdc7331224..002bf62816d 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -7,14 +7,20 @@ import { PresenceUpdateListener, type User, } from "@buape/carbon"; -import { danger } from "../../globals.js"; +import { danger, logVerbose } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js"; import { + isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, normalizeDiscordSlug, + resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, + resolveGroupDmAllow, resolveDiscordGuildEntry, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; @@ -37,6 +43,12 @@ type DiscordReactionListenerParams = { accountId: string; runtime: RuntimeEnv; botUserId?: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; allowNameMatching: boolean; guildEntries?: Record; logger: Logger; @@ -179,6 +191,12 @@ async function runDiscordReactionHandler(params: { cfg: params.handlerParams.cfg, accountId: params.handlerParams.accountId, botUserId: params.handlerParams.botUserId, + dmEnabled: params.handlerParams.dmEnabled, + groupDmEnabled: params.handlerParams.groupDmEnabled, + groupDmChannels: params.handlerParams.groupDmChannels, + dmPolicy: params.handlerParams.dmPolicy, + allowFrom: params.handlerParams.allowFrom, + groupPolicy: params.handlerParams.groupPolicy, allowNameMatching: params.handlerParams.allowNameMatching, guildEntries: params.handlerParams.guildEntries, logger: params.handlerParams.logger, @@ -193,6 +211,12 @@ async function handleDiscordReactionEvent(params: { cfg: LoadedConfig; accountId: string; botUserId?: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; allowNameMatching: boolean; guildEntries?: Record; logger: Logger; @@ -236,6 +260,12 @@ async function handleDiscordReactionEvent(params: { channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread; + if (isDirectMessage && !params.dmEnabled) { + return; + } + if (isGroupDm && !params.groupDmEnabled) { + return; + } let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; let parentName: string | undefined; let parentSlug = ""; @@ -264,6 +294,45 @@ async function handleDiscordReactionEvent(params: { reactionBase = { baseText, contextKey }; return reactionBase; }; + const isDirectReactionAuthorized = async () => { + if (!isDirectMessage) { + return true; + } + const storeAllowFrom = + params.dmPolicy === "allowlist" + ? [] + : await readChannelAllowFromStore("discord").catch(() => []); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: [], + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); + const allowMatch = allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }, + allowNameMatching: params.allowNameMatching, + }) + : { allowed: false }; + return allowMatch.allowed; + }, + }); + if (access.decision !== "allow") { + logVerbose( + `discord reaction blocked sender=${user.id} (dmPolicy=${params.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, + ); + return false; + } + return true; + }; const emitReaction = (text: string, parentPeerId?: string) => { const { contextKey } = resolveReactionBase(); const route = resolveAgentRoute({ @@ -322,6 +391,44 @@ async function handleDiscordReactionEvent(params: { parentSlug, scope: "thread", }); + const isGuildReactionAllowed = (channelConfig: { allowed?: boolean } | null) => { + if (!isGuildMessage) { + return true; + } + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return false; + } + if (channelConfig?.allowed === false) { + return false; + } + return true; + }; + + if (!(await isDirectReactionAuthorized())) { + return; + } + + if ( + isGroupDm && + !resolveGroupDmAllow({ + channels: params.groupDmChannels, + channelId: data.channel_id, + channelName, + channelSlug, + }) + ) { + return; + } // Parallelize async operations for thread channels if (isThreadChannel) { @@ -370,6 +477,9 @@ async function handleDiscordReactionEvent(params: { if (channelConfig?.allowed === false) { return; } + if (!isGuildReactionAllowed(channelConfig)) { + return; + } const messageAuthorId = message?.author?.id ?? undefined; if (!shouldNotifyReaction({ mode: reactionMode, messageAuthorId })) { @@ -394,6 +504,9 @@ async function handleDiscordReactionEvent(params: { if (channelConfig?.allowed === false) { return; } + if (!isGuildReactionAllowed(channelConfig)) { + return; + } const reactionMode = guildInfo?.reactionNotifications ?? "own"; diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 15c8e2aa7b4..629f8a3e7aa 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -561,6 +561,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, runtime, botUserId, + dmEnabled, + groupDmEnabled, + groupDmChannels: groupDmChannels ?? [], + dmPolicy, + allowFrom: allowFrom ?? [], + groupPolicy, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg), guildEntries, logger, @@ -573,6 +579,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, runtime, botUserId, + dmEnabled, + groupDmEnabled, + groupDmChannels: groupDmChannels ?? [], + dmPolicy, + allowFrom: allowFrom ?? [], + groupPolicy, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg), guildEntries, logger, diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index d65d6a79188..1fe36976a55 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveDmAllowState, resolveDmGroupAccessDecision, + resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "./dm-policy-shared.js"; @@ -75,6 +76,37 @@ describe("security/dm-policy-shared", () => { expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]); }); + it("resolves access + effective allowlists in one shared call", () => { + const resolved = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group:room"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + }); + expect(resolved.decision).toBe("allow"); + expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)"); + expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); + expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]); + }); + + it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { + const resolved = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: "allowlist", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: [], + storeAllowFrom: ["paired-user"], + isSenderAllowed: () => false, + }); + expect(resolved.decision).toBe("block"); + expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)"); + expect(resolved.effectiveAllowFrom).toEqual(["owner"]); + }); + const channels = [ "bluebubbles", "imessage", diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index ee07dfff3c7..a1084ace9ff 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -77,6 +77,41 @@ export function resolveDmGroupAccessDecision(params: { return { decision: "block", reason: `dmPolicy=${dmPolicy} (not allowlisted)` }; } +export function resolveDmGroupAccessWithLists(params: { + isGroup: boolean; + dmPolicy?: string | null; + groupPolicy?: string | null; + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + isSenderAllowed: (allowFrom: string[]) => boolean; +}): { + decision: DmGroupAccessDecision; + reason: string; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + dmPolicy: params.dmPolicy, + }); + const access = resolveDmGroupAccessDecision({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + effectiveAllowFrom, + effectiveGroupAllowFrom, + isSenderAllowed: params.isSenderAllowed, + }); + return { + ...access, + effectiveAllowFrom, + effectiveGroupAllowFrom, + }; +} + export async function resolveDmAllowState(params: { provider: ChannelId; allowFrom?: Array | null; diff --git a/src/slack/monitor/events/reactions.test.ts b/src/slack/monitor/events/reactions.test.ts new file mode 100644 index 00000000000..815ca1c65b2 --- /dev/null +++ b/src/slack/monitor/events/reactions.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SlackMonitorContext } from "../context.js"; +import { registerSlackReactionEvents } from "./reactions.js"; + +const enqueueSystemEventMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); + +vi.mock("../../../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + +type SlackReactionHandler = (args: { + event: Record; + body: unknown; +}) => Promise; + +function createReactionContext(overrides?: { + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + channelType?: "im" | "channel"; +}) { + let addedHandler: SlackReactionHandler | null = null; + let removedHandler: SlackReactionHandler | null = null; + const channelType = overrides?.channelType ?? "im"; + const app = { + event: vi.fn((name: string, handler: SlackReactionHandler) => { + if (name === "reaction_added") { + addedHandler = handler; + } else if (name === "reaction_removed") { + removedHandler = handler; + } + }), + }; + const ctx = { + app, + runtime: { error: vi.fn() }, + dmPolicy: overrides?.dmPolicy ?? "open", + groupPolicy: "open", + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: false, + shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), + isChannelAllowed: vi.fn().mockReturnValue(true), + resolveChannelName: vi.fn().mockResolvedValue({ + name: channelType === "im" ? "direct" : "general", + type: channelType, + }), + resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), + resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), + } as unknown as SlackMonitorContext; + registerSlackReactionEvents({ ctx }); + return { + ctx, + getAddedHandler: () => addedHandler, + getRemovedHandler: () => removedHandler, + }; +} + +function makeReactionEvent(overrides?: { user?: string; channel?: string }) { + return { + type: "reaction_added", + user: overrides?.user ?? "U1", + reaction: "thumbsup", + item: { + type: "message", + channel: overrides?.channel ?? "D1", + ts: "123.456", + }, + item_user: "UBOT", + }; +} + +describe("registerSlackReactionEvents", () => { + it("enqueues DM reaction system events when dmPolicy is open", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ dmPolicy: "open" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks DM reaction system events when dmPolicy is disabled", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ dmPolicy: "disabled" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks DM reaction system events for unauthorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "allowlist", + allowFrom: ["U2"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM reaction system events for authorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "allowlist", + allowFrom: ["U1"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("enqueues channel reaction events regardless of dmPolicy", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getRemovedHandler } = createReactionContext({ + dmPolicy: "disabled", + channelType: "channel", + }); + const removedHandler = getRemovedHandler(); + expect(removedHandler).toBeTruthy(); + + await removedHandler!({ + event: { + ...makeReactionEvent({ channel: "C1" }), + type: "reaction_removed", + }, + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/slack/monitor/events/reactions.ts b/src/slack/monitor/events/reactions.ts index b437352d6ca..5007c6aad93 100644 --- a/src/slack/monitor/events/reactions.ts +++ b/src/slack/monitor/events/reactions.ts @@ -1,6 +1,9 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; +import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { resolveDmGroupAccessWithLists } from "../../../security/dm-policy-shared.js"; +import { resolveSlackAllowListMatch } from "../allow-list.js"; +import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; @@ -32,6 +35,33 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } channelName: channelInfo?.name, }); const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined; + if (channelType === "im") { + if (!event.user) { + return; + } + const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: ctx.dmPolicy, + groupPolicy: ctx.groupPolicy, + allowFrom: allowFromLower, + groupAllowFrom: [], + storeAllowFrom: [], + isSenderAllowed: (allowList) => + resolveSlackAllowListMatch({ + allowList, + id: event.user, + name: actorInfo?.name, + allowNameMatching: ctx.allowNameMatching, + }).allowed, + }); + if (access.decision !== "allow") { + logVerbose( + `slack: drop reaction sender ${event.user} (dmPolicy=${ctx.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, + ); + return; + } + } const actorLabel = actorInfo?.name ?? event.user; const emojiLabel = event.reaction ?? "emoji"; const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined; From 4258a3307f5a616c26c5bd63e108d918b71a9217 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:30:19 +0000 Subject: [PATCH 29/95] refactor(agents): unify subagent announce delivery pipeline Co-authored-by: Smith Labs Co-authored-by: Do Cao Hieu --- CHANGELOG.md | 1 + src/agents/subagent-announce-dispatch.test.ts | 156 ++++++++++++++++++ src/agents/subagent-announce-dispatch.ts | 104 ++++++++++++ src/agents/subagent-announce.ts | 113 ++++--------- ...agent-registry.announce-loop-guard.test.ts | 39 +++++ src/agents/subagent-registry.ts | 13 +- src/gateway/server-methods/send.ts | 7 +- src/infra/outbound/channel-resolution.ts | 73 ++++++++ src/infra/outbound/message.test.ts | 51 +++++- src/infra/outbound/message.ts | 26 +-- .../targets.channel-resolution.test.ts | 61 +++++++ src/infra/outbound/targets.ts | 27 ++- src/telegram/send.test.ts | 38 +++++ src/telegram/send.ts | 46 +++--- 14 files changed, 623 insertions(+), 132 deletions(-) create mode 100644 src/agents/subagent-announce-dispatch.test.ts create mode 100644 src/agents/subagent-announce-dispatch.ts create mode 100644 src/infra/outbound/channel-resolution.ts create mode 100644 src/infra/outbound/targets.channel-resolution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db4c8796de..6ee1e3d0375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. diff --git a/src/agents/subagent-announce-dispatch.test.ts b/src/agents/subagent-announce-dispatch.test.ts new file mode 100644 index 00000000000..fcc2f992e2b --- /dev/null +++ b/src/agents/subagent-announce-dispatch.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { + mapQueueOutcomeToDeliveryResult, + runSubagentAnnounceDispatch, +} from "./subagent-announce-dispatch.js"; + +describe("mapQueueOutcomeToDeliveryResult", () => { + it("maps steered to delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("steered")).toEqual({ + delivered: true, + path: "steered", + }); + }); + + it("maps queued to delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("queued")).toEqual({ + delivered: true, + path: "queued", + }); + }); + + it("maps none to not-delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("none")).toEqual({ + delivered: false, + path: "none", + }); + }); +}); + +describe("runSubagentAnnounceDispatch", () => { + it("uses queue-first ordering for non-completion mode", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: false, + queue, + direct, + }); + + expect(queue).toHaveBeenCalledTimes(1); + expect(direct).toHaveBeenCalledTimes(1); + expect(result.delivered).toBe(true); + expect(result.path).toBe("direct"); + expect(result.phases).toEqual([ + { phase: "queue-primary", delivered: false, path: "none", error: undefined }, + { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, + ]); + }); + + it("short-circuits direct send when non-completion queue delivers", async () => { + const queue = vi.fn(async () => "queued" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: false, + queue, + direct, + }); + + expect(queue).toHaveBeenCalledTimes(1); + expect(direct).not.toHaveBeenCalled(); + expect(result.path).toBe("queued"); + expect(result.phases).toEqual([ + { phase: "queue-primary", delivered: true, path: "queued", error: undefined }, + ]); + }); + + it("uses direct-first ordering for completion mode", async () => { + const queue = vi.fn(async () => "queued" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(direct).toHaveBeenCalledTimes(1); + expect(queue).not.toHaveBeenCalled(); + expect(result.path).toBe("direct"); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, + ]); + }); + + it("falls back to queue when completion direct send fails", async () => { + const queue = vi.fn(async () => "steered" as const); + const direct = vi.fn(async () => ({ + delivered: false, + path: "direct" as const, + error: "network", + })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(direct).toHaveBeenCalledTimes(1); + expect(queue).toHaveBeenCalledTimes(1); + expect(result.path).toBe("steered"); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: false, path: "direct", error: "network" }, + { phase: "queue-fallback", delivered: true, path: "steered", error: undefined }, + ]); + }); + + it("returns direct failure when completion fallback queue cannot deliver", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ + delivered: false, + path: "direct" as const, + error: "failed", + })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(result).toMatchObject({ + delivered: false, + path: "direct", + error: "failed", + }); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: false, path: "direct", error: "failed" }, + { phase: "queue-fallback", delivered: false, path: "none", error: undefined }, + ]); + }); + + it("returns none immediately when signal is already aborted", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + const controller = new AbortController(); + controller.abort(); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + signal: controller.signal, + queue, + direct, + }); + + expect(queue).not.toHaveBeenCalled(); + expect(direct).not.toHaveBeenCalled(); + expect(result).toEqual({ + delivered: false, + path: "none", + phases: [], + }); + }); +}); diff --git a/src/agents/subagent-announce-dispatch.ts b/src/agents/subagent-announce-dispatch.ts new file mode 100644 index 00000000000..93aa0dd9092 --- /dev/null +++ b/src/agents/subagent-announce-dispatch.ts @@ -0,0 +1,104 @@ +export type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; + +export type SubagentAnnounceQueueOutcome = "steered" | "queued" | "none"; + +export type SubagentAnnounceDeliveryResult = { + delivered: boolean; + path: SubagentDeliveryPath; + error?: string; + phases?: SubagentAnnounceDispatchPhaseResult[]; +}; + +export type SubagentAnnounceDispatchPhase = "queue-primary" | "direct-primary" | "queue-fallback"; + +export type SubagentAnnounceDispatchPhaseResult = { + phase: SubagentAnnounceDispatchPhase; + delivered: boolean; + path: SubagentDeliveryPath; + error?: string; +}; + +export function mapQueueOutcomeToDeliveryResult( + outcome: SubagentAnnounceQueueOutcome, +): SubagentAnnounceDeliveryResult { + if (outcome === "steered") { + return { + delivered: true, + path: "steered", + }; + } + if (outcome === "queued") { + return { + delivered: true, + path: "queued", + }; + } + return { + delivered: false, + path: "none", + }; +} + +export async function runSubagentAnnounceDispatch(params: { + expectsCompletionMessage: boolean; + signal?: AbortSignal; + queue: () => Promise; + direct: () => Promise; +}): Promise { + const phases: SubagentAnnounceDispatchPhaseResult[] = []; + const appendPhase = ( + phase: SubagentAnnounceDispatchPhase, + result: SubagentAnnounceDeliveryResult, + ) => { + phases.push({ + phase, + delivered: result.delivered, + path: result.path, + error: result.error, + }); + }; + const withPhases = (result: SubagentAnnounceDeliveryResult): SubagentAnnounceDeliveryResult => ({ + ...result, + phases, + }); + + if (params.signal?.aborted) { + return withPhases({ + delivered: false, + path: "none", + }); + } + + if (!params.expectsCompletionMessage) { + const primaryQueue = mapQueueOutcomeToDeliveryResult(await params.queue()); + appendPhase("queue-primary", primaryQueue); + if (primaryQueue.delivered) { + return withPhases(primaryQueue); + } + + const primaryDirect = await params.direct(); + appendPhase("direct-primary", primaryDirect); + return withPhases(primaryDirect); + } + + const primaryDirect = await params.direct(); + appendPhase("direct-primary", primaryDirect); + if (primaryDirect.delivered) { + return withPhases(primaryDirect); + } + + if (params.signal?.aborted) { + return withPhases({ + delivered: false, + path: "none", + }); + } + + const fallbackQueue = mapQueueOutcomeToDeliveryResult(await params.queue()); + appendPhase("queue-fallback", fallbackQueue); + if (fallbackQueue.delivered) { + return withPhases(fallbackQueue); + } + + return withPhases(primaryDirect); +} diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 7d7fd7ceb48..c99a6cb6593 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -32,6 +32,10 @@ import { queueEmbeddedPiMessage, waitForEmbeddedPiRunEnd, } from "./pi-embedded.js"; +import { + runSubagentAnnounceDispatch, + type SubagentAnnounceDeliveryResult, +} from "./subagent-announce-dispatch.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; @@ -53,14 +57,6 @@ type ToolResultMessage = { content?: unknown; }; -type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; - -type SubagentAnnounceDeliveryResult = { - delivered: boolean; - path: SubagentDeliveryPath; - error?: string; -}; - function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): number { const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs; if (typeof configured !== "number" || !Number.isFinite(configured)) { @@ -705,27 +701,6 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } -function queueOutcomeToDeliveryResult( - outcome: "steered" | "queued" | "none", -): SubagentAnnounceDeliveryResult { - if (outcome === "steered") { - return { - delivered: true, - path: "steered", - }; - } - if (outcome === "queued") { - return { - delivered: true, - path: "queued", - }; - } - return { - delivered: false, - path: "none", - }; -} - async function sendSubagentAnnounceDirectly(params: { targetRequesterSessionKey: string; triggerMessage: string; @@ -905,64 +880,34 @@ async function deliverSubagentAnnouncement(params: { directIdempotencyKey: string; signal?: AbortSignal; }): Promise { - if (params.signal?.aborted) { - return { - delivered: false, - path: "none", - }; - } - // Non-completion mode mirrors historical behavior: try queued/steered delivery first, - // then (only if not queued) attempt direct delivery. - if (!params.expectsCompletionMessage) { - const queueOutcome = await maybeQueueSubagentAnnounce({ - requesterSessionKey: params.requesterSessionKey, - announceId: params.announceId, - triggerMessage: params.triggerMessage, - summaryLine: params.summaryLine, - requesterOrigin: params.requesterOrigin, - signal: params.signal, - }); - const queued = queueOutcomeToDeliveryResult(queueOutcome); - if (queued.delivered) { - return queued; - } - } - - // Completion-mode uses direct send first so manual spawns can return immediately - // in the common ready-to-deliver case. - const direct = await sendSubagentAnnounceDirectly({ - targetRequesterSessionKey: params.targetRequesterSessionKey, - triggerMessage: params.triggerMessage, - completionMessage: params.completionMessage, - directIdempotencyKey: params.directIdempotencyKey, - completionDirectOrigin: params.completionDirectOrigin, - completionRouteMode: params.completionRouteMode, - spawnMode: params.spawnMode, - directOrigin: params.directOrigin, - requesterIsSubagent: params.requesterIsSubagent, + return await runSubagentAnnounceDispatch({ expectsCompletionMessage: params.expectsCompletionMessage, signal: params.signal, - bestEffortDeliver: params.bestEffortDeliver, + queue: async () => + await maybeQueueSubagentAnnounce({ + requesterSessionKey: params.requesterSessionKey, + announceId: params.announceId, + triggerMessage: params.triggerMessage, + summaryLine: params.summaryLine, + requesterOrigin: params.requesterOrigin, + signal: params.signal, + }), + direct: async () => + await sendSubagentAnnounceDirectly({ + targetRequesterSessionKey: params.targetRequesterSessionKey, + triggerMessage: params.triggerMessage, + completionMessage: params.completionMessage, + directIdempotencyKey: params.directIdempotencyKey, + completionDirectOrigin: params.completionDirectOrigin, + completionRouteMode: params.completionRouteMode, + spawnMode: params.spawnMode, + directOrigin: params.directOrigin, + requesterIsSubagent: params.requesterIsSubagent, + expectsCompletionMessage: params.expectsCompletionMessage, + signal: params.signal, + bestEffortDeliver: params.bestEffortDeliver, + }), }); - if (direct.delivered || !params.expectsCompletionMessage) { - return direct; - } - - // If completion path failed direct delivery, try queueing as a fallback so the - // report can still be delivered once the requester session is idle. - const queueOutcome = await maybeQueueSubagentAnnounce({ - requesterSessionKey: params.requesterSessionKey, - announceId: params.announceId, - triggerMessage: params.triggerMessage, - summaryLine: params.summaryLine, - requesterOrigin: params.requesterOrigin, - signal: params.signal, - }); - if (queueOutcome === "steered" || queueOutcome === "queued") { - return queueOutcomeToDeliveryResult(queueOutcome); - } - - return direct; } function loadSessionEntryByKey(sessionKey: string) { diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 8389c53503c..498b38aaedc 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -155,4 +155,43 @@ describe("announce loop guard (#18264)", () => { const stored = runs.find((run) => run.runId === entry.runId); expect(stored?.cleanupCompletedAt).toBeDefined(); }); + + test("announce rejection resets cleanupHandled so retries can resume", async () => { + announceFn.mockReset(); + announceFn.mockRejectedValueOnce(new Error("announce failed")); + registry.resetSubagentRegistryForTests(); + + const now = Date.now(); + const runId = "test-announce-rejection"; + loadSubagentRegistryFromDisk.mockReturnValue( + new Map([ + [ + runId, + { + runId, + childSessionKey: "agent:main:subagent:child-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "agent:main:main", + task: "rejection test", + cleanup: "keep" as const, + createdAt: now - 30_000, + startedAt: now - 20_000, + endedAt: now - 10_000, + cleanupHandled: false, + }, + ], + ]), + ); + + registry.initSubagentRegistry(); + await Promise.resolve(); + await Promise.resolve(); + + const runs = registry.listSubagentRunsForRequester("agent:main:main"); + const stored = runs.find((run) => run.runId === runId); + expect(stored?.cleanupHandled).toBe(false); + expect(stored?.cleanupCompletedAt).toBeUndefined(); + expect(stored?.announceRetryCount).toBe(1); + expect(stored?.lastAnnounceRetryAt).toBeTypeOf("number"); + }); }); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index edb8f228b07..072fd91693f 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -331,9 +331,16 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor outcome: entry.outcome, spawnMode: entry.spawnMode, expectsCompletionMessage: entry.expectsCompletionMessage, - }).then((didAnnounce) => { - void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); - }); + }) + .then((didAnnounce) => { + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + }) + .catch((error) => { + defaultRuntime.log( + `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`, + ); + void finalizeSubagentCleanup(runId, entry.cleanup, false); + }); return true; } diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 9e976a79ae1..f398d94aae4 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,7 +1,8 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; import { loadConfig } from "../../config/config.js"; +import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { @@ -166,7 +167,7 @@ export const sendHandlers: GatewayRequestHandlers = { ? request.threadId.trim() : undefined; const outboundChannel = channel; - const plugin = getChannelPlugin(channel); + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); if (!plugin) { respond( false, @@ -393,7 +394,7 @@ export const sendHandlers: GatewayRequestHandlers = { ? request.accountId.trim() : undefined; try { - const plugin = getChannelPlugin(channel); + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); const outbound = plugin?.outbound; if (!outbound?.sendPoll) { respond( diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts new file mode 100644 index 00000000000..58596da93f3 --- /dev/null +++ b/src/infra/outbound/channel-resolution.ts @@ -0,0 +1,73 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; +import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import { getActivePluginRegistry, getActivePluginRegistryKey } from "../../plugins/runtime.js"; +import { + isDeliverableMessageChannel, + normalizeMessageChannel, + type DeliverableMessageChannel, +} from "../../utils/message-channel.js"; + +const bootstrapAttempts = new Set(); + +export function normalizeDeliverableOutboundChannel( + raw?: string | null, +): DeliverableMessageChannel | undefined { + const normalized = normalizeMessageChannel(raw); + if (!normalized || !isDeliverableMessageChannel(normalized)) { + return undefined; + } + return normalized; +} + +function maybeBootstrapChannelPlugin(params: { + channel: DeliverableMessageChannel; + cfg?: OpenClawConfig; +}): void { + const cfg = params.cfg; + if (!cfg) { + return; + } + + const activeRegistry = getActivePluginRegistry(); + if ((activeRegistry?.channels?.length ?? 0) > 0) { + return; + } + + const registryKey = getActivePluginRegistryKey() ?? ""; + const attemptKey = `${registryKey}:${params.channel}`; + if (bootstrapAttempts.has(attemptKey)) { + return; + } + bootstrapAttempts.add(attemptKey); + + const autoEnabled = applyPluginAutoEnable({ config: cfg }).config; + const defaultAgentId = resolveDefaultAgentId(autoEnabled); + const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId); + loadOpenClawPlugins({ + config: autoEnabled, + workspaceDir, + }); +} + +export function resolveOutboundChannelPlugin(params: { + channel: string; + cfg?: OpenClawConfig; +}): ChannelPlugin | undefined { + const normalized = normalizeDeliverableOutboundChannel(params.channel); + if (!normalized) { + return undefined; + } + + const resolve = () => getChannelPlugin(normalized); + const current = resolve(); + if (current) { + return current; + } + + maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg }); + return resolve(); +} diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 3714e7ab5ac..d6fab2e39dc 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ getChannelPlugin: vi.fn(), resolveOutboundTarget: vi.fn(), deliverOutboundPayloads: vi.fn(), + loadOpenClawPlugins: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ @@ -11,6 +12,19 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: mocks.getChannelPlugin, })); +vi.mock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: () => "main", + resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + vi.mock("./targets.js", () => ({ resolveOutboundTarget: mocks.resolveOutboundTarget, })); @@ -19,13 +33,17 @@ vi.mock("./deliver.js", () => ({ deliverOutboundPayloads: mocks.deliverOutboundPayloads, })); +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { sendMessage } from "./message.js"; describe("sendMessage", () => { beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); mocks.getChannelPlugin.mockClear(); mocks.resolveOutboundTarget.mockClear(); mocks.deliverOutboundPayloads.mockClear(); + mocks.loadOpenClawPlugins.mockClear(); mocks.getChannelPlugin.mockReturnValue({ outbound: { deliveryMode: "direct" }, @@ -37,8 +55,8 @@ describe("sendMessage", () => { it("passes explicit agentId to outbound delivery for scoped media roots", async () => { await sendMessage({ cfg: {}, - channel: "mattermost", - to: "channel:town-square", + channel: "telegram", + to: "123456", content: "hi", agentId: "work", }); @@ -46,9 +64,34 @@ describe("sendMessage", () => { expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ agentId: "work", - channel: "mattermost", - to: "channel:town-square", + channel: "telegram", + to: "123456", }), ); }); + + it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => { + const telegramPlugin = { + outbound: { deliveryMode: "direct" }, + }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + + await expect( + sendMessage({ + cfg: { channels: { telegram: { botToken: "test-token" } } }, + channel: "telegram", + to: "123456", + content: "hi", + }), + ).resolves.toMatchObject({ + channel: "telegram", + to: "123456", + via: "direct", + }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 649aabd0ece..30451b66959 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,4 +1,3 @@ -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; @@ -10,6 +9,10 @@ import { type GatewayClientMode, type GatewayClientName, } from "../../utils/message-channel.js"; +import { + normalizeDeliverableOutboundChannel, + resolveOutboundChannelPlugin, +} from "./channel-resolution.js"; import { resolveMessageChannelSelection } from "./channel-selection.js"; import { deliverOutboundPayloads, @@ -107,17 +110,18 @@ async function resolveRequiredChannel(params: { cfg: OpenClawConfig; channel?: string; }): Promise { - const channel = params.channel?.trim() - ? normalizeChannelId(params.channel) - : (await resolveMessageChannelSelection({ cfg: params.cfg })).channel; - if (!channel) { - throw new Error(`Unknown channel: ${params.channel}`); + if (params.channel?.trim()) { + const normalized = normalizeDeliverableOutboundChannel(params.channel); + if (!normalized) { + throw new Error(`Unknown channel: ${params.channel}`); + } + return normalized; } - return channel; + return (await resolveMessageChannelSelection({ cfg: params.cfg })).channel; } -function resolveRequiredPlugin(channel: string) { - const plugin = getChannelPlugin(channel); +function resolveRequiredPlugin(channel: string, cfg: OpenClawConfig) { + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); if (!plugin) { throw new Error(`Unknown channel: ${channel}`); } @@ -166,7 +170,7 @@ async function callMessageGateway(params: { export async function sendMessage(params: MessageSendParams): Promise { const cfg = params.cfg ?? loadConfig(); const channel = await resolveRequiredChannel({ cfg, channel: params.channel }); - const plugin = resolveRequiredPlugin(channel); + const plugin = resolveRequiredPlugin(channel, cfg); const deliveryMode = plugin.outbound?.deliveryMode ?? "direct"; const normalizedPayloads = normalizeReplyPayloadsForDelivery([ { @@ -279,7 +283,7 @@ export async function sendPoll(params: MessagePollParams): Promise ({ + getChannelPlugin: vi.fn(), + loadOpenClawPlugins: vi.fn(), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, + normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: () => "main", + resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { resolveOutboundTarget } from "./targets.js"; + +describe("resolveOutboundTarget channel resolution", () => { + beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); + mocks.getChannelPlugin.mockReset(); + mocks.loadOpenClawPlugins.mockReset(); + }); + + it("recovers telegram plugin resolution so announce delivery does not fail with Unsupported channel: telegram", () => { + const telegramPlugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + + const result = resolveOutboundTarget({ + channel: "telegram", + to: "123456", + cfg: { channels: { telegram: { botToken: "test-token" } } }, + mode: "explicit", + }); + + expect(result).toEqual({ ok: true, to: "123456" }); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 41baa558653..d9411e2223c 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,5 +1,4 @@ import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -20,6 +19,10 @@ import { normalizeMessageChannel, } from "../../utils/message-channel.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import { + normalizeDeliverableOutboundChannel, + resolveOutboundChannelPlugin, +} from "./channel-resolution.js"; import { missingTargetError } from "./target-errors.js"; export type OutboundChannel = DeliverableMessageChannel | "none"; @@ -181,7 +184,10 @@ export function resolveOutboundTarget(params: { }; } - const plugin = getChannelPlugin(params.channel); + const plugin = resolveOutboundChannelPlugin({ + channel: params.channel, + cfg: params.cfg, + }); if (!plugin) { return { ok: false, @@ -242,7 +248,7 @@ export function resolveHeartbeatDeliveryTarget(params: { if (rawTarget === "none" || rawTarget === "last") { target = rawTarget; } else if (typeof rawTarget === "string") { - const normalized = normalizeChannelId(rawTarget); + const normalized = normalizeDeliverableOutboundChannel(rawTarget); if (normalized) { target = normalized; } @@ -269,7 +275,10 @@ export function resolveHeartbeatDeliveryTarget(params: { let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId; if (heartbeatAccountId && resolvedTarget.channel) { - const plugin = getChannelPlugin(resolvedTarget.channel); + const plugin = resolveOutboundChannelPlugin({ + channel: resolvedTarget.channel, + cfg, + }); const listAccountIds = plugin?.config.listAccountIds; const accountIds = listAccountIds ? listAccountIds(cfg) : []; if (accountIds.length > 0) { @@ -331,7 +340,10 @@ export function resolveHeartbeatDeliveryTarget(params: { } let reason: string | undefined; - const plugin = getChannelPlugin(resolvedTarget.channel); + const plugin = resolveOutboundChannelPlugin({ + channel: resolvedTarget.channel, + cfg, + }); if (plugin?.config.resolveAllowFrom) { const explicit = resolveOutboundTarget({ channel: resolvedTarget.channel, @@ -516,7 +528,10 @@ export function resolveHeartbeatSenderContext(params: { params.delivery.accountId ?? (provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined); const allowFromRaw = provider - ? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({ + ? (resolveOutboundChannelPlugin({ + channel: provider, + cfg: params.cfg, + })?.config.resolveAllowFrom?.({ cfg: params.cfg, accountId, }) ?? []) diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 37d881d843c..afd616b5f15 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -196,6 +196,10 @@ describe("sendMessageTelegram", () => { for (const testCase of cases) { botCtorSpy.mockClear(); loadConfig.mockReturnValue(testCase.cfg); + botApi.sendMessage.mockResolvedValue({ + message_id: 1, + chat: { id: "123" }, + }); await sendMessageTelegram("123", "hi", testCase.opts); expect(botCtorSpy, testCase.name).toHaveBeenCalledWith( "tok", @@ -325,6 +329,40 @@ describe("sendMessageTelegram", () => { } }); + it("fails when Telegram text send returns no message_id", async () => { + const sendMessage = vi.fn().mockResolvedValue({ + chat: { id: "123" }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await expect( + sendMessageTelegram("123", "hi", { + token: "tok", + api, + }), + ).rejects.toThrow(/returned no message_id/i); + }); + + it("fails when Telegram media send returns no message_id", async () => { + mockLoadedMedia({ contentType: "image/png", fileName: "photo.png" }); + const sendPhoto = vi.fn().mockResolvedValue({ + chat: { id: "123" }, + }); + const api = { sendPhoto } as unknown as { + sendPhoto: typeof sendPhoto; + }; + + await expect( + sendMessageTelegram("123", "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/photo.png", + }), + ).rejects.toThrow(/returned no message_id/i); + }); + it("uses native fetch for BAN compatibility when api is omitted", async () => { const originalFetch = globalThis.fetch; const originalBun = (globalThis as { Bun?: unknown }).Bun; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 85327df22b5..ceaa9113e32 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -86,6 +86,16 @@ type TelegramReactionOpts = { retry?: RetryConfig; }; +function resolveTelegramMessageIdOrThrow( + result: TelegramMessageLike | null | undefined, + context: string, +): number { + if (typeof result?.message_id === "number" && Number.isFinite(result.message_id)) { + return Math.trunc(result.message_id); + } + throw new Error(`Telegram ${context} returned no message_id`); +} + const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i; const MESSAGE_NOT_MODIFIED_RE = @@ -685,11 +695,9 @@ export async function sendMessageTelegram( })(); const result = await sendMedia(mediaSender.label, mediaSender.sender); - const mediaMessageId = String(result?.message_id ?? "unknown"); + const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send"); const resolvedChatId = String(result?.chat?.id ?? chatId); - if (result?.message_id) { - recordSentMessage(chatId, result.message_id); - } + recordSentMessage(chatId, mediaMessageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, @@ -708,13 +716,15 @@ export async function sendMessageTelegram( : undefined; const textRes = await sendTelegramText(followUpText, textParams); // Return the text message ID as the "main" message (it's the actual content). + const textMessageId = resolveTelegramMessageIdOrThrow(textRes, "text follow-up send"); + recordSentMessage(chatId, textMessageId); return { - messageId: String(textRes?.message_id ?? mediaMessageId), + messageId: String(textMessageId), chatId: resolvedChatId, }; } - return { messageId: mediaMessageId, chatId: resolvedChatId }; + return { messageId: String(mediaMessageId), chatId: resolvedChatId }; } if (!text || !text.trim()) { @@ -728,16 +738,14 @@ export async function sendMessageTelegram( } : undefined; const res = await sendTelegramText(text, textParams, opts.plainText); - const messageId = String(res?.message_id ?? "unknown"); - if (res?.message_id) { - recordSentMessage(chatId, res.message_id); - } + const messageId = resolveTelegramMessageIdOrThrow(res, "text send"); + recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); - return { messageId, chatId: String(res?.chat?.id ?? chatId) }; + return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; } export async function reactMessageTelegram( @@ -1013,18 +1021,16 @@ export async function sendStickerTelegram( requestWithChatNotFound(() => api.sendSticker(chatId, fileId.trim(), effectiveParams), label), ); - const messageId = String(result?.message_id ?? "unknown"); + const messageId = resolveTelegramMessageIdOrThrow(result, "sticker send"); const resolvedChatId = String(result?.chat?.id ?? chatId); - if (result?.message_id) { - recordSentMessage(chatId, result.message_id); - } + recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); - return { messageId, chatId: resolvedChatId }; + return { messageId: String(messageId), chatId: resolvedChatId }; } type TelegramPollOpts = { @@ -1121,12 +1127,10 @@ export async function sendPollTelegram( ), ); - const messageId = String(result?.message_id ?? "unknown"); + const messageId = resolveTelegramMessageIdOrThrow(result, "poll send"); const resolvedChatId = String(result?.chat?.id ?? chatId); const pollId = result?.poll?.id; - if (result?.message_id) { - recordSentMessage(chatId, result.message_id); - } + recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", @@ -1134,7 +1138,7 @@ export async function sendPollTelegram( direction: "outbound", }); - return { messageId, chatId: resolvedChatId, pollId }; + return { messageId: String(messageId), chatId: resolvedChatId, pollId }; } // --------------------------------------------------------------------------- From 876018f322b7f4388985fe9d14f73b8296d40072 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:31:32 +0100 Subject: [PATCH 30/95] chore(deps): update dependencies and lockfile --- CHANGELOG.md | 1 + package.json | 12 +- pnpm-lock.yaml | 2120 +++++++++++------------------------------------- 3 files changed, 475 insertions(+), 1658 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee1e3d0375..d71991f6a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. diff --git a/package.json b/package.json index 81a8a66cb4b..5f6443b64c8 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.997.0", + "@aws-sdk/client-bedrock": "^3.998.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", "@discordjs/voice": "^0.19.0", @@ -151,10 +151,10 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.55.0", - "@mariozechner/pi-ai": "0.55.0", - "@mariozechner/pi-coding-agent": "0.55.0", - "@mariozechner/pi-tui": "0.55.0", + "@mariozechner/pi-agent-core": "0.55.1", + "@mariozechner/pi-ai": "0.55.1", + "@mariozechner/pi-coding-agent": "0.55.1", + "@mariozechner/pi-tui": "0.55.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", @@ -204,7 +204,7 @@ "@types/node": "^25.3.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260224.1", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", "oxfmt": "0.35.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 365b0ee1707..0ddc70d9f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 0.14.1 version: 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.997.0 - version: 3.997.0 + specifier: ^3.998.0 + version: 3.998.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) @@ -54,23 +54,23 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.55.0 - version: 0.55.0 + specifier: 0.55.1 + version: 0.55.1 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 '@napi-rs/canvas': specifier: ^0.1.89 - version: 0.1.92 + version: 0.1.95 '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 @@ -214,8 +214,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260224.1 - version: 7.0.0-dev.20260224.1 + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -236,7 +236,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -314,7 +314,7 @@ importers: version: 10.6.1 openclaw: specifier: '>=2026.1.26' - version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/imessage: {} @@ -350,7 +350,7 @@ importers: dependencies: openclaw: specifier: '>=2026.1.26' - version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -532,226 +532,111 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.995.0': - resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==} + '@aws-sdk/client-bedrock-runtime@3.998.0': + resolution: {integrity: sha512-orRgpdNmdRLik+en3xDxlGuT5AxQU+GFUTMn97ZdRuPLnAiY7Y6/8VTsod6y97/3NB8xuTZbH9wNXzW97IWNMA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.997.0': - resolution: {integrity: sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==} + '@aws-sdk/client-bedrock@3.998.0': + resolution: {integrity: sha512-NeSBIdsJwVtACGHXVoguJOsKhq6oR5Q2B6BUU7LWGqIl1skwPors77aLpOa2240ZFtX3Br/0lJYfxAhB8692KA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.995.0': - resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==} + '@aws-sdk/core@3.973.14': + resolution: {integrity: sha512-iAQ1jIGESTVjoqNNY9VlsE9FnCz+Hc8s+dgurF6WrgFyVIw+uggH+V102RFhwjRv4dLSSLfzjDwvQnLszov7TQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.997.0': - resolution: {integrity: sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==} + '@aws-sdk/credential-provider-env@3.972.12': + resolution: {integrity: sha512-WPtj/iAYHHd+NDM6AZoilZwUz0nMaPxbTPGLA7nhyIYRZN2L8trqfbNvm7g/Jr3gzfKp1LpO6AtBTnrhz9WW2g==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.993.0': - resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} + '@aws-sdk/credential-provider-http@3.972.14': + resolution: {integrity: sha512-umtjCicH2o/Fcc8Fu1562UkDyt6gql4czTYVlUfHfAM8S4QEKggzmtHYYYpPfQcjFj1ajyy68ahYSuF67x4ptQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.11': - resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} + '@aws-sdk/credential-provider-ini@3.972.12': + resolution: {integrity: sha512-qjzgnMl6GIBbVeK74jBqSF07+s6kyeZl5R88qjMs302JlqkxE57jkvflDmZ9I017ffEWqIUa9/M4Hfp28qyu1g==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.13': - resolution: {integrity: sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==} + '@aws-sdk/credential-provider-login@3.972.12': + resolution: {integrity: sha512-AO57y46PzG24bJzxWLk+FYJG6MzxvXoFXnOKnmKUGV43ub4/FS/4Rz7zCC6ThqUotgqEFd30l5LTAd65RP65pg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.11': - resolution: {integrity: sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==} + '@aws-sdk/credential-provider-node@3.972.13': + resolution: {integrity: sha512-ME2sgus+gFRtiudy5Xqj9iT/tj8lHOIGrFgktuO5skJU4EngOvTZ1Hpj8mknrW4FgWXmpWhc88NtEscUuuDpKw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.9': - resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} + '@aws-sdk/credential-provider-process@3.972.12': + resolution: {integrity: sha512-msxrHBpVP5AOIDohNPCINUtL47f7XI1TEru3N13uM3nWUMvIRA1vFa8Tlxbxm1EntPPvLAxRmvE5EbjDjOZkbw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.11': - resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} + '@aws-sdk/credential-provider-sso@3.972.12': + resolution: {integrity: sha512-D5iC5546hJyhobJN0szOT4KVeJQ8z/meZq2B3lEDZFcvHONKw+tzq36DAJUy3qLTueeB2geSxiHXngQlA11eoA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.13': - resolution: {integrity: sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==} + '@aws-sdk/credential-provider-web-identity@3.972.12': + resolution: {integrity: sha512-yluBahBVsduoA/zgV0NAXtwwXvQ6tNn95dNA3Hg+vISdiPWA46QY0d9PLO2KpNbjtm+1oGcWxemS4fYTwJ0W1w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.11': - resolution: {integrity: sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==} + '@aws-sdk/eventstream-handler-node@3.972.8': + resolution: {integrity: sha512-tVrf8X7hKnqv3HyVraUbsQW5mfHlD++S5NSIbfQEx0sCRvIwUbTPDl/lJCxhNmZ2zjgUyBIXIKrWilFWBxzv+w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.9': - resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} + '@aws-sdk/middleware-eventstream@3.972.5': + resolution: {integrity: sha512-j8sFerTrzS9tEJhiW2k+T9hsELE+13D5H+mqMjTRyPSgAOebkiK9d4t8vjbLOXuk7yi5lop40x15MubgcjpLmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.11': - resolution: {integrity: sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==} + '@aws-sdk/middleware-host-header@3.972.5': + resolution: {integrity: sha512-dVA0m1cEQ2iA6yB19aHvWNeUVTuvTt3AXzT0aiIu2uxk0S7AcmwDCDaRgYa/v+eFHcJVxEnpYTozqA7X62xinw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.9': - resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} + '@aws-sdk/middleware-logger@3.972.5': + resolution: {integrity: sha512-03RqplLZjUTkYi0dDPR/bbOLnDLFNdaVvNENgA3XK7Ph1MhEBhUYlgoGfOyRAKApDZ+WG4ykOoA8jI8J04jmFA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.10': - resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} + '@aws-sdk/middleware-recursion-detection@3.972.5': + resolution: {integrity: sha512-2QSuuVkpHTe84+mDdnFjHX8rAP3g0yYwLVAhS3lQN1rW5Z/zNsf8/pYQrLjLO4n4sPCsUAkTa0Vrod0lk+o1Tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.12': - resolution: {integrity: sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==} + '@aws-sdk/middleware-user-agent@3.972.14': + resolution: {integrity: sha512-PzDz+yRAQuIzd+4ZY3s6/TYRzlNKAn4Gae3E5uLV7NnYHqrZHFoAfKE4beXcu3C51pA2/FQ3X2qOGSYqUoN1WQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.11': - resolution: {integrity: sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-process@3.972.9': - resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-sso@3.972.11': - resolution: {integrity: sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-sso@3.972.9': - resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.972.11': - resolution: {integrity: sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.972.9': - resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/eventstream-handler-node@3.972.5': - resolution: {integrity: sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/eventstream-handler-node@3.972.7': - resolution: {integrity: sha512-p8k2ZWKJVrR3KIcBbI+/+FcWXdwe3LLgGnixsA7w8lDwWjzSVDHFp6uPeSqBt5PQpRxzak9EheJ1xTmOnHGf4g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-eventstream@3.972.3': - resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-eventstream@3.972.4': - resolution: {integrity: sha512-0t+2Dn46cRE9iu5ynUXINBtR0wNHi/Jz3FbrqS5k3dGot2O7Ln1xCqXbJUAtGM5ZAqN77SbnpETAgVWC84DeoA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-host-header@3.972.4': - resolution: {integrity: sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-logger@3.972.4': - resolution: {integrity: sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.972.4': - resolution: {integrity: sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.11': - resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.13': - resolution: {integrity: sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-websocket@3.972.6': - resolution: {integrity: sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==} + '@aws-sdk/middleware-websocket@3.972.9': + resolution: {integrity: sha512-O+FSwU9UvKd+QNuGLHqvmP33kkH4jh8pAgdMo3wbFLf+u30fS9/2gbSSWWtNCcWkSNFyG6RUlKU7jPSLApFfGw==} engines: {node: '>= 14.0.0'} - '@aws-sdk/middleware-websocket@3.972.8': - resolution: {integrity: sha512-KPUXz8lRw73Rh12/QkELxiryC9Wi9Ah1xNzFe2Vtbz2/81c2ZA0yM8er+u0iCF/SRMMhDQshLcmRNgn/ueA+gA==} - engines: {node: '>= 14.0.0'} - - '@aws-sdk/nested-clients@3.993.0': - resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} + '@aws-sdk/nested-clients@3.996.2': + resolution: {integrity: sha512-W+u6EM8WRxOIhAhR2mXMHSaUygqItpTehkgxLwJngXqr9RlAR4t6CtECH7o7QK0ct3oyi5Z8ViDHtPbel+D2Rg==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.995.0': - resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==} + '@aws-sdk/region-config-resolver@3.972.5': + resolution: {integrity: sha512-AOitrygDwfTNCLCW7L+GScDy1p49FZ6WutTUFWROouoPetfVNmpL4q8TWD3MhfY/ynhoGhleUQENrBH374EU8w==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.996.1': - resolution: {integrity: sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==} + '@aws-sdk/token-providers@3.998.0': + resolution: {integrity: sha512-JFzi44tQnENZQ+1DYcHfoa/wTRKkccz0VsNMow0rvsxZtqUEkeV2pYFbir35mHTyUKju9995ay1MAGxLt1dpRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + '@aws-sdk/types@3.973.3': + resolution: {integrity: sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.4': - resolution: {integrity: sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==} + '@aws-sdk/util-endpoints@3.996.2': + resolution: {integrity: sha512-83E6T1CKi0/IozPzqRBKqduW0mS4UQdI3soBH6CG7UgupTADWunqEMOTuPWCs9XGjpJJ4ujj+yu7pn8svhp5yg==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.993.0': - resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.995.0': - resolution: {integrity: sha512-lYSadNdZZ513qCKoj/KlJ+PgCycL3n8ZNS37qLVFC0t7TbHzoxvGquu9aD2n9OCERAn43OMhQ7dXjYDYdjAXzA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.997.0': - resolution: {integrity: sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.2': - resolution: {integrity: sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.993.0': - resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.995.0': - resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.996.1': - resolution: {integrity: sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.3': - resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.4': - resolution: {integrity: sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==} + '@aws-sdk/util-format-url@3.972.5': + resolution: {integrity: sha512-PccfrPQVOEQSL8xaSvu988ESMlqdH1Qfk3AWPZksCOYPHyzYeUV988E+DBachXNV7tBVTUvK85cZYEZu7JtPxQ==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-browser@3.972.5': + resolution: {integrity: sha512-2ja1WqtuBaEAMgVoHYuWx393DF6ULqdt3OozeO7BosqouYaoU47Adtp9vEF+GImSG/Q8A+dqfwDULTTdMkHGUQ==} - '@aws-sdk/util-user-agent-browser@3.972.4': - resolution: {integrity: sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==} - - '@aws-sdk/util-user-agent-node@3.972.10': - resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==} + '@aws-sdk/util-user-agent-node@3.972.13': + resolution: {integrity: sha512-PHErmuu+v6iAST48zcsB2cYwDKW45gk6qCp49t1p0NGZ4EaFPr/tA5jl0X/ekDwvWbuT0LTj++fjjdVQAbuh0Q==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -759,21 +644,8 @@ packages: aws-crt: optional: true - '@aws-sdk/util-user-agent-node@3.972.12': - resolution: {integrity: sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.972.5': - resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.6': - resolution: {integrity: sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==} + '@aws-sdk/xml-builder@3.972.7': + resolution: {integrity: sha512-9GF86s6mHuc1TYCbuKatMDWl2PyK3KIkpRaI7ul2/gYZPfaLzKZ+ISHhxzVb9KVeakf75tUQe6CXW2gugSCXNw==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.3': @@ -792,12 +664,12 @@ packages: resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} engines: {node: '>=20.0.0'} - '@azure/msal-common@16.0.4': - resolution: {integrity: sha512-0KZ9/wbUyZN65JLAx5bGNfWjkD0kRMUgM99oSpZFg7wEOb3XcKIiHrFnIpgyc8zZ70fHodyh8JKEOel1oN24Gw==} + '@azure/msal-common@16.1.0': + resolution: {integrity: sha512-uiX0ChrRFbreXlPlDR8LwHKmZpJudDAr124iNWJKJ+b7MJUWXmvVU3idSi/c5lk1FwLVZeMxhQir3BGdV09I+g==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.0.4': - resolution: {integrity: sha512-WbA77m68noCw4qV+1tMm5nodll34JCDF0KmrSrp9LskS0bGbgHt98ZRxq69BQK5mjMqDD5ThHJOrrGSfzPybxw==} + '@azure/msal-node@5.0.5': + resolution: {integrity: sha512-CxUYSZgFiviUC3d8Hc+tT7uxre6QkPEWYEHWXmyEBzaO6tfFY4hs5KbXWU6s4q9Zv1NP/04qiR3mcujYLRuYuw==} engines: {node: '>=20'} '@babel/generator@8.0.0-rc.1': @@ -1492,26 +1364,21 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.54.1': - resolution: {integrity: sha512-AC0SqEbR62PckWOyP0CmhYtfcC+Q6e1DGghwEcKpomTtmNfHTy7iTVy64mmtB2CFiN8j4rJFCqh2xJHgucUvkA==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.55.0': resolution: {integrity: sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.54.1': - resolution: {integrity: sha512-tiVvoNQV+3dpWgRQ1U/3bwJoDVSYwL17BE/kc00nXmaSLAPwNZoxLagtQ+HBr/rGzkq5viOgQf2dk+ud+/4UCg==} + '@mariozechner/pi-agent-core@0.55.1': + resolution: {integrity: sha512-t9FAb4ouy8HJSIa8gSRC7j8oeUOb2XDdhvBiHj7FhfpYafj1vRPrvGIEXUV8fPJDCI07vhK9iztP27EPk+yEWw==} engines: {node: '>=20.0.0'} - hasBin: true '@mariozechner/pi-ai@0.55.0': resolution: {integrity: sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.54.1': - resolution: {integrity: sha512-pPFrdaKZ16oIcdhZVcfWPhCDFx8PWHaACjQS9aFFcMOhLBduyKAGyf8bQtfysekl+gIbBSGDT2rgCxsOwK2bQw==} + '@mariozechner/pi-ai@0.55.1': + resolution: {integrity: sha512-JJX1LrVWPUPMExu0f89XR4nMNP37+FNLjEE4cIHq9Hi6xQtOiiEi7OjDFMx58hWsq81xH1CwmQXqGTWBjbXKTw==} engines: {node: '>=20.0.0'} hasBin: true @@ -1520,14 +1387,19 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.54.1': - resolution: {integrity: sha512-FY8QcLlr9T276oZAwMSSPo1drg+J9Y7B+A0S9g8Jh6IFJxymKZZq29/Vit6XDziJfZIgJDraC6lpobtxgTEoFQ==} + '@mariozechner/pi-coding-agent@0.55.1': + resolution: {integrity: sha512-H2M8mbBNyDqhON6+3m4H8CjqJ9taGq/CM3B8dG73+VJJIXFm5SExhU9bdgcw2xh0wWj8yEumsj0of6Tu+F7Ffg==} engines: {node: '>=20.0.0'} + hasBin: true '@mariozechner/pi-tui@0.55.0': resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.55.1': + resolution: {integrity: sha512-rnqDUp2fm/ySevC0Ltj/ZFRbEc1kZ1A4qHESejj9hA8NVrb/pX9g82XwTE762JOieEGrRWAtmHLNOm7/e4dJMw==} + engines: {node: '>=20.0.0'} + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -1547,144 +1419,74 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} - '@napi-rs/canvas-android-arm64@0.1.92': - resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==} + '@napi-rs/canvas-android-arm64@0.1.95': + resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-android-arm64@0.1.94': - resolution: {integrity: sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@napi-rs/canvas-darwin-arm64@0.1.92': - resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==} + '@napi-rs/canvas-darwin-arm64@0.1.95': + resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-arm64@0.1.94': - resolution: {integrity: sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@napi-rs/canvas-darwin-x64@0.1.92': - resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==} + '@napi-rs/canvas-darwin-x64@0.1.95': + resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.94': - resolution: {integrity: sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': - resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': + resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': - resolution: {integrity: sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': - resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': + resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': - resolution: {integrity: sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==} + '@napi-rs/canvas-linux-arm64-musl@0.1.95': + resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-musl@0.1.92': - resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-musl@0.1.94': - resolution: {integrity: sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': - resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': + resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': - resolution: {integrity: sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - - '@napi-rs/canvas-linux-x64-gnu@0.1.92': - resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==} + '@napi-rs/canvas-linux-x64-gnu@0.1.95': + resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-gnu@0.1.94': - resolution: {integrity: sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==} + '@napi-rs/canvas-linux-x64-musl@0.1.95': + resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-musl@0.1.92': - resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-musl@0.1.94': - resolution: {integrity: sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': - resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==} + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': + resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': - resolution: {integrity: sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@napi-rs/canvas-win32-x64-msvc@0.1.92': - resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==} + '@napi-rs/canvas-win32-x64-msvc@0.1.95': + resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.94': - resolution: {integrity: sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@napi-rs/canvas@0.1.92': - resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==} - engines: {node: '>= 10'} - - '@napi-rs/canvas@0.1.94': - resolution: {integrity: sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==} + '@napi-rs/canvas@0.1.95': + resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.1.1': @@ -1815,8 +1617,8 @@ packages: resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} engines: {node: '>= 20'} - '@octokit/endpoint@11.0.2': - resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} engines: {node: '>= 20'} '@octokit/graphql@9.0.3': @@ -1859,8 +1661,8 @@ packages: peerDependencies: '@octokit/core': '>=6' - '@octokit/plugin-retry@8.0.3': - resolution: {integrity: sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==} + '@octokit/plugin-retry@8.1.0': + resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} engines: {node: '>= 20'} peerDependencies: '@octokit/core': '>=7' @@ -1875,8 +1677,8 @@ packages: resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} engines: {node: '>= 20'} - '@octokit/request@10.0.7': - resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} engines: {node: '>= 20'} '@octokit/types@16.0.0': @@ -2667,22 +2469,10 @@ packages: resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} engines: {node: '>=18.0.0'} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} - engines: {node: '>=18.0.0'} - - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} - engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.9': resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.2': - resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.6': resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} engines: {node: '>=18.0.0'} @@ -2691,82 +2481,42 @@ packages: resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.10': resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.10': resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.10': resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.10': resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.10': resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} - engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.11': resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} - engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.10': resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} - engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.10': resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@4.2.1': resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} engines: {node: '>=18.0.0'} @@ -2775,22 +2525,10 @@ packages: resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.16': - resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.20': resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.33': - resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.37': resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} engines: {node: '>=18.0.0'} @@ -2799,30 +2537,14 @@ packages: resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.10': resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} - engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.10': resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.12': resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} engines: {node: '>=18.0.0'} @@ -2831,46 +2553,22 @@ packages: resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.10': resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.10': resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.10': resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} - engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.10': resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} - engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.5': resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} engines: {node: '>=18.0.0'} @@ -2879,22 +2577,10 @@ packages: resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.11.5': - resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} - engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.0': resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.13.0': resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} engines: {node: '>=18.0.0'} @@ -2903,30 +2589,14 @@ packages: resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} - engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.1': resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.1': resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.2': resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} engines: {node: '>=18.0.0'} @@ -2935,50 +2605,26 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@4.2.1': resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} - engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.1': resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.32': - resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.36': resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.35': - resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.39': resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} - engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.3.1': resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.1': resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} engines: {node: '>=18.0.0'} @@ -2987,30 +2633,14 @@ packages: resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} - engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.10': resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} - engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.15': resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} - engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.1': resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} engines: {node: '>=18.0.0'} @@ -3019,18 +2649,10 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@4.2.1': resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} - engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.1': resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} @@ -3292,43 +2914,46 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-3qSsqv7FmM4z09wEpEXdhmgMfiJF/OMOZa41AdgMsXTTRpX2/38hDg2KGhi3fc24M2T3MnLPLTqw6HyTOBaV1Q==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-F8ZCCX2UESHcbxvnkd1Dn5PTnOOgpGddFHYgn4usyWRMzNZLPP+YjyGALZe9zdR/D8L0uraND0Haok+TPq8xYg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Up8Z/QNcwce5C4rWnbLNW5w7lRARdyKZcNbB1NMnaswaGOBdeDmdP0wbVsOgJMoDp6vnun+EkvrSft8hWLLhIg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Iu5rnCmqwGIMUu//BXkl9VQaxAAsqVvFhU4mJoNexNkMxPqVcu9quqYAouY7tN/95WcKzUsPpyRfkThdbNFO/g==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-WWjIfHCWlcriempYYc/sPJ3HFt6znNZKp60nvDNih0+wmxNqEfT5Yzu5zAY0awIe7XLelFSY+bolkpzMYVWEIQ==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-lmfQO+HdmPMk0dtPoNo8dZereTUYNQuapsAI7nFHCP8F25I8eGKKXY2nD1R8W1hp/LmVtske1pqKFNN6IOCt5g==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-e4eJyzR9ne0XreqYgQNqfX7SNuaePxggnUtVrLERgBv25QKwdQl72GnSXDhdxZHzrb97YwumiXWMQQJj9h8NCg==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA==} + '@typescript/native-preview@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-mUf1aON+eZLupLorX4214n4W6uWIz/lvNv81ErzjJylD/GyJPEJkvDLmgIK3bbvLpMwTRWdVJLhpLCah5Qe8iA==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3637,6 +3262,9 @@ packages: resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} engines: {node: 18 || 20 || >=22} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3949,6 +3577,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4046,6 +3677,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + extsprintf@1.3.0: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} @@ -4063,6 +3699,9 @@ packages: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4182,10 +3821,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -4198,6 +3833,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -4376,8 +4015,8 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - ipull@3.9.3: - resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==} + ipull@3.9.5: + resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==} engines: {node: '>=18.0.0'} hasBin: true @@ -4490,6 +4129,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.3: + resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4538,8 +4180,8 @@ packages: lifecycle-utils@2.1.0: resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==} - lifecycle-utils@3.1.0: - resolution: {integrity: sha512-kVvegv+r/icjIo1dkHv1hznVQi4FzEVglJD2IU4w07HzevIyH3BAYsFZzEIbBk/nNZjXHGgclJ5g9rz9QdBCLw==} + lifecycle-utils@3.1.1: + resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==} lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} @@ -5019,8 +4661,8 @@ packages: zod: optional: true - openclaw@2026.2.23: - resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==} + openclaw@2026.2.24: + resolution: {integrity: sha512-a6zrcS6v5tUWqzsFh5cNtyu5+Tra1UW5yvPtYhRYCKSS/q6lXrLu+dj0ylJPOHRPAho2alZZL1gw1Qd2hAd2sQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -5030,9 +4672,6 @@ packages: opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} - opusscript@0.0.8: - resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==} - opusscript@0.1.1: resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} @@ -5163,6 +4802,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -5277,6 +4919,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5520,8 +5165,8 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 - simple-git@3.31.1: - resolution: {integrity: sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==} + simple-git@3.32.2: + resolution: {integrity: sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==} simple-yenc@1.0.4: resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==} @@ -6060,6 +5705,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -6093,7 +5741,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -6101,7 +5749,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6109,7 +5757,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6118,81 +5766,29 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.995.0': + '@aws-sdk/client-bedrock-runtime@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/eventstream-handler-node': 3.972.5 - '@aws-sdk/middleware-eventstream': 3.972.3 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/middleware-websocket': 3.972.6 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-bedrock-runtime@3.997.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-node': 3.972.12 - '@aws-sdk/eventstream-handler-node': 3.972.7 - '@aws-sdk/middleware-eventstream': 3.972.4 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/middleware-websocket': 3.972.8 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/eventstream-handler-node': 3.972.8 + '@aws-sdk/middleware-eventstream': 3.972.5 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/middleware-websocket': 3.972.9 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/eventstream-serde-browser': 4.2.10 @@ -6226,67 +5822,22 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.995.0': + '@aws-sdk/client-bedrock@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-bedrock@3.997.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-node': 3.972.12 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/fetch-http-handler': 5.3.11 @@ -6316,69 +5867,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.993.0': + '@aws-sdk/core@3.973.14': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.973.11': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.5 - '@smithy/core': 3.23.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/core@3.973.13': - dependencies: - '@aws-sdk/types': 3.973.2 - '@aws-sdk/xml-builder': 3.972.6 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/xml-builder': 3.972.7 '@smithy/core': 3.23.6 '@smithy/node-config-provider': 4.3.10 '@smithy/property-provider': 4.2.10 @@ -6391,39 +5883,18 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.11': + '@aws-sdk/credential-provider-env@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.9': + '@aws-sdk/credential-provider-http@3.972.14': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/fetch-http-handler': 5.3.11 '@smithy/node-http-handler': 4.4.12 '@smithy/property-provider': 4.2.10 @@ -6433,17 +5904,17 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.11': + '@aws-sdk/credential-provider-ini@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-env': 3.972.11 - '@aws-sdk/credential-provider-http': 3.972.13 - '@aws-sdk/credential-provider-login': 3.972.11 - '@aws-sdk/credential-provider-process': 3.972.11 - '@aws-sdk/credential-provider-sso': 3.972.11 - '@aws-sdk/credential-provider-web-identity': 3.972.11 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-login': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/credential-provider-imds': 4.2.10 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6452,30 +5923,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.972.9': + '@aws-sdk/credential-provider-login@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-login': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/protocol-http': 5.3.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6484,45 +5936,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.9': + '@aws-sdk/credential-provider-node@3.972.13': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.10': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-ini': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.12': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.11 - '@aws-sdk/credential-provider-http': 3.972.13 - '@aws-sdk/credential-provider-ini': 3.972.11 - '@aws-sdk/credential-provider-process': 3.972.11 - '@aws-sdk/credential-provider-sso': 3.972.11 - '@aws-sdk/credential-provider-web-identity': 3.972.11 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-ini': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/types': 3.973.3 '@smithy/credential-provider-imds': 4.2.10 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6531,30 +5953,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.11': + '@aws-sdk/credential-provider-process@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.9': + '@aws-sdk/credential-provider-sso@3.972.12': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6562,24 +5975,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.972.9': + '@aws-sdk/credential-provider-web-identity@3.972.12': dependencies: - '@aws-sdk/client-sso': 3.993.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/token-providers': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6587,127 +5987,55 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.9': + '@aws-sdk/eventstream-handler-node@3.972.8': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/eventstream-handler-node@3.972.5': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/eventstream-handler-node@3.972.7': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/eventstream-codec': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.3': + '@aws-sdk/middleware-eventstream@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-eventstream@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.3': + '@aws-sdk/middleware-host-header@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.3': + '@aws-sdk/middleware-logger@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.3': + '@aws-sdk/middleware-recursion-detection@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@aws/lambda-invoke-store': 0.2.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.11': + '@aws-sdk/middleware-user-agent@3.972.14': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@smithy/core': 3.23.2 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 '@smithy/core': 3.23.6 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.6': + '@aws-sdk/middleware-websocket@3.972.9': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-format-url': 3.972.3 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-websocket@3.972.8': - dependencies: - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-format-url': 3.972.4 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-format-url': 3.972.5 '@smithy/eventstream-codec': 4.2.10 '@smithy/eventstream-serde-browser': 4.2.10 '@smithy/fetch-http-handler': 5.3.11 @@ -6719,106 +6047,20 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.993.0': + '@aws-sdk/nested-clients@3.996.2': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/nested-clients@3.995.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/nested-clients@3.996.1': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/fetch-http-handler': 5.3.11 @@ -6848,51 +6090,19 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.3': + '@aws-sdk/region-config-resolver@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/region-config-resolver@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/config-resolver': 4.4.9 '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.993.0': + '@aws-sdk/token-providers@3.998.0': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.995.0': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.997.0': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6900,50 +6110,22 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.973.1': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/types@3.973.2': + '@aws-sdk/types@3.973.3': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.993.0': + '@aws-sdk/util-endpoints@3.996.2': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.995.0': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.996.1': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 '@smithy/url-parser': 4.2.10 '@smithy/util-endpoints': 3.3.1 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.3': + '@aws-sdk/util-format-url@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/querystring-builder': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 @@ -6952,43 +6134,22 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/util-user-agent-browser@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.10': + '@aws-sdk/util-user-agent-node@3.972.13': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.972.12': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/types': 3.973.3 '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.5': - dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.6 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.6': + '@aws-sdk/xml-builder@3.972.7': dependencies: '@smithy/types': 4.13.0 fast-xml-parser: 5.3.6 @@ -7016,11 +6177,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-common@16.0.4': {} + '@azure/msal-common@16.1.0': {} - '@azure/msal-node@5.0.4': + '@azure/msal-node@5.0.5': dependencies: - '@azure/msal-common': 16.0.4 + '@azure/msal-common': 16.1.0 jsonwebtoken: 9.0.3 uuid: 8.3.2 @@ -7065,26 +6226,6 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8)': - dependencies: - '@types/node': 25.3.0 - discord-api-types: 0.38.37 - optionalDependencies: - '@cloudflare/workers-types': 4.20260120.0 - '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) - '@hono/node-server': 1.19.9(hono@4.11.10) - '@types/bun': 1.3.9 - '@types/ws': 8.18.1 - ws: 8.19.0 - transitivePeerDependencies: - - '@discordjs/opus' - - bufferutil - - ffmpeg-static - - hono - - node-opus - - opusscript - - utf-8-validate - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)': dependencies: '@types/node': 25.3.0 @@ -7241,21 +6382,6 @@ snapshots: - supports-color optional: true - '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)': - dependencies: - '@types/ws': 8.18.1 - discord-api-types: 0.38.40 - prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8) - tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - '@discordjs/opus' - - bufferutil - - ffmpeg-static - - node-opus - - opusscript - - utf-8-validate - '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': dependencies: '@types/ws': 8.18.1 @@ -7602,7 +6728,7 @@ snapshots: '@larksuiteoapi/node-sdk@1.59.0': dependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -7618,7 +6744,7 @@ snapshots: dependencies: '@types/node': 24.10.13 optionalDependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -7713,18 +6839,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.54.1(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) @@ -7737,21 +6851,9 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.54.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.995.0 - '@google/genai': 1.42.0 - '@mistralai/mistralai': 1.10.0 - '@sinclair/typebox': 0.34.48 - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - chalk: 5.6.2 - openai: 6.10.0(ws@8.19.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.22.0 - zod-to-json-schema: 3.25.1(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7764,7 +6866,7 @@ snapshots: '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.997.0 + '@aws-sdk/client-bedrock-runtime': 3.998.0 '@google/genai': 1.42.0 '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 @@ -7785,26 +6887,21 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.54.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.54.1 - '@silvia-odwyer/photon-node': 0.3.4 + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.998.0 + '@google/genai': 1.42.0 + '@mistralai/mistralai': 1.10.0 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - file-type: 21.3.0 - glob: 13.0.6 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - marked: 15.0.12 - minimatch: 10.2.1 - proper-lockfile: 4.1.2 - yaml: 2.8.2 - optionalDependencies: - '@mariozechner/clipboard': 0.3.2 + openai: 6.10.0(ws@8.19.0)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.22.0 + zod-to-json-schema: 3.25.1(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7843,7 +6940,37 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.54.1': + '@mariozechner/pi-coding-agent@0.55.1(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/jiti': 2.6.5 + '@mariozechner/pi-agent-core': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.1 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + extract-zip: 2.0.1 + file-type: 21.3.0 + glob: 13.0.6 + hosted-git-info: 9.0.2 + ignore: 7.0.5 + marked: 15.0.12 + minimatch: 10.2.1 + proper-lockfile: 4.1.2 + yaml: 2.8.2 + optionalDependencies: + '@mariozechner/clipboard': 0.3.2 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-tui@0.55.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -7852,7 +6979,7 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 - '@mariozechner/pi-tui@0.55.0': + '@mariozechner/pi-tui@0.55.1': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -7879,9 +7006,9 @@ snapshots: '@microsoft/agents-hosting@1.3.1': dependencies: '@azure/core-auth': 1.10.1 - '@azure/msal-node': 5.0.4 + '@azure/msal-node': 5.0.5 '@microsoft/agents-activity': 1.3.1 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -7897,99 +7024,52 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/canvas-android-arm64@0.1.92': + '@napi-rs/canvas-android-arm64@0.1.95': optional: true - '@napi-rs/canvas-android-arm64@0.1.94': + '@napi-rs/canvas-darwin-arm64@0.1.95': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.92': + '@napi-rs/canvas-darwin-x64@0.1.95': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.94': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': optional: true - '@napi-rs/canvas-darwin-x64@0.1.92': + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': optional: true - '@napi-rs/canvas-darwin-x64@0.1.94': + '@napi-rs/canvas-linux-arm64-musl@0.1.95': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': + '@napi-rs/canvas-linux-x64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': + '@napi-rs/canvas-linux-x64-musl@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.92': + '@napi-rs/canvas-win32-x64-msvc@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.94': - optional: true - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': - optional: true - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': - optional: true - - '@napi-rs/canvas-linux-x64-gnu@0.1.92': - optional: true - - '@napi-rs/canvas-linux-x64-gnu@0.1.94': - optional: true - - '@napi-rs/canvas-linux-x64-musl@0.1.92': - optional: true - - '@napi-rs/canvas-linux-x64-musl@0.1.94': - optional: true - - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': - optional: true - - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': - optional: true - - '@napi-rs/canvas-win32-x64-msvc@0.1.92': - optional: true - - '@napi-rs/canvas-win32-x64-msvc@0.1.94': - optional: true - - '@napi-rs/canvas@0.1.92': + '@napi-rs/canvas@0.1.95': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.92 - '@napi-rs/canvas-darwin-arm64': 0.1.92 - '@napi-rs/canvas-darwin-x64': 0.1.92 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.92 - '@napi-rs/canvas-linux-arm64-musl': 0.1.92 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-musl': 0.1.92 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.92 - '@napi-rs/canvas-win32-x64-msvc': 0.1.92 - - '@napi-rs/canvas@0.1.94': - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.94 - '@napi-rs/canvas-darwin-arm64': 0.1.94 - '@napi-rs/canvas-darwin-x64': 0.1.94 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.94 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.94 - '@napi-rs/canvas-linux-arm64-musl': 0.1.94 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-musl': 0.1.94 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.94 - '@napi-rs/canvas-win32-x64-msvc': 0.1.94 + '@napi-rs/canvas-android-arm64': 0.1.95 + '@napi-rs/canvas-darwin-arm64': 0.1.95 + '@napi-rs/canvas-darwin-x64': 0.1.95 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.95 + '@napi-rs/canvas-linux-arm64-musl': 0.1.95 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-musl': 0.1.95 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.95 + '@napi-rs/canvas-win32-x64-msvc': 0.1.95 '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -8061,7 +7141,7 @@ snapshots: dependencies: '@octokit/auth-oauth-app': 9.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 toad-cache: 3.7.0 @@ -8072,14 +7152,14 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/auth-oauth-device@8.0.3': dependencies: '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8087,7 +7167,7 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8102,20 +7182,20 @@ snapshots: dependencies: '@octokit/auth-token': 6.0.0 '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 before-after-hook: 4.0.0 universal-user-agent: 7.0.3 - '@octokit/endpoint@11.0.2': + '@octokit/endpoint@11.0.3': dependencies: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/graphql@9.0.3': dependencies: - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8135,7 +7215,7 @@ snapshots: '@octokit/oauth-methods@6.0.2': dependencies: '@octokit/oauth-authorization-url': 8.0.0 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -8157,7 +7237,7 @@ snapshots: '@octokit/core': 7.0.6 '@octokit/types': 16.0.0 - '@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)': + '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': dependencies: '@octokit/core': 7.0.6 '@octokit/request-error': 7.1.0 @@ -8174,12 +7254,13 @@ snapshots: dependencies: '@octokit/types': 16.0.0 - '@octokit/request@10.0.7': + '@octokit/request@10.0.8': dependencies: - '@octokit/endpoint': 11.0.2 + '@octokit/endpoint': 11.0.3 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.3 universal-user-agent: 7.0.3 '@octokit/types@16.0.0': @@ -8782,7 +7863,7 @@ snapshots: '@slack/types': 2.20.0 '@slack/web-api': 7.14.1 '@types/express': 5.0.6 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) express: 5.2.1 path-to-regexp: 8.3.0 raw-body: 3.0.2 @@ -8828,7 +7909,7 @@ snapshots: '@slack/types': 2.20.0 '@types/node': 25.3.0 '@types/retry': 0.12.0 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) eventemitter3: 5.0.4 form-data: 2.5.4 is-electron: 2.2.2 @@ -8844,20 +7925,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/abort-controller@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - '@smithy/config-resolver@4.4.9': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -8867,19 +7934,6 @@ snapshots: '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/core@3.23.2': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - '@smithy/core@3.23.6': dependencies: '@smithy/middleware-serde': 4.2.11 @@ -8901,14 +7955,6 @@ snapshots: '@smithy/url-parser': 4.2.10 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.10': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8916,59 +7962,29 @@ snapshots: '@smithy/util-hex-encoding': 4.2.1 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.8': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.10': dependencies: '@smithy/eventstream-serde-universal': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.10': dependencies: '@smithy/eventstream-serde-universal': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.10': dependencies: '@smithy/eventstream-codec': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.8': - dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.11': dependencies: '@smithy/protocol-http': 5.3.10 @@ -8977,14 +7993,6 @@ snapshots: '@smithy/util-base64': 4.3.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - '@smithy/hash-node@4.2.10': dependencies: '@smithy/types': 4.13.0 @@ -8992,31 +8000,15 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/hash-node@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.1': dependencies: tslib: 2.8.1 @@ -9027,23 +8019,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.8': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.16': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.20': dependencies: '@smithy/core': 3.23.6 @@ -9055,18 +8030,6 @@ snapshots: '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.33': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - '@smithy/middleware-retry@4.4.37': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -9085,22 +8048,11 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/middleware-stack@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/node-config-provider@4.3.10': dependencies: '@smithy/property-provider': 4.2.10 @@ -9108,21 +8060,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.8': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.4.10': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/node-http-handler@4.4.12': dependencies: '@smithy/abort-controller': 4.2.10 @@ -9136,56 +8073,26 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/protocol-http@5.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/querystring-builder@4.2.10': dependencies: '@smithy/types': 4.13.0 '@smithy/util-uri-escape': 4.2.1 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 - '@smithy/querystring-parser@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/service-error-classification@4.2.10': dependencies: '@smithy/types': 4.13.0 - '@smithy/service-error-classification@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/shared-ini-file-loader@4.4.5': dependencies: '@smithy/types': 4.13.0 @@ -9202,27 +8109,6 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/signature-v4@5.3.8': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.11.5': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - '@smithy/smithy-client@4.12.0': dependencies: '@smithy/core': 3.23.6 @@ -9233,10 +8119,6 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@smithy/types@4.12.0': - dependencies: - tslib: 2.8.1 - '@smithy/types@4.13.0': dependencies: tslib: 2.8.1 @@ -9247,36 +8129,16 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/url-parser@4.2.8': - dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/util-base64@4.3.1': dependencies: '@smithy/util-buffer-from': 4.2.1 '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.2': dependencies: tslib: 2.8.1 @@ -9286,31 +8148,15 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.1': dependencies: '@smithy/is-array-buffer': 4.2.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-config-provider@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.32': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.36': dependencies: '@smithy/property-provider': 4.2.10 @@ -9318,16 +8164,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.35': - dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.39': dependencies: '@smithy/config-resolver': 4.4.9 @@ -9338,22 +8174,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-endpoints@3.3.1': dependencies: '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.1': dependencies: tslib: 2.8.1 @@ -9363,34 +8189,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-middleware@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-retry@4.2.10': dependencies: '@smithy/service-error-classification': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-retry@4.2.8': - dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.12': - dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/util-stream@4.5.15': dependencies: '@smithy/fetch-http-handler': 5.3.11 @@ -9402,10 +8206,6 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.1': dependencies: tslib: 2.8.1 @@ -9415,20 +8215,11 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - '@smithy/util-utf8@4.2.1': dependencies: '@smithy/util-buffer-from': 4.2.1 tslib: 2.8.1 - '@smithy/uuid@1.1.0': - dependencies: - tslib: 2.8.1 - '@smithy/uuid@1.1.1': dependencies: tslib: 2.8.1 @@ -9718,36 +8509,41 @@ snapshots: dependencies: '@types/node': 25.3.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.3.0 optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260224.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260225.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260225.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -10071,14 +8867,6 @@ snapshots: aws4@1.13.2: {} - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 2.5.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -10150,6 +8938,8 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -10426,6 +9216,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} @@ -10583,6 +9377,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + extsprintf@1.3.0: {} fast-content-type-parse@3.0.0: {} @@ -10595,6 +9399,10 @@ snapshots: dependencies: strnum: 2.1.2 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -10648,8 +9456,6 @@ snapshots: flatbuffers@24.12.23: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -10740,8 +9546,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -10762,6 +9566,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -10973,7 +9781,7 @@ snapshots: ipaddr.js@2.3.0: {} - ipull@3.9.3: + ipull@3.9.5: dependencies: '@tinyhttp/content-disposition': 2.2.4 async-retry: 1.3.3 @@ -11016,7 +9824,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 is-interactive@2.0.0: {} @@ -11088,6 +9896,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.3: {} + json5@2.2.3: {} jsonfile@6.2.0: @@ -11160,7 +9970,7 @@ snapshots: lifecycle-utils@2.1.0: {} - lifecycle-utils@3.1.0: {} + lifecycle-utils@3.1.1: {} lightningcss-android-arm64@1.30.2: optional: true @@ -11488,9 +10298,9 @@ snapshots: filenamify: 6.0.0 fs-extra: 11.3.3 ignore: 7.0.5 - ipull: 3.9.3 + ipull: 3.9.5 is-unicode-supported: 2.1.0 - lifecycle-utils: 3.1.0 + lifecycle-utils: 3.1.1 log-symbols: 7.0.1 nanoid: 5.1.6 node-addon-api: 8.5.0 @@ -11499,7 +10309,7 @@ snapshots: pretty-ms: 9.3.0 proper-lockfile: 4.1.2 semver: 7.7.4 - simple-git: 3.31.1 + simple-git: 3.32.2 slice-ansi: 7.1.2 stdout-update: 4.0.1 strip-ansi: 7.1.2 @@ -11584,7 +10394,7 @@ snapshots: '@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.6) '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - '@octokit/plugin-retry': 8.0.3(@octokit/core@7.0.6) + '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6) '@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6) '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -11628,28 +10438,29 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): + openclaw@2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.995.0 - '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + '@aws-sdk/client-bedrock': 3.998.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) '@clack/prompts': 1.0.1 - '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.40.0) '@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.0) '@homebridge/ciao': 1.3.5 '@larksuiteoapi/node-sdk': 1.59.0 '@line/bot-sdk': 10.6.0 '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.54.1 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 '@mozilla/readability': 0.6.0 - '@napi-rs/canvas': 0.1.94 + '@napi-rs/canvas': 0.1.95 '@sinclair/typebox': 0.34.48 '@slack/bolt': 4.6.0(@types/express@5.0.6) '@slack/web-api': 7.14.1 + '@snazzah/davey': 0.1.9 '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) ajv: 8.18.0 chalk: 5.6.2 @@ -11672,7 +10483,7 @@ snapshots: markdown-it: 14.1.1 node-edge-tts: 1.2.10 node-llama-cpp: 3.15.1(typescript@5.9.3) - opusscript: 0.0.8 + opusscript: 0.1.1 osc-progress: 0.3.0 pdfjs-dist: 5.4.624 playwright-core: 1.58.2 @@ -11709,8 +10520,6 @@ snapshots: '@wasm-audio-decoders/common': 9.0.7 optional: true - opusscript@0.0.8: {} - opusscript@0.1.1: {} ora@8.2.0: @@ -11874,11 +10683,13 @@ snapshots: pdfjs-dist@5.4.624: optionalDependencies: - '@napi-rs/canvas': 0.1.94 + '@napi-rs/canvas': 0.1.95 node-readable-to-web-readable-stream: 0.4.2 peberminta@0.9.0: {} + pend@1.2.0: {} + performance-now@2.1.0: {} picocolors@1.1.1: {} @@ -11939,11 +10750,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8): - optionalDependencies: - '@discordjs/opus': 0.10.0 - opusscript: 0.0.8 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): optionalDependencies: '@discordjs/opus': 0.10.0 @@ -12029,6 +10835,11 @@ snapshots: dependencies: punycode: 2.3.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -12137,7 +10948,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -12150,7 +10961,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260224.1 + '@typescript/native-preview': 7.0.0-dev.20260225.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -12376,7 +11187,7 @@ snapshots: dependencies: signal-polyfill: 0.2.2 - simple-git@3.31.1: + simple-git@3.32.2: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -12505,7 +11316,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 string_decoder@1.1.1: @@ -12599,7 +11410,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -12610,7 +11421,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -12853,6 +11664,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yoctocolors@2.1.2: {} zod-to-json-schema@3.25.1(zod@3.25.76): From 8f8e46d898c55a5b37d4b6d6f1b446e04f104a4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:34:37 +0100 Subject: [PATCH 31/95] refactor: unify reaction ingress policy guards across channels --- .../bluebubbles/src/monitor-processing.ts | 43 ++- .../mattermost/src/mattermost/monitor.ts | 83 ++---- src/discord/monitor/listeners.ts | 263 ++++++++++++------ src/plugin-sdk/index.ts | 1 + src/security/dm-policy-shared.test.ts | 64 +++++ src/signal/monitor/event-handler.ts | 23 +- 6 files changed, 289 insertions(+), 188 deletions(-) diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 67fb50a78c6..f25e47d50e6 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -7,8 +7,7 @@ import { logTypingFailure, recordPendingHistoryEntryIfEnabled, resolveAckReaction, - resolveDmGroupAccessDecision, - resolveEffectiveAllowFromLists, + resolveDmGroupAccessWithLists, resolveControlCommandGate, stripMarkdown, type HistoryEntry, @@ -504,24 +503,13 @@ export async function processMessage( const storeAllowFrom = await core.channel.pairing .readAllowFromStore("bluebubbles") .catch(() => []); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - dmPolicy, - }); - const groupAllowEntry = formatGroupAllowlistEntry({ - chatGuid: message.chatGuid, - chatId: message.chatId ?? undefined, - chatIdentifier: message.chatIdentifier ?? undefined, - }); - const groupName = message.chatName?.trim() || undefined; - const accessDecision = resolveDmGroupAccessDecision({ + const accessDecision = resolveDmGroupAccessWithLists({ isGroup, dmPolicy, groupPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, isSenderAllowed: (allowFrom) => isAllowedBlueBubblesSender({ allowFrom, @@ -531,6 +519,14 @@ export async function processMessage( chatIdentifier: message.chatIdentifier ?? undefined, }), }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const groupAllowEntry = formatGroupAllowlistEntry({ + chatGuid: message.chatGuid, + chatId: message.chatId ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + const groupName = message.chatName?.trim() || undefined; if (accessDecision.decision !== "allow") { if (isGroup) { @@ -1389,18 +1385,13 @@ export async function processReaction( const storeAllowFrom = await core.channel.pairing .readAllowFromStore("bluebubbles") .catch(() => []); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({ - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - dmPolicy, - }); - const accessDecision = resolveDmGroupAccessDecision({ + const accessDecision = resolveDmGroupAccessWithLists({ isGroup: reaction.isGroup, dmPolicy, groupPolicy, - effectiveAllowFrom, - effectiveGroupAllowFrom, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, isSenderAllowed: (allowFrom) => isAllowedBlueBubblesSender({ allowFrom, diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 6056c3fef15..af8d8e07e60 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -17,6 +17,7 @@ import { recordPendingHistoryEntryIfEnabled, isDangerousNameMatchingEnabled, resolveControlCommandGate, + resolveDmGroupAccessWithLists, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveChannelMediaMaxBytes, @@ -883,68 +884,38 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const kind = channelKind(channelInfo.type); // Enforce DM/group policy and allowlist checks (same as normal messages) - if (kind === "direct") { - const dmPolicy = account.config.dmPolicy ?? "pairing"; - if (dmPolicy === "disabled") { - logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`); - return; - } - // For pairing/allowlist modes, only allow reactions from approved senders - if (dmPolicy !== "open") { - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const storeAllowFrom = normalizeAllowList( - dmPolicy === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), - ); - const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); - const allowed = isSenderAllowed({ + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const storeAllowFrom = normalizeAllowList( + dmPolicy === "allowlist" + ? [] + : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + ); + const reactionAccess = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: account.config.allowFrom, + groupAllowFrom: account.config.groupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isSenderAllowed({ senderId: userId, senderName, - allowFrom: effectiveAllowFrom, + allowFrom: normalizeAllowList(allowFrom), allowNameMatching, - }); - if (!allowed) { - logVerboseMessage( - `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`, - ); - return; - } - } - } else if (kind) { - if (groupPolicy === "disabled") { - logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); - return; - } - if (groupPolicy === "allowlist") { - const dmPolicyForStore = account.config.dmPolicy ?? "pairing"; - const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); - const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); - const storeAllowFrom = normalizeAllowList( - dmPolicyForStore === "allowlist" - ? [] - : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + }), + }); + if (reactionAccess.decision !== "allow") { + if (kind === "direct") { + logVerboseMessage( + `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`, ); - const effectiveGroupAllowFrom = Array.from( - new Set([ - ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), - ...storeAllowFrom, - ]), + } else { + logVerboseMessage( + `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`, ); - // Drop when allowlist is empty (same as normal message handler) - const allowed = - effectiveGroupAllowFrom.length > 0 && - isSenderAllowed({ - senderId: userId, - senderName, - allowFrom: effectiveGroupAllowFrom, - allowNameMatching, - }); - if (!allowed) { - logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); - return; - } } + return; } const teamId = channelInfo?.team_id ?? undefined; diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 002bf62816d..c8af895ad25 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -204,6 +204,99 @@ async function runDiscordReactionHandler(params: { }); } +type DiscordReactionIngressAuthorizationParams = { + user: User; + isDirectMessage: boolean; + isGroupDm: boolean; + isGuildMessage: boolean; + channelId: string; + channelName?: string; + channelSlug: string; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels: string[]; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom: string[]; + groupPolicy: "open" | "allowlist" | "disabled"; + allowNameMatching: boolean; + guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null; + channelConfig?: { allowed?: boolean } | null; +}; + +async function authorizeDiscordReactionIngress( + params: DiscordReactionIngressAuthorizationParams, +): Promise<{ allowed: true } | { allowed: false; reason: string }> { + if (params.isDirectMessage && !params.dmEnabled) { + return { allowed: false, reason: "dm-disabled" }; + } + if (params.isGroupDm && !params.groupDmEnabled) { + return { allowed: false, reason: "group-dm-disabled" }; + } + if (params.isDirectMessage) { + const storeAllowFrom = + params.dmPolicy === "allowlist" + ? [] + : await readChannelAllowFromStore("discord").catch(() => []); + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: [], + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); + const allowMatch = allowList + ? resolveDiscordAllowListMatch({ + allowList, + candidate: { + id: params.user.id, + name: params.user.username, + tag: formatDiscordUserTag(params.user), + }, + allowNameMatching: params.allowNameMatching, + }) + : { allowed: false }; + return allowMatch.allowed; + }, + }); + if (access.decision !== "allow") { + return { allowed: false, reason: access.reason }; + } + } + if ( + params.isGroupDm && + !resolveGroupDmAllow({ + channels: params.groupDmChannels, + channelId: params.channelId, + channelName: params.channelName, + channelSlug: params.channelSlug, + }) + ) { + return { allowed: false, reason: "group-dm-not-allowlisted" }; + } + if (!params.isGuildMessage) { + return { allowed: true }; + } + const channelAllowlistConfigured = + Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0; + const channelAllowed = params.channelConfig?.allowed !== false; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy: params.groupPolicy, + guildAllowlisted: Boolean(params.guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return { allowed: false, reason: "guild-policy" }; + } + if (params.channelConfig?.allowed === false) { + return { allowed: false, reason: "guild-channel-denied" }; + } + return { allowed: true }; +} + async function handleDiscordReactionEvent(params: { data: DiscordReactionEvent; client: Client; @@ -260,10 +353,25 @@ async function handleDiscordReactionEvent(params: { channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread; - if (isDirectMessage && !params.dmEnabled) { - return; - } - if (isGroupDm && !params.groupDmEnabled) { + const ingressAccess = await authorizeDiscordReactionIngress({ + user, + isDirectMessage, + isGroupDm, + isGuildMessage, + channelId: data.channel_id, + channelName, + channelSlug, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + groupPolicy: params.groupPolicy, + allowNameMatching: params.allowNameMatching, + guildInfo, + }); + if (!ingressAccess.allowed) { + logVerbose(`discord reaction blocked sender=${user.id} (reason=${ingressAccess.reason})`); return; } let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; @@ -294,45 +402,6 @@ async function handleDiscordReactionEvent(params: { reactionBase = { baseText, contextKey }; return reactionBase; }; - const isDirectReactionAuthorized = async () => { - if (!isDirectMessage) { - return true; - } - const storeAllowFrom = - params.dmPolicy === "allowlist" - ? [] - : await readChannelAllowFromStore("discord").catch(() => []); - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: params.dmPolicy, - groupPolicy: params.groupPolicy, - allowFrom: params.allowFrom, - groupAllowFrom: [], - storeAllowFrom, - isSenderAllowed: (allowEntries) => { - const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]); - const allowMatch = allowList - ? resolveDiscordAllowListMatch({ - allowList, - candidate: { - id: user.id, - name: user.username, - tag: formatDiscordUserTag(user), - }, - allowNameMatching: params.allowNameMatching, - }) - : { allowed: false }; - return allowMatch.allowed; - }, - }); - if (access.decision !== "allow") { - logVerbose( - `discord reaction blocked sender=${user.id} (dmPolicy=${params.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, - ); - return false; - } - return true; - }; const emitReaction = (text: string, parentPeerId?: string) => { const { contextKey } = resolveReactionBase(); const route = resolveAgentRoute({ @@ -391,44 +460,6 @@ async function handleDiscordReactionEvent(params: { parentSlug, scope: "thread", }); - const isGuildReactionAllowed = (channelConfig: { allowed?: boolean } | null) => { - if (!isGuildMessage) { - return true; - } - const channelAllowlistConfigured = - Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; - const channelAllowed = channelConfig?.allowed !== false; - if ( - !isDiscordGroupAllowedByPolicy({ - groupPolicy: params.groupPolicy, - guildAllowlisted: Boolean(guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) - ) { - return false; - } - if (channelConfig?.allowed === false) { - return false; - } - return true; - }; - - if (!(await isDirectReactionAuthorized())) { - return; - } - - if ( - isGroupDm && - !resolveGroupDmAllow({ - channels: params.groupDmChannels, - channelId: data.channel_id, - channelName, - channelSlug, - }) - ) { - return; - } // Parallelize async operations for thread channels if (isThreadChannel) { @@ -450,7 +481,25 @@ async function handleDiscordReactionEvent(params: { await loadThreadParentInfo(); const channelConfig = resolveThreadChannelConfig(); - if (channelConfig?.allowed === false) { + const threadAccess = await authorizeDiscordReactionIngress({ + user, + isDirectMessage, + isGroupDm, + isGuildMessage, + channelId: data.channel_id, + channelName, + channelSlug, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + groupPolicy: params.groupPolicy, + allowNameMatching: params.allowNameMatching, + guildInfo, + channelConfig, + }); + if (!threadAccess.allowed) { return; } @@ -474,10 +523,25 @@ async function handleDiscordReactionEvent(params: { await loadThreadParentInfo(); const channelConfig = resolveThreadChannelConfig(); - if (channelConfig?.allowed === false) { - return; - } - if (!isGuildReactionAllowed(channelConfig)) { + const threadAccess = await authorizeDiscordReactionIngress({ + user, + isDirectMessage, + isGroupDm, + isGuildMessage, + channelId: data.channel_id, + channelName, + channelSlug, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + groupPolicy: params.groupPolicy, + allowNameMatching: params.allowNameMatching, + guildInfo, + channelConfig, + }); + if (!threadAccess.allowed) { return; } @@ -501,11 +565,28 @@ async function handleDiscordReactionEvent(params: { parentSlug, scope: "channel", }); - if (channelConfig?.allowed === false) { - return; - } - if (!isGuildReactionAllowed(channelConfig)) { - return; + if (isGuildMessage) { + const channelAccess = await authorizeDiscordReactionIngress({ + user, + isDirectMessage, + isGroupDm, + isGuildMessage, + channelId: data.channel_id, + channelName, + channelSlug, + dmEnabled: params.dmEnabled, + groupDmEnabled: params.groupDmEnabled, + groupDmChannels: params.groupDmChannels, + dmPolicy: params.dmPolicy, + allowFrom: params.allowFrom, + groupPolicy: params.groupPolicy, + allowNameMatching: params.allowNameMatching, + guildInfo, + channelConfig, + }); + if (!channelAccess.allowed) { + return; + } } const reactionMode = guildInfo?.reactionNotifications ?? "own"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7faa2341dc0..6e25d50740b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -375,6 +375,7 @@ export { formatDocsLink } from "../terminal/links.js"; export { resolveDmAllowState, resolveDmGroupAccessDecision, + resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "../security/dm-policy-shared.js"; export type { HookEntry } from "../hooks/types.js"; diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index 1fe36976a55..735b7d8728d 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -118,6 +118,70 @@ describe("security/dm-policy-shared", () => { "zalo", ] as const; + it("keeps message/reaction policy parity table across channels", () => { + const cases = [ + { + name: "dmPolicy=open", + dmPolicy: "open" as const, + allowFrom: [] as string[], + senderAllowed: false, + expectedDecision: "allow" as const, + expectedReactionAllowed: true, + }, + { + name: "dmPolicy=disabled", + dmPolicy: "disabled" as const, + allowFrom: [] as string[], + senderAllowed: false, + expectedDecision: "block" as const, + expectedReactionAllowed: false, + }, + { + name: "dmPolicy=allowlist unauthorized", + dmPolicy: "allowlist" as const, + allowFrom: ["owner"], + senderAllowed: false, + expectedDecision: "block" as const, + expectedReactionAllowed: false, + }, + { + name: "dmPolicy=allowlist authorized", + dmPolicy: "allowlist" as const, + allowFrom: ["owner"], + senderAllowed: true, + expectedDecision: "allow" as const, + expectedReactionAllowed: true, + }, + { + name: "dmPolicy=pairing unauthorized", + dmPolicy: "pairing" as const, + allowFrom: [] as string[], + senderAllowed: false, + expectedDecision: "pairing" as const, + expectedReactionAllowed: false, + }, + ]; + + for (const channel of channels) { + for (const testCase of cases) { + const access = resolveDmGroupAccessWithLists({ + isGroup: false, + dmPolicy: testCase.dmPolicy, + groupPolicy: "allowlist", + allowFrom: testCase.allowFrom, + groupAllowFrom: [], + storeAllowFrom: [], + isSenderAllowed: () => testCase.senderAllowed, + }); + const reactionAllowed = access.decision === "allow"; + expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision); + expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe( + testCase.expectedReactionAllowed, + ); + } + } + }); + for (const channel of channels) { it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => { const decision = resolveDmGroupAccessDecision({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index e87158d8d8d..ea9fa9f49d6 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -36,10 +36,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { - resolveDmGroupAccessDecision, - resolveEffectiveAllowFromLists, -} from "../../security/dm-policy-shared.js"; +import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js"; import { normalizeE164 } from "../../utils.js"; import { formatSignalPairingIdLine, @@ -460,23 +457,19 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { deps.dmPolicy === "allowlist" ? [] : await readChannelAllowFromStore("signal").catch(() => []); - const { effectiveAllowFrom: effectiveDmAllow, effectiveGroupAllowFrom: effectiveGroupAllow } = - resolveEffectiveAllowFromLists({ - allowFrom: deps.allowFrom, - groupAllowFrom: deps.groupAllowFrom, - storeAllowFrom, - dmPolicy: deps.dmPolicy, - }); const resolveAccessDecision = (isGroup: boolean) => - resolveDmGroupAccessDecision({ + resolveDmGroupAccessWithLists({ isGroup, dmPolicy: deps.dmPolicy, groupPolicy: deps.groupPolicy, - effectiveAllowFrom: effectiveDmAllow, - effectiveGroupAllowFrom: effectiveGroupAllow, - isSenderAllowed: (allowFrom) => isSignalSenderAllowed(sender, allowFrom), + allowFrom: deps.allowFrom, + groupAllowFrom: deps.groupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => isSignalSenderAllowed(sender, allowEntries), }); const dmAccess = resolveAccessDecision(false); + const effectiveDmAllow = dmAccess.effectiveAllowFrom; + const effectiveGroupAllow = dmAccess.effectiveGroupAllowFrom; const dmAllowed = dmAccess.decision === "allow"; if ( From 20c2db21035e8706ccbd63d553e8042c19bbf8f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:36:52 +0100 Subject: [PATCH 32/95] refactor(gateway): split browser auth hardening paths --- .../server.auth.browser-hardening.test.ts | 155 ++++++++++++++++++ src/gateway/server.auth.test.ts | 69 -------- src/gateway/server.impl.ts | 27 ++- .../server/ws-connection/message-handler.ts | 78 +++++++-- 4 files changed, 240 insertions(+), 89 deletions(-) create mode 100644 src/gateway/server.auth.browser-hardening.test.ts diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts new file mode 100644 index 00000000000..070addbdc53 --- /dev/null +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; +import { + connectReq, + installGatewayTestHooks, + readConnectChallengeNonce, + testState, + trackConnectChallengeNonce, + withGatewayServer, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const TEST_OPERATOR_CLIENT = { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, +}; + +const originForPort = (port: number) => `http://127.0.0.1:${port}`; + +const openWs = async (port: number, headers?: Record) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); + trackConnectChallengeNonce(ws); + await new Promise((resolve) => ws.once("open", resolve)); + return ws; +}; + +async function createSignedDevice(params: { + token: string; + scopes: string[]; + clientId: string; + clientMode: string; + identityPath?: string; + nonce: string; + signedAtMs?: number; +}) { + const identity = params.identityPath + ? loadOrCreateDeviceIdentity(params.identityPath) + : loadOrCreateDeviceIdentity(); + const signedAtMs = params.signedAtMs ?? Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: params.clientId, + clientMode: params.clientMode, + role: "operator", + scopes: params.scopes, + signedAtMs, + token: params.token, + nonce: params.nonce, + }); + return { + identity, + device: { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: params.nonce, + }, + }; +} + +describe("gateway auth browser hardening", () => { + test("rejects non-local browser origins for non-control-ui clients", async () => { + testState.gatewayAuth = { mode: "token", token: "secret" }; + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin: "https://attacker.example" }); + try { + const res = await connectReq(ws, { + token: "secret", + client: TEST_OPERATOR_CLIENT, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("origin not allowed"); + } finally { + ws.close(); + } + }); + }); + + test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => { + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, + }; + await withGatewayServer(async ({ port }) => { + const firstWs = await openWs(port, { origin: originForPort(port) }); + try { + const first = await connectReq(firstWs, { token: "wrong" }); + expect(first.ok).toBe(false); + expect(first.error?.message ?? "").not.toContain("retry later"); + } finally { + firstWs.close(); + } + + const secondWs = await openWs(port, { origin: originForPort(port) }); + try { + const second = await connectReq(secondWs, { token: "wrong" }); + expect(second.ok).toBe(false); + expect(second.error?.message ?? "").toContain("retry later"); + } finally { + secondWs.close(); + } + }); + }); + + test("does not silently auto-pair non-control-ui browser clients on loopback", async () => { + const { listDevicePairing } = await import("../infra/device-pairing.js"); + testState.gatewayAuth = { mode: "token", token: "secret" }; + + await withGatewayServer(async ({ port }) => { + const browserWs = await openWs(port, { origin: originForPort(port) }); + try { + const nonce = await readConnectChallengeNonce(browserWs); + expect(typeof nonce).toBe("string"); + const { identity, device } = await createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`), + nonce: String(nonce ?? ""), + }); + const res = await connectReq(browserWs, { + token: "secret", + scopes: ["operator.admin"], + client: TEST_OPERATOR_CLIENT, + device, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + + const pairing = await listDevicePairing(); + const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); + expect(pending).toBeTruthy(); + expect(pending?.silent).toBe(false); + } finally { + browserWs.close(); + } + }); + }); +}); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 38668de7f40..83a97644d19 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -672,17 +672,6 @@ describe("gateway server auth/connect", () => { ws.close(); }); - test("rejects non-local browser origins for non-control-ui clients", async () => { - const ws = await openWs(port, { origin: "https://attacker.example" }); - const res = await connectReq(ws, { - token: "secret", - client: TEST_OPERATOR_CLIENT, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("origin not allowed"); - ws.close(); - }); - test("returns control ui hint when token is missing", async () => { const ws = await openWs(port, { origin: originForPort(port) }); const res = await connectReq(ws, { @@ -712,27 +701,6 @@ describe("gateway server auth/connect", () => { ); ws.close(); }); - - test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => { - testState.gatewayAuth = { - mode: "token", - token: "secret", - rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, - }; - await withGatewayServer(async ({ port }) => { - const firstWs = await openWs(port, { origin: originForPort(port) }); - const first = await connectReq(firstWs, { token: "wrong" }); - expect(first.ok).toBe(false); - expect(first.error?.message ?? "").not.toContain("retry later"); - firstWs.close(); - - const secondWs = await openWs(port, { origin: originForPort(port) }); - const second = await connectReq(secondWs, { token: "wrong" }); - expect(second.ok).toBe(false); - expect(second.error?.message ?? "").toContain("retry later"); - secondWs.close(); - }); - }); }); describe("explicit none auth", () => { @@ -1246,43 +1214,6 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); - test("does not silently auto-pair non-control-ui browser clients on loopback", async () => { - const { listDevicePairing } = await import("../infra/device-pairing.js"); - const { randomUUID } = await import("node:crypto"); - const os = await import("node:os"); - const path = await import("node:path"); - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - ws.close(); - - const browserWs = await openWs(port, { origin: originForPort(port) }); - const nonce = await readConnectChallengeNonce(browserWs); - const { identity, device } = await createSignedDevice({ - token: "secret", - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`), - nonce, - }); - const res = await connectReq(browserWs, { - token: "secret", - scopes: ["operator.admin"], - client: TEST_OPERATOR_CLIENT, - device, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - - const pairing = await listDevicePairing(); - const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); - expect(pending).toBeTruthy(); - expect(pending?.silent).toBe(false); - - browserWs.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - test("merges remote node/operator pairing requests for the same unpaired device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8b368539469..3dbd86e1e5e 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -110,6 +110,21 @@ const logWsControl = log.child("ws"); const gatewayRuntime = runtimeForLogger(log); const canvasRuntime = runtimeForLogger(logCanvas); +type AuthRateLimitConfig = Parameters[0]; + +function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): { + rateLimiter?: AuthRateLimiter; + browserRateLimiter: AuthRateLimiter; +} { + const rateLimiter = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined; + // Browser-origin WS auth attempts always use loopback-non-exempt throttling. + const browserRateLimiter = createAuthRateLimiter({ + ...rateLimitConfig, + exemptLoopback: false, + }); + return { rateLimiter, browserRateLimiter }; +} + export type GatewayServer = { close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise; }; @@ -311,16 +326,10 @@ export async function startGatewayServer( let hooksConfig = runtimeConfig.hooksConfig; const canvasHostEnabled = runtimeConfig.canvasHostEnabled; - // Create auth rate limiter only when explicitly configured. + // Create auth rate limiters used by connect/auth flows. const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit; - const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig - ? createAuthRateLimiter(rateLimitConfig) - : undefined; - // Always keep a browser-origin fallback limiter for WS auth attempts. - const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({ - ...rateLimitConfig, - exemptLoopback: false, - }); + const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } = + createGatewayAuthRateLimiters(rateLimitConfig); let controlUiRootState: ControlUiRootState | undefined; if (controlUiRootOverride) { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0d694d12529..8feb385e8f9 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -83,6 +83,52 @@ import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized- type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000; +const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1"; + +type HandshakeBrowserSecurityContext = { + hasBrowserOriginHeader: boolean; + enforceOriginCheckForAnyClient: boolean; + rateLimitClientIp: string | undefined; + authRateLimiter?: AuthRateLimiter; +}; + +function resolveHandshakeBrowserSecurityContext(params: { + requestOrigin?: string; + hasProxyHeaders: boolean; + clientIp: string | undefined; + rateLimiter?: AuthRateLimiter; + browserRateLimiter?: AuthRateLimiter; +}): HandshakeBrowserSecurityContext { + const hasBrowserOriginHeader = Boolean( + params.requestOrigin && params.requestOrigin.trim() !== "", + ); + return { + hasBrowserOriginHeader, + enforceOriginCheckForAnyClient: hasBrowserOriginHeader && !params.hasProxyHeaders, + rateLimitClientIp: + hasBrowserOriginHeader && isLoopbackAddress(params.clientIp) + ? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP + : params.clientIp, + authRateLimiter: + hasBrowserOriginHeader && params.browserRateLimiter + ? params.browserRateLimiter + : params.rateLimiter, + }; +} + +function shouldAllowSilentLocalPairing(params: { + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + isControlUi: boolean; + isWebchat: boolean; + reason: "not-paired" | "role-upgrade" | "scope-upgrade"; +}): boolean { + return ( + params.isLocalClient && + (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) && + (params.reason === "not-paired" || params.reason === "scope-upgrade") + ); +} export function attachGatewayWsMessageHandler(params: { socket: WebSocket; @@ -195,12 +241,19 @@ export function attachGatewayWsMessageHandler(params: { const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); const unauthorizedFloodGuard = new UnauthorizedFloodGuard(); - const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== ""); - const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders; - const browserRateLimitClientIp = - hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp; - const authRateLimiter = - hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter; + const browserSecurity = resolveHandshakeBrowserSecurityContext({ + requestOrigin, + hasProxyHeaders, + clientIp, + rateLimiter, + browserRateLimiter, + }); + const { + hasBrowserOriginHeader, + enforceOriginCheckForAnyClient, + rateLimitClientIp: browserRateLimitClientIp, + authRateLimiter, + } = browserSecurity; socket.on("message", async (data) => { if (isClosed()) { @@ -338,7 +391,7 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const isWebchat = isWebchatConnect(connectParams); - if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) { + if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) { const originCheck = checkBrowserOrigin({ requestHost, origin: requestOrigin, @@ -622,10 +675,13 @@ export function attachGatewayWsMessageHandler(params: { const requirePairing = async ( reason: "not-paired" | "role-upgrade" | "scope-upgrade", ) => { - const allowSilentLocalPairing = - isLocalClient && - (!hasBrowserOriginHeader || isControlUi || isWebchat) && - (reason === "not-paired" || reason === "scope-upgrade"); + const allowSilentLocalPairing = shouldAllowSilentLocalPairing({ + isLocalClient, + hasBrowserOriginHeader, + isControlUi, + isWebchat, + reason, + }); const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, From aaeed3c4ea2a166dc31bf7a67c40425ac27a9bfe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:38:24 +0000 Subject: [PATCH 33/95] test(agents): add missing announce delivery regressions --- src/agents/subagent-announce.format.test.ts | 41 ++++++++++ src/gateway/server-methods/send.test.ts | 74 ++++++++++++++++++- src/infra/outbound/channel-resolution.ts | 13 +++- .../targets.channel-resolution.test.ts | 44 ++++++++++- src/telegram/send.test.ts | 31 ++++++++ 5 files changed, 197 insertions(+), 6 deletions(-) diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index 91f4b0d6752..8952e82cc68 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -825,6 +825,47 @@ describe("subagent announce formatting", () => { } }); + it("routes manual completion direct-send for telegram forum topics", async () => { + sendSpy.mockClear(); + agentSpy.mockClear(); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-telegram-topic", + }, + "agent:main:main": { + sessionId: "requester-session-telegram-topic", + lastChannel: "telegram", + lastTo: "123:topic:999", + lastThreadId: 999, + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-telegram-topic", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "telegram", + to: "123", + threadId: 42, + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("telegram"); + expect(call?.params?.to).toBe("123"); + expect(call?.params?.threadId).toBe("42"); + }); + it("uses hook-provided thread target across requester thread variants", async () => { const cases = [ { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7734de8e911..0e3bfba668c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { sendHandlers } from "./send.js"; import type { GatewayRequestContext } from "./types.js"; @@ -10,6 +12,8 @@ const mocks = vi.hoisted(() => ({ resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), resolveMessageChannelSelection: vi.fn(), sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), + getChannelPlugin: vi.fn(), + loadOpenClawPlugins: vi.fn(), })); vi.mock("../../config/config.js", async () => { @@ -22,10 +26,38 @@ vi.mock("../../config/config.js", async () => { }); vi.mock("../../channels/plugins/index.js", () => ({ - getChannelPlugin: () => ({ outbound: { sendPoll: mocks.sendPoll } }), + getChannelPlugin: mocks.getChannelPlugin, normalizeChannelId: (value: string) => (value === "webchat" ? null : value), })); +vi.mock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentId: ({ + sessionKey, + }: { + sessionKey?: string; + config?: unknown; + agentId?: string; + }) => { + if (typeof sessionKey === "string") { + const match = sessionKey.match(/^agent:([^:]+)/i); + if (match?.[1]) { + return match[1]; + } + } + return "main"; + }, + resolveDefaultAgentId: () => "main", + resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + vi.mock("../../infra/outbound/targets.js", () => ({ resolveOutboundTarget: mocks.resolveOutboundTarget, })); @@ -85,14 +117,19 @@ function mockDeliverySuccess(messageId: string) { } describe("gateway send mirroring", () => { + let registrySeq = 0; + beforeEach(() => { vi.clearAllMocks(); + registrySeq += 1; + setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`); mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" }); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "slack", configured: ["slack"], }); mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" }); + mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } }); }); it("accepts media-only sends without message", async () => { @@ -475,4 +512,39 @@ describe("gateway send mirroring", () => { }), ); }); + + it("recovers cold plugin resolution for telegram threaded sends", async () => { + mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" }); + mocks.deliverOutboundPayloads.mockResolvedValue([ + { messageId: "m-telegram", channel: "telegram" }, + ]); + const telegramPlugin = { outbound: { sendPoll: mocks.sendPoll } }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + + const { respond } = await runSend({ + to: "123", + message: "forum completion", + channel: "telegram", + threadId: "42", + idempotencyKey: "idem-cold-telegram-thread", + }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + threadId: "42", + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ messageId: "m-telegram" }), + undefined, + expect.objectContaining({ channel: "telegram" }), + ); + }); }); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 58596da93f3..8d17294d024 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -47,10 +47,15 @@ function maybeBootstrapChannelPlugin(params: { const autoEnabled = applyPluginAutoEnable({ config: cfg }).config; const defaultAgentId = resolveDefaultAgentId(autoEnabled); const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId); - loadOpenClawPlugins({ - config: autoEnabled, - workspaceDir, - }); + try { + loadOpenClawPlugins({ + config: autoEnabled, + workspaceDir, + }); + } catch { + // Allow a follow-up resolution attempt if bootstrap failed transiently. + bootstrapAttempts.delete(attemptKey); + } } export function resolveOutboundChannelPlugin(params: { diff --git a/src/infra/outbound/targets.channel-resolution.test.ts b/src/infra/outbound/targets.channel-resolution.test.ts index 615134f654b..01779d0655c 100644 --- a/src/infra/outbound/targets.channel-resolution.test.ts +++ b/src/infra/outbound/targets.channel-resolution.test.ts @@ -28,8 +28,11 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolveOutboundTarget } from "./targets.js"; describe("resolveOutboundTarget channel resolution", () => { + let registrySeq = 0; + beforeEach(() => { - setActivePluginRegistry(createTestRegistry([])); + registrySeq += 1; + setActivePluginRegistry(createTestRegistry([]), `targets-test-${registrySeq}`); mocks.getChannelPlugin.mockReset(); mocks.loadOpenClawPlugins.mockReset(); }); @@ -58,4 +61,43 @@ describe("resolveOutboundTarget channel resolution", () => { expect(result).toEqual({ ok: true, to: "123456" }); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); }); + + it("retries bootstrap on subsequent resolve when the first bootstrap attempt fails", () => { + const telegramPlugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + mocks.loadOpenClawPlugins + .mockImplementationOnce(() => { + throw new Error("bootstrap failed"); + }) + .mockImplementation(() => undefined); + + const first = resolveOutboundTarget({ + channel: "telegram", + to: "123456", + cfg: { channels: { telegram: { botToken: "test-token" } } }, + mode: "explicit", + }); + const second = resolveOutboundTarget({ + channel: "telegram", + to: "123456", + cfg: { channels: { telegram: { botToken: "test-token" } } }, + mode: "explicit", + }); + + expect(first.ok).toBe(false); + expect(second).toEqual({ ok: true, to: "123456" }); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index afd616b5f15..b589fdcf52b 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1280,6 +1280,23 @@ describe("sendStickerTelegram", () => { expect(sendSticker).toHaveBeenNthCalledWith(2, chatId, "fileId123", undefined); expect(res.messageId).toBe("109"); }); + + it("fails when sticker send returns no message_id", async () => { + const chatId = "123"; + const sendSticker = vi.fn().mockResolvedValue({ + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await expect( + sendStickerTelegram(chatId, "fileId123", { + token: "tok", + api, + }), + ).rejects.toThrow(/returned no message_id/i); + }); }); describe("shared send behaviors", () => { @@ -1542,6 +1559,20 @@ describe("sendPollTelegram", () => { expect(api.sendPoll).not.toHaveBeenCalled(); }); + + it("fails when poll send returns no message_id", async () => { + const api = { + sendPoll: vi.fn(async () => ({ chat: { id: 555 }, poll: { id: "p1" } })), + }; + + await expect( + sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"] }, + { token: "t", api: api as unknown as Bot["api"] }, + ), + ).rejects.toThrow(/returned no message_id/i); + }); }); describe("createForumTopicTelegram", () => { From f312222159a6ef4921c4d3bd5c8e4b2bc21d2a23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 00:42:51 +0000 Subject: [PATCH 34/95] test: preserve config exports in agent handler mock --- src/gateway/server-methods/agent.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 5d65d262735..dbf21b0efbf 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -43,9 +43,14 @@ vi.mock("../../commands/agent.js", () => ({ agentCommand: mocks.agentCommand, })); -vi.mock("../../config/config.js", () => ({ - loadConfig: () => mocks.loadConfigReturn, -})); +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + loadConfig: () => mocks.loadConfigReturn, + }; +}); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], From c0026274d9cafcfaf5c84bf4b6a5386755099b30 Mon Sep 17 00:00:00 2001 From: Aleksandrs Tihenko <87486610+rrenamed@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:47:16 +0200 Subject: [PATCH 35/95] fix(auth): distinguish revoked API keys from transient auth errors (#25754) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 8f9c07a200644284e11adae76368adab40c5fa4e Co-authored-by: rrenamed <87486610+rrenamed@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + ...th-profiles.markauthprofilefailure.test.ts | 16 +++++++ src/agents/auth-profiles/types.ts | 1 + src/agents/auth-profiles/usage.test.ts | 45 ++++++++++++++++++- src/agents/auth-profiles/usage.ts | 12 ++--- src/agents/failover-error.test.ts | 31 +++++++++++++ src/agents/failover-error.ts | 12 ++++- ...dded-helpers.isbillingerrormessage.test.ts | 40 +++++++++++++++++ src/agents/pi-embedded-helpers.ts | 1 + src/agents/pi-embedded-helpers/errors.ts | 15 +++++++ src/agents/pi-embedded-helpers/types.ts | 1 + src/commands/doctor-auth.hints.test.ts | 28 ++++++++++++ src/commands/doctor-auth.ts | 30 ++++++++++--- src/commands/models/list.probe.test.ts | 22 +++++++++ src/commands/models/list.probe.ts | 10 +++-- 15 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 src/commands/doctor-auth.hints.test.ts create mode 100644 src/commands/models/list.probe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d71991f6a01..f4ff72155d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. +- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index 1a30d8a9119..865fbf87816 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -114,6 +114,22 @@ describe("markAuthProfileFailure", () => { expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil); }); }); + it("disables auth_permanent failures via disabledUntil (like billing)", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "auth_permanent", + agentDir, + }); + + const stats = store.usageStats?.["anthropic:default"]; + expect(typeof stats?.disabledUntil).toBe("number"); + expect(stats?.disabledReason).toBe("auth_permanent"); + // Should NOT set cooldownUntil (that's for transient errors) + expect(stats?.cooldownUntil).toBeUndefined(); + }); + }); it("resets backoff counters outside the failure window", async () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); try { diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 7332d304812..c23e6aa404d 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -34,6 +34,7 @@ export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCr export type AuthProfileFailureReason = | "auth" + | "auth_permanent" | "format" | "rate_limit" | "billing" diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 0025007f729..8c499654b49 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -141,6 +141,24 @@ describe("resolveProfilesUnavailableReason", () => { ).toBe("billing"); }); + it("returns auth_permanent for active permanent auth disables", () => { + const now = Date.now(); + const store = makeStore({ + "anthropic:default": { + disabledUntil: now + 60_000, + disabledReason: "auth_permanent", + }, + }); + + expect( + resolveProfilesUnavailableReason({ + store, + profileIds: ["anthropic:default"], + now, + }), + ).toBe("auth_permanent"); + }); + it("uses recorded non-rate-limit failure counts for active cooldown windows", () => { const now = Date.now(); const store = makeStore({ @@ -490,7 +508,7 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () async function markFailureAt(params: { store: ReturnType; now: number; - reason: "rate_limit" | "billing"; + reason: "rate_limit" | "billing" | "auth_permanent"; }): Promise { vi.useFakeTimers(); vi.setSystemTime(params.now); @@ -528,6 +546,18 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () }), readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, + { + label: "disabledUntil(auth_permanent)", + reason: "auth_permanent" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now + 20 * 60 * 60 * 1000, + disabledReason: "auth_permanent", + errorCount: 5, + failureCounts: { auth_permanent: 5 }, + lastFailureAt: now - 60_000, + }), + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, ]; for (const testCase of activeWindowCases) { @@ -573,6 +603,19 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, + { + label: "disabledUntil(auth_permanent)", + reason: "auth_permanent" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now - 60_000, + disabledReason: "auth_permanent", + errorCount: 5, + failureCounts: { auth_permanent: 2 }, + lastFailureAt: now - 60_000, + }), + expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, ]; for (const testCase of expiredWindowCases) { diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 958e3ae127e..60c43c9c3c8 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -4,6 +4,7 @@ import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [ + "auth_permanent", "auth", "billing", "format", @@ -394,8 +395,8 @@ function computeNextProfileUsageStats(params: { lastFailureAt: params.now, }; - if (params.reason === "billing") { - const billingCount = failureCounts.billing ?? 1; + if (params.reason === "billing" || params.reason === "auth_permanent") { + const billingCount = failureCounts[params.reason] ?? 1; const backoffMs = calculateAuthProfileBillingDisableMsWithConfig({ errorCount: billingCount, baseMs: params.cfgResolved.billingBackoffMs, @@ -408,7 +409,7 @@ function computeNextProfileUsageStats(params: { now: params.now, recomputedUntil: params.now + backoffMs, }); - updatedStats.disabledReason = "billing"; + updatedStats.disabledReason = params.reason; } else { const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount); // Keep active cooldown windows immutable so retries within the window @@ -424,8 +425,9 @@ function computeNextProfileUsageStats(params: { } /** - * Mark a profile as failed for a specific reason. Billing failures are treated - * as "disabled" (longer backoff) vs the regular cooldown window. + * Mark a profile as failed for a specific reason. Billing and permanent-auth + * failures are treated as "disabled" (longer backoff) vs the regular cooldown + * window. */ export async function markAuthProfileFailure(params: { store: AuthProfileStore; diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index d7c1edccbe1..8b2cb846298 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -4,6 +4,7 @@ import { describeFailoverError, isTimeoutError, resolveFailoverReasonFromError, + resolveFailoverStatus, } from "./failover-error.js"; describe("failover-error", () => { @@ -69,6 +70,36 @@ describe("failover-error", () => { expect(err?.status).toBe(400); }); + it("401/403 with generic message still returns auth (backward compat)", () => { + expect(resolveFailoverReasonFromError({ status: 401, message: "Unauthorized" })).toBe("auth"); + expect(resolveFailoverReasonFromError({ status: 403, message: "Forbidden" })).toBe("auth"); + }); + + it("401 with permanent auth message returns auth_permanent", () => { + expect(resolveFailoverReasonFromError({ status: 401, message: "invalid_api_key" })).toBe( + "auth_permanent", + ); + }); + + it("403 with revoked key message returns auth_permanent", () => { + expect(resolveFailoverReasonFromError({ status: 403, message: "api key revoked" })).toBe( + "auth_permanent", + ); + }); + + it("resolveFailoverStatus maps auth_permanent to 403", () => { + expect(resolveFailoverStatus("auth_permanent")).toBe(403); + }); + + it("coerces permanent auth error with correct reason", () => { + const err = coerceToFailoverError( + { status: 401, message: "invalid_api_key" }, + { provider: "anthropic", model: "claude-opus-4-6" }, + ); + expect(err?.reason).toBe("auth_permanent"); + expect(err?.provider).toBe("anthropic"); + }); + it("describes non-Error values consistently", () => { const described = describeFailoverError(123); expect(described.message).toBe("123"); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 4de2babde4d..708af55e322 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -1,4 +1,8 @@ -import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js"; +import { + classifyFailoverReason, + isAuthPermanentErrorMessage, + type FailoverReason, +} from "./pi-embedded-helpers.js"; const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded|stop reason:\s*abort|reason:\s*abort|unhandled stop reason:\s*abort/i; @@ -47,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine return 429; case "auth": return 401; + case "auth_permanent": + return 403; case "timeout": return 408; case "format": @@ -158,6 +164,10 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return "rate_limit"; } if (status === 401 || status === 403) { + const msg = getErrorMessage(err); + if (msg && isAuthPermanentErrorMessage(msg)) { + return "auth_permanent"; + } return "auth"; } if (status === 408) { diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 638b6c24bb8..a109af6d89f 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyFailoverReason, isAuthErrorMessage, + isAuthPermanentErrorMessage, isBillingErrorMessage, isCloudCodeAssistFormatError, isCloudflareOrHtmlErrorPage, @@ -16,6 +17,39 @@ import { parseImageSizeError, } from "./pi-embedded-helpers.js"; +describe("isAuthPermanentErrorMessage", () => { + it("matches permanent auth failure patterns", () => { + const samples = [ + "invalid_api_key", + "api key revoked", + "api key deactivated", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + "could not authenticate api key", + "could not validate credentials", + "API_KEY_REVOKED", + "api_key_deleted", + ]; + for (const sample of samples) { + expect(isAuthPermanentErrorMessage(sample)).toBe(true); + } + }); + it("does not match transient auth errors", () => { + const samples = [ + "unauthorized", + "invalid token", + "authentication failed", + "forbidden", + "access denied", + "token has expired", + ]; + for (const sample of samples) { + expect(isAuthPermanentErrorMessage(sample)).toBe(false); + } + }); +}); + describe("isAuthErrorMessage", () => { it("matches credential validation errors", () => { const samples = [ @@ -480,6 +514,12 @@ describe("classifyFailoverReason", () => { ), ).toBe("rate_limit"); }); + it("classifies permanent auth errors as auth_permanent", () => { + expect(classifyFailoverReason("invalid_api_key")).toBe("auth_permanent"); + expect(classifyFailoverReason("Your api key has been revoked")).toBe("auth_permanent"); + expect(classifyFailoverReason("key has been disabled")).toBe("auth_permanent"); + expect(classifyFailoverReason("account has been deactivated")).toBe("auth_permanent"); + }); it("classifies JSON api_error internal server failures as timeout", () => { expect( classifyFailoverReason( diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 06bf2b1938b..dd10fdca3d1 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -16,6 +16,7 @@ export { getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, + isAuthPermanentErrorMessage, isModelNotFoundErrorMessage, isBillingAssistantError, parseApiErrorInfo, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6eea521ede1..246f6c0ad24 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -649,6 +649,14 @@ const ERROR_PATTERNS = { "plans & billing", "insufficient balance", ], + authPermanent: [ + /api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i, + "invalid_api_key", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + /could not (?:authenticate|validate).*(?:api[_ ]?key|credentials)/i, + ], auth: [ /invalid[_ ]?api[_ ]?key/, "incorrect api key", @@ -755,6 +763,10 @@ export function isBillingAssistantError(msg: AssistantMessage | undefined): bool return isBillingErrorMessage(msg.errorMessage ?? ""); } +export function isAuthPermanentErrorMessage(raw: string): boolean { + return matchesErrorPatterns(raw, ERROR_PATTERNS.authPermanent); +} + export function isAuthErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.auth); } @@ -899,6 +911,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isTimeoutErrorMessage(raw)) { return "timeout"; } + if (isAuthPermanentErrorMessage(raw)) { + return "auth_permanent"; + } if (isAuthErrorMessage(raw)) { return "auth"; } diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/pi-embedded-helpers/types.ts index 2753e979eb2..2440473d9f6 100644 --- a/src/agents/pi-embedded-helpers/types.ts +++ b/src/agents/pi-embedded-helpers/types.ts @@ -2,6 +2,7 @@ export type EmbeddedContextFile = { path: string; content: string }; export type FailoverReason = | "auth" + | "auth_permanent" | "format" | "rate_limit" | "billing" diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts new file mode 100644 index 00000000000..f660a4e82a2 --- /dev/null +++ b/src/commands/doctor-auth.hints.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveUnusableProfileHint } from "./doctor-auth.js"; + +describe("resolveUnusableProfileHint", () => { + it("returns billing guidance for disabled billing profiles", () => { + expect(resolveUnusableProfileHint({ kind: "disabled", reason: "billing" })).toBe( + "Top up credits (provider billing) or switch provider.", + ); + }); + + it("returns credential guidance for permanent auth disables", () => { + expect(resolveUnusableProfileHint({ kind: "disabled", reason: "auth_permanent" })).toBe( + "Refresh or replace credentials, then retry.", + ); + }); + + it("falls back to cooldown guidance for non-billing disable reasons", () => { + expect(resolveUnusableProfileHint({ kind: "disabled", reason: "unknown" })).toBe( + "Wait for cooldown or switch provider.", + ); + }); + + it("returns cooldown guidance for cooldown windows", () => { + expect(resolveUnusableProfileHint({ kind: "cooldown" })).toBe( + "Wait for cooldown or switch provider.", + ); + }); +}); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index a12ab384a20..f408dc43f93 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -206,6 +206,21 @@ type AuthIssue = { remainingMs?: number; }; +export function resolveUnusableProfileHint(params: { + kind: "cooldown" | "disabled"; + reason?: string; +}): string { + if (params.kind === "disabled") { + if (params.reason === "billing") { + return "Top up credits (provider billing) or switch provider."; + } + if (params.reason === "auth_permanent" || params.reason === "auth") { + return "Refresh or replace credentials, then retry."; + } + } + return "Wait for cooldown or switch provider."; +} + function formatAuthIssueHint(issue: AuthIssue): string | null { if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) { return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand( @@ -245,13 +260,14 @@ export async function noteAuthProfileHealth(params: { } const stats = store.usageStats?.[profileId]; const remaining = formatRemainingShort(until - now); - const kind = - typeof stats?.disabledUntil === "number" && now < stats.disabledUntil - ? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}` - : "cooldown"; - const hint = kind.startsWith("disabled:billing") - ? "Top up credits (provider billing) or switch provider." - : "Wait for cooldown or switch provider."; + const disabledActive = typeof stats?.disabledUntil === "number" && now < stats.disabledUntil; + const kind = disabledActive + ? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}` + : "cooldown"; + const hint = resolveUnusableProfileHint({ + kind: disabledActive ? "disabled" : "cooldown", + reason: stats?.disabledReason, + }); out.push(`- ${profileId}: ${kind} (${remaining})${hint ? ` — ${hint}` : ""}`); } return out; diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts new file mode 100644 index 00000000000..55c5ef064f3 --- /dev/null +++ b/src/commands/models/list.probe.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { mapFailoverReasonToProbeStatus } from "./list.probe.js"; + +describe("mapFailoverReasonToProbeStatus", () => { + it("maps auth_permanent to auth", () => { + expect(mapFailoverReasonToProbeStatus("auth_permanent")).toBe("auth"); + }); + + it("keeps existing failover reason mappings", () => { + expect(mapFailoverReasonToProbeStatus("auth")).toBe("auth"); + expect(mapFailoverReasonToProbeStatus("rate_limit")).toBe("rate_limit"); + expect(mapFailoverReasonToProbeStatus("billing")).toBe("billing"); + expect(mapFailoverReasonToProbeStatus("timeout")).toBe("timeout"); + expect(mapFailoverReasonToProbeStatus("format")).toBe("format"); + }); + + it("falls back to unknown for unrecognized values", () => { + expect(mapFailoverReasonToProbeStatus(undefined)).toBe("unknown"); + expect(mapFailoverReasonToProbeStatus(null)).toBe("unknown"); + expect(mapFailoverReasonToProbeStatus("model_not_found")).toBe("unknown"); + }); +}); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 60b38316117..ef48564df88 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -82,11 +82,13 @@ export type AuthProbeOptions = { maxTokens: number; }; -const toStatus = (reason?: string | null): AuthProbeStatus => { +export function mapFailoverReasonToProbeStatus(reason?: string | null): AuthProbeStatus { if (!reason) { return "unknown"; } - if (reason === "auth") { + if (reason === "auth" || reason === "auth_permanent") { + // Keep probe output backward-compatible: permanent auth failures still + // surface in the auth bucket instead of showing as unknown. return "auth"; } if (reason === "rate_limit") { @@ -102,7 +104,7 @@ const toStatus = (reason?: string | null): AuthProbeStatus => { return "format"; } return "unknown"; -}; +} function buildCandidateMap(modelCandidates: string[]): Map { const map = new Map(); @@ -346,7 +348,7 @@ async function probeTarget(params: { label: target.label, source: target.source, mode: target.mode, - status: toStatus(described.reason), + status: mapFailoverReasonToProbeStatus(described.reason), error: redactSecrets(described.message), latencyMs: Date.now() - start, }; From 70e31c6f68b88bcf91f6df827a0ea05bb90ab112 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:47:07 +0100 Subject: [PATCH 36/95] fix(gateway): harden hooks URL parsing (#26864) --- .../server-http.hooks-request-timeout.test.ts | 20 +++++++++++++++++-- src/gateway/server-http.ts | 6 ++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 448707eb1c7..577ffe1ab43 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -41,10 +41,11 @@ function createHooksConfig(): HooksConfigResolved { function createRequest(params?: { authorization?: string; remoteAddress?: string; + url?: string; }): IncomingMessage { return { method: "POST", - url: "/hooks/wake", + url: params?.url ?? "/hooks/wake", headers: { host: "127.0.0.1:18789", authorization: params?.authorization ?? "Bearer hook-secret", @@ -71,10 +72,11 @@ function createResponse(): { function createHandler(params?: { dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"]; dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"]; + bindHost?: string; }) { return createHooksRequestHandler({ getHooksConfig: () => createHooksConfig(), - bindHost: "127.0.0.1", + bindHost: params?.bindHost ?? "127.0.0.1", port: 18789, logHooks: { warn: vi.fn(), @@ -139,4 +141,18 @@ describe("createHooksRequestHandler timeout status mapping", () => { expect(mappedRes.statusCode).toBe(429); expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); }); + + test.each(["0.0.0.0", "::"])( + "does not throw when bindHost=%s while parsing non-hook request URL", + async (bindHost) => { + const handler = createHandler({ bindHost }); + const req = createRequest({ url: "/" }); + const { res, end } = createResponse(); + + const handled = await handler(req, res); + + expect(handled).toBe(false); + expect(end).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 72a81a769ad..41d04d5d3ac 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -208,7 +208,7 @@ export function createHooksRequestHandler( logHooks: SubsystemLogger; } & HookDispatchers, ): HooksRequestHandler { - const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; + const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; const hookAuthLimiter = createAuthRateLimiter({ maxAttempts: HOOK_AUTH_FAILURE_LIMIT, windowMs: HOOK_AUTH_FAILURE_WINDOW_MS, @@ -227,7 +227,9 @@ export function createHooksRequestHandler( if (!hooksConfig) { return false; } - const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); + // Only pathname/search are used here; keep the base host fixed so bind-host + // representation (e.g. IPv6 wildcards) cannot break request parsing. + const url = new URL(req.url ?? "/", "http://localhost"); const basePath = hooksConfig.basePath; if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { return false; From 6fb082e13160cfa7282c587a225d80b0e8c770bd Mon Sep 17 00:00:00 2001 From: codexGW <9350182+codexGW@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:53:38 -0800 Subject: [PATCH 37/95] fix(typing): call markDispatchIdle in followup runner to prevent stuck indicator (#26881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The followup runner (used for queued messages, inter-agent sends, heartbeat followups, etc.) only called typing.markRunComplete() in its finally block. The typing controller requires BOTH markRunComplete AND markDispatchIdle to trigger cleanup — but markDispatchIdle was only wired through the buffered dispatcher path, which followup turns bypass entirely. This caused the typing indicator to persist indefinitely on channels like Telegram when the agent replied with NO_REPLY or produced empty payloads, because the keepalive loop was never stopped. Adds markDispatchIdle() alongside markRunComplete() in the followup runner's finally block, and four test cases covering NO_REPLY, empty payloads, agent errors, and successful delivery. Complements #26295 which addressed the channel-level callback layer. Fixes #26595 Co-authored-by: Samantha --- src/auto-reply/reply/followup-runner.test.ts | 81 ++++++++++++++++++++ src/auto-reply/reply/followup-runner.ts | 8 ++ 2 files changed, 89 insertions(+) diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index da5d55fa9dd..a6e0c9f849a 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -428,6 +428,87 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); }); +describe("createFollowupRunner typing cleanup", () => { + it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "NO_REPLY" }], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on agent error", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("agent exploded")); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => { + const typing = createMockTypingController(); + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(onBlockReply).toHaveBeenCalled(); + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); +}); + describe("createFollowupRunner agentDir forwarding", () => { it("passes queued run agentDir to runEmbeddedPiAgent", async () => { runEmbeddedPiAgentMock.mockClear(); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 0c91d543d91..3f280d18e52 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -314,7 +314,15 @@ export function createFollowupRunner(params: { await sendFollowupPayloads(finalPayloads, queued); } finally { + // Both signals are required for the typing controller to clean up. + // The main inbound dispatch path calls markDispatchIdle() from the + // buffered dispatcher's finally block, but followup turns bypass the + // dispatcher entirely — so we must fire both signals here. Without + // this, NO_REPLY / empty-payload followups leave the typing indicator + // stuck (the keepalive loop keeps sending "typing" to Telegram + // indefinitely until the TTL expires). typing.markRunComplete(); + typing.markDispatchIdle(); } }; } From ec45c317f5d0631a3d333b236da58c4749ede2a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:38:45 +0100 Subject: [PATCH 38/95] fix(gateway): block trusted-proxy control-ui node bypass --- src/gateway/server.auth.test.ts | 108 +++++++++++++++++- .../server/ws-connection/message-handler.ts | 2 + 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 83a97644d19..900ef34b6b4 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -131,10 +131,11 @@ async function expectHelloOkServerVersion(port: number, expectedVersion: string) } async function createSignedDevice(params: { - token: string; + token?: string | null; scopes: string[]; clientId: string; clientMode: string; + role?: "operator" | "node"; identityPath?: string; nonce: string; signedAtMs?: number; @@ -149,10 +150,10 @@ async function createSignedDevice(params: { deviceId: identity.deviceId, clientId: params.clientId, clientMode: params.clientMode, - role: "operator", + role: params.role ?? "operator", scopes: params.scopes, signedAtMs, - token: params.token, + token: params.token ?? null, nonce: params.nonce, }); return { @@ -187,6 +188,23 @@ async function approvePendingPairingIfNeeded() { } } +async function configureTrustedProxyControlUiAuth() { + testState.gatewayAuth = { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); +} + function isConnectResMessage(id: string) { return (o: unknown) => { if (!o || typeof o !== "object" || Array.isArray(o)) { @@ -776,6 +794,90 @@ describe("gateway server auth/connect", () => { }); }); + test("allows trusted-proxy control ui operator without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "operator", + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + ws.close(); + }); + }); + + test("rejects trusted-proxy control ui node role without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "node", + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("control ui requires device identity"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ); + ws.close(); + }); + }); + + test("requires pairing for trusted-proxy control ui node role with unpaired device", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const challengeNonce = await readConnectChallengeNonce(ws); + expect(challengeNonce).toBeTruthy(); + const { device } = await createSignedDevice({ + token: null, + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + nonce: String(challengeNonce), + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "node", + scopes: [], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + ws.close(); + }); + }); + test("allows localhost control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret", { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 8feb385e8f9..30d288b651d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -491,6 +491,7 @@ export function attachGatewayWsMessageHandler(params: { } const trustedProxyAuthOk = isControlUi && + role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy"; @@ -629,6 +630,7 @@ export function attachGatewayWsMessageHandler(params: { const trustedProxyAuthOk = isControlUi && + role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy"; From 3cd3d489f4ea9ceb03be41d17d8259f17bbb38b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:54:04 +0100 Subject: [PATCH 39/95] docs(changelog): note trusted-proxy control-ui hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ff72155d1..5df5ae62dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. - Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. +- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. From 8c701ba1ffb8907002e0370e99dd0e3ec8da79ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:54:10 +0100 Subject: [PATCH 40/95] test(gateway): add hooks bind-host hardening coverage --- src/gateway/server.plugin-http-auth.test.ts | 120 +++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 25568d4803e..79093169c6a 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -1,7 +1,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, test, vi } from "vitest"; +import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { createGatewayHttpServer } from "./server-http.js"; +import type { HooksConfigResolved } from "./hooks.js"; +import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js"; import { withTempConfig } from "./test-temp-config.js"; function createRequest(params: { @@ -65,6 +67,25 @@ async function dispatchRequest( await new Promise((resolve) => setImmediate(resolve)); } +function createHooksConfig(): HooksConfigResolved { + return { + basePath: "/hooks", + token: "hook-secret", + maxBodyBytes: 1024, + mappings: [], + agentPolicy: { + defaultAgentId: "main", + knownAgentIds: new Set(["main"]), + allowedAgentIds: undefined, + }, + sessionPolicy: { + allowRequestSessionKey: false, + defaultSessionKey: undefined, + allowedSessionKeyPrefixes: undefined, + }, + }; +} + describe("gateway plugin HTTP auth boundary", () => { test("applies default security headers and optional strict transport security", async () => { const resolvedAuth: ResolvedGatewayAuth = { @@ -220,4 +241,101 @@ describe("gateway plugin HTTP auth boundary", () => { }, }); }); + + test.each(["0.0.0.0", "::"])( + "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s", + async (bindHost) => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-hooks-bindhost-", + run: async () => { + const handleHooksRequest = createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost, + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: () => {}, + dispatchAgentHook: () => "run-1", + }); + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest, + resolvedAuth, + }); + + const response = createResponse(); + await dispatchRequest(server, createRequest({ path: "/" }), response.res); + + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); + }, + }); + }, + ); + + test("rejects query-token hooks requests with bindHost=::", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-hooks-query-token-", + run: async () => { + const handleHooksRequest = createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost: "::", + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: () => {}, + dispatchAgentHook: () => "run-1", + }); + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest, + resolvedAuth, + }); + + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/hooks/wake?token=bad" }), + response.res, + ); + + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Hook token must be provided"); + }, + }); + }); }); From 5e1bfb2ce2b73a27d04e03c5049910753f09aa3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 01:07:27 +0000 Subject: [PATCH 41/95] docs(changelog): add followup typing fix note (#26881) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df5ae62dda..339093dbc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. - Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. - Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. +- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. - Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. From ce8c67c314b93f570f53c2a9abc124e1e3a54715 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:11:36 +0100 Subject: [PATCH 42/95] fix(slack): gate interactive system events by sender auth --- CHANGELOG.md | 1 + src/slack/modal-metadata.test.ts | 4 + src/slack/modal-metadata.ts | 3 + src/slack/monitor/auth.ts | 144 +++++++++++- src/slack/monitor/events/interactions.test.ts | 208 +++++++++++++++++- src/slack/monitor/events/interactions.ts | 75 ++++++- 6 files changed, 415 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 339093dbc8b..0df4711abaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. diff --git a/src/slack/modal-metadata.test.ts b/src/slack/modal-metadata.test.ts index d209c70587c..a7a7ce8224b 100644 --- a/src/slack/modal-metadata.test.ts +++ b/src/slack/modal-metadata.test.ts @@ -18,6 +18,7 @@ describe("parseSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "D123", channelType: "im", + userId: "U123", ignored: "x", }), ), @@ -25,6 +26,7 @@ describe("parseSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "D123", channelType: "im", + userId: "U123", }); }); }); @@ -37,11 +39,13 @@ describe("encodeSlackModalPrivateMetadata", () => { sessionKey: "agent:main:slack:channel:C1", channelId: "", channelType: "im", + userId: "U123", }), ), ).toEqual({ sessionKey: "agent:main:slack:channel:C1", channelType: "im", + userId: "U123", }); }); diff --git a/src/slack/modal-metadata.ts b/src/slack/modal-metadata.ts index 491fb5d38f3..963024487a9 100644 --- a/src/slack/modal-metadata.ts +++ b/src/slack/modal-metadata.ts @@ -2,6 +2,7 @@ export type SlackModalPrivateMetadata = { sessionKey?: string; channelId?: string; channelType?: string; + userId?: string; }; const SLACK_PRIVATE_METADATA_MAX = 3000; @@ -20,6 +21,7 @@ export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateM sessionKey: normalizeString(parsed.sessionKey), channelId: normalizeString(parsed.channelId), channelType: normalizeString(parsed.channelType), + userId: normalizeString(parsed.userId), }; } catch { return {}; @@ -31,6 +33,7 @@ export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata ...(input.sessionKey ? { sessionKey: input.sessionKey } : {}), ...(input.channelId ? { channelId: input.channelId } : {}), ...(input.channelType ? { channelType: input.channelType } : {}), + ...(input.userId ? { userId: input.userId } : {}), }; const encoded = JSON.stringify(payload); if (encoded.length > SLACK_PRIVATE_METADATA_MAX) { diff --git a/src/slack/monitor/auth.ts b/src/slack/monitor/auth.ts index d8fa5e5b4e5..cb43241f899 100644 --- a/src/slack/monitor/auth.ts +++ b/src/slack/monitor/auth.ts @@ -1,6 +1,12 @@ import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; -import { allowListMatches, normalizeAllowList, normalizeAllowListLower } from "./allow-list.js"; -import type { SlackMonitorContext } from "./context.js"; +import { + allowListMatches, + normalizeAllowList, + normalizeAllowListLower, + resolveSlackUserAllowed, +} from "./allow-list.js"; +import { resolveSlackChannelConfig } from "./channel-config.js"; +import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) { const storeAllowFrom = @@ -27,3 +33,137 @@ export function isSlackSenderAllowListed(params: { }) ); } + +export type SlackSystemEventAuthResult = { + allowed: boolean; + reason?: + | "missing-sender" + | "sender-mismatch" + | "channel-not-allowed" + | "dm-disabled" + | "sender-not-allowlisted" + | "sender-not-channel-allowed"; + channelType?: "im" | "mpim" | "channel" | "group"; + channelName?: string; +}; + +export async function authorizeSlackSystemEventSender(params: { + ctx: SlackMonitorContext; + senderId?: string; + channelId?: string; + channelType?: string | null; + expectedSenderId?: string; +}): Promise { + const senderId = params.senderId?.trim(); + if (!senderId) { + return { allowed: false, reason: "missing-sender" }; + } + + const expectedSenderId = params.expectedSenderId?.trim(); + if (expectedSenderId && expectedSenderId !== senderId) { + return { allowed: false, reason: "sender-mismatch" }; + } + + const channelId = params.channelId?.trim(); + let channelType = normalizeSlackChannelType(params.channelType, channelId); + let channelName: string | undefined; + if (channelId) { + const info: { + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + } = await params.ctx.resolveChannelName(channelId).catch(() => ({})); + channelName = info.name; + channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId); + if ( + !params.ctx.isChannelAllowed({ + channelId, + channelName, + channelType, + }) + ) { + return { + allowed: false, + reason: "channel-not-allowed", + channelType, + channelName, + }; + } + } + + const senderInfo: { name?: string } = await params.ctx + .resolveUserName(senderId) + .catch(() => ({})); + const senderName = senderInfo.name; + + const resolveAllowFromLower = async () => + (await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower; + + if (channelType === "im") { + if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { + return { allowed: false, reason: "dm-disabled", channelType, channelName }; + } + if (params.ctx.dmPolicy !== "open") { + const allowFromLower = await resolveAllowFromLower(); + const senderAllowListed = isSlackSenderAllowListed({ + allowListLower: allowFromLower, + senderId, + senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!senderAllowListed) { + return { + allowed: false, + reason: "sender-not-allowlisted", + channelType, + channelName, + }; + } + } + } else if (!channelId) { + // No channel context. Apply allowFrom if configured so we fail closed + // for privileged interactive events when owner allowlist is present. + const allowFromLower = await resolveAllowFromLower(); + if (allowFromLower.length > 0) { + const senderAllowListed = isSlackSenderAllowListed({ + allowListLower: allowFromLower, + senderId, + senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!senderAllowListed) { + return { allowed: false, reason: "sender-not-allowlisted" }; + } + } + } else { + const channelConfig = resolveSlackChannelConfig({ + channelId, + channelName, + channels: params.ctx.channelsConfig, + defaultRequireMention: params.ctx.defaultRequireMention, + }); + const channelUsersAllowlistConfigured = + Array.isArray(channelConfig?.users) && channelConfig.users.length > 0; + if (channelUsersAllowlistConfigured) { + const channelUserAllowed = resolveSlackUserAllowed({ + allowList: channelConfig?.users, + userId: senderId, + userName: senderName, + allowNameMatching: params.ctx.allowNameMatching, + }); + if (!channelUserAllowed) { + return { + allowed: false, + reason: "sender-not-channel-allowed", + channelType, + channelName, + }; + } + } + } + + return { + allowed: true, + channelType, + channelName, + }; +} diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index 7710239cc71..cfd53506358 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -30,6 +30,7 @@ type RegisteredViewHandler = (args: { view?: { id?: string; callback_id?: string; + private_metadata?: string; root_view_id?: string; previous_view_id?: string; external_id?: string; @@ -58,7 +59,23 @@ type RegisteredViewClosedHandler = (args: { }; }) => Promise; -function createContext() { +function createContext(overrides?: { + dmEnabled?: boolean; + dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; + allowFrom?: string[]; + allowNameMatching?: boolean; + channelsConfig?: Record; + isChannelAllowed?: (params: { + channelId?: string; + channelName?: string; + channelType?: "im" | "mpim" | "channel" | "group"; + }) => boolean; + resolveUserName?: (userId: string) => Promise<{ name?: string }>; + resolveChannelName?: (channelId: string) => Promise<{ + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + }>; +}) { let handler: RegisteredHandler | null = null; let viewHandler: RegisteredViewHandler | null = null; let viewClosedHandler: RegisteredViewClosedHandler | null = null; @@ -80,9 +97,40 @@ function createContext() { }; const runtimeLog = vi.fn(); const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1"); + const isChannelAllowed = vi + .fn< + (params: { + channelId?: string; + channelName?: string; + channelType?: "im" | "mpim" | "channel" | "group"; + }) => boolean + >() + .mockImplementation((params) => overrides?.isChannelAllowed?.(params) ?? true); + const resolveUserName = vi + .fn<(userId: string) => Promise<{ name?: string }>>() + .mockImplementation((userId) => overrides?.resolveUserName?.(userId) ?? Promise.resolve({})); + const resolveChannelName = vi + .fn< + (channelId: string) => Promise<{ + name?: string; + type?: "im" | "mpim" | "channel" | "group"; + }> + >() + .mockImplementation( + (channelId) => overrides?.resolveChannelName?.(channelId) ?? Promise.resolve({}), + ); const ctx = { app, runtime: { log: runtimeLog }, + dmEnabled: overrides?.dmEnabled ?? true, + dmPolicy: overrides?.dmPolicy ?? ("open" as const), + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: overrides?.allowNameMatching ?? false, + channelsConfig: overrides?.channelsConfig ?? {}, + defaultRequireMention: true, + isChannelAllowed, + resolveUserName, + resolveChannelName, resolveSlackSystemEventSessionKey: resolveSessionKey, }; return { @@ -90,6 +138,9 @@ function createContext() { app, runtimeLog, resolveSessionKey, + isChannelAllowed, + resolveUserName, + resolveChannelName, getHandler: () => handler, getViewHandler: () => viewHandler, getViewClosedHandler: () => viewClosedHandler, @@ -168,7 +219,7 @@ describe("registerSlackInteractionEvents", () => { }); expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C1", - channelType: undefined, + channelType: "channel", }); expect(app.client.chat.update).toHaveBeenCalledTimes(1); }); @@ -228,6 +279,85 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("blocks block actions from users outside configured channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, app, getHandler } = createContext({ + channelsConfig: { + C1: { users: ["U_ALLOWED"] }, + }, + }); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + const respond = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + respond, + body: { + user: { id: "U_DENIED" }, + channel: { id: "C1" }, + message: { + ts: "201.202", + blocks: [{ type: "actions", block_id: "verify_block", elements: [] }], + }, + }, + action: { + type: "button", + action_id: "openclaw:verify", + block_id: "verify_block", + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(app.client.chat.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + }); + + it("blocks DM block actions when sender is not in allowFrom", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, app, getHandler } = createContext({ + dmPolicy: "allowlist", + allowFrom: ["U_OWNER"], + }); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + const respond = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + respond, + body: { + user: { id: "U_ATTACKER" }, + channel: { id: "D222" }, + message: { + ts: "301.302", + blocks: [{ type: "actions", block_id: "verify_block", elements: [] }], + }, + }, + action: { + type: "button", + action_id: "openclaw:verify", + block_id: "verify_block", + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(app.client.chat.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + }); + it("ignores malformed action payloads after ack and logs warning", async () => { enqueueSystemEventMock.mockClear(); const { ctx, app, getHandler, runtimeLog } = createContext(); @@ -338,7 +468,7 @@ describe("registerSlackInteractionEvents", () => { expect(ack).toHaveBeenCalled(); expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C222", - channelType: undefined, + channelType: "channel", }); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; @@ -697,7 +827,11 @@ describe("registerSlackInteractionEvents", () => { previous_view_id: "VPREV", external_id: "deploy-ext-1", hash: "view-hash-1", - private_metadata: JSON.stringify({ channelId: "D123", channelType: "im" }), + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + userId: "U777", + }), state: { values: { env_block: { @@ -771,6 +905,59 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("blocks modal events when private metadata userId does not match submitter", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U222" }, + view: { + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + userId: "U111", + }), + }, + }, + } as never); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks modal events when private metadata is missing userId", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U222" }, + view: { + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "D123", + channelType: "im", + }), + }, + }, + } as never); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + it("captures modal input labels and picker values across block types", async () => { enqueueSystemEventMock.mockClear(); const { ctx, getViewHandler } = createContext(); @@ -786,6 +973,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V400", callback_id: "openclaw:routing_form", + private_metadata: JSON.stringify({ userId: "U444" }), state: { values: { env_block: { @@ -1001,6 +1189,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V555", callback_id: "openclaw:long_richtext", + private_metadata: JSON.stringify({ userId: "U555" }), state: { values: { richtext_block: { @@ -1054,7 +1243,10 @@ describe("registerSlackInteractionEvents", () => { previous_view_id: "VPREV900", external_id: "deploy-ext-900", hash: "view-hash-900", - private_metadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }), + private_metadata: JSON.stringify({ + sessionKey: "agent:main:slack:channel:C99", + userId: "U900", + }), state: { values: { env_block: { @@ -1101,7 +1293,10 @@ describe("registerSlackInteractionEvents", () => { viewId: "V900", userId: "U900", isCleared: true, - privateMetadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }), + privateMetadata: JSON.stringify({ + sessionKey: "agent:main:slack:channel:C99", + userId: "U900", + }), rootViewId: "VROOT900", previousViewId: "VPREV900", externalId: "deploy-ext-900", @@ -1131,6 +1326,7 @@ describe("registerSlackInteractionEvents", () => { view: { id: "V901", callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ userId: "U901" }), }, }, }); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index cbc4fc9f36e..40a06ad9f2e 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -2,6 +2,7 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; import { escapeSlackMrkdwn } from "../mrkdwn.js"; @@ -78,6 +79,7 @@ type SlackModalBody = { type SlackModalEventBase = { callbackId: string; userId: string; + expectedUserId?: string; viewId?: string; sessionRouting: ReturnType; payload: { @@ -366,11 +368,15 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { function resolveModalSessionRouting(params: { ctx: SlackMonitorContext; - privateMetadata: unknown; + metadata: ReturnType; }): { sessionKey: string; channelId?: string; channelType?: string } { - const metadata = parseSlackModalPrivateMetadata(params.privateMetadata); + const metadata = params.metadata; if (metadata.sessionKey) { - return { sessionKey: metadata.sessionKey }; + return { + sessionKey: metadata.sessionKey, + channelId: metadata.channelId, + channelType: metadata.channelType, + }; } if (metadata.channelId) { return { @@ -416,17 +422,19 @@ function resolveSlackModalEventBase(params: { ctx: SlackMonitorContext; body: SlackModalBody; }): SlackModalEventBase { + const metadata = parseSlackModalPrivateMetadata(params.body.view?.private_metadata); const callbackId = params.body.view?.callback_id ?? "unknown"; const userId = params.body.user?.id ?? "unknown"; const viewId = params.body.view?.id; const inputs = summarizeViewState(params.body.view?.state?.values); const sessionRouting = resolveModalSessionRouting({ ctx: params.ctx, - privateMetadata: params.body.view?.private_metadata, + metadata, }); return { callbackId, userId, + expectedUserId: metadata.userId, viewId, sessionRouting, payload: { @@ -449,16 +457,17 @@ function resolveSlackModalEventBase(params: { }; } -function emitSlackModalLifecycleEvent(params: { +async function emitSlackModalLifecycleEvent(params: { ctx: SlackMonitorContext; body: SlackModalBody; interactionType: SlackModalInteractionKind; contextPrefix: "slack:interaction:view" | "slack:interaction:view-closed"; -}): void { - const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({ - ctx: params.ctx, - body: params.body, - }); +}): Promise { + const { callbackId, userId, expectedUserId, viewId, sessionRouting, payload } = + resolveSlackModalEventBase({ + ctx: params.ctx, + body: params.body, + }); const isViewClosed = params.interactionType === "view_closed"; const isCleared = params.body.is_cleared === true; const eventPayload = isViewClosed @@ -482,6 +491,27 @@ function emitSlackModalLifecycleEvent(params: { ); } + if (!expectedUserId) { + params.ctx.runtime.log?.( + `slack:interaction drop modal callback=${callbackId} user=${userId} reason=missing-expected-user`, + ); + return; + } + + const auth = await authorizeSlackSystemEventSender({ + ctx: params.ctx, + senderId: userId, + channelId: sessionRouting.channelId, + channelType: sessionRouting.channelType, + expectedSenderId: expectedUserId, + }); + if (!auth.allowed) { + params.ctx.runtime.log?.( + `slack:interaction drop modal callback=${callbackId} user=${userId} reason=${auth.reason ?? "unauthorized"}`, + ); + return; + } + enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { sessionKey: sessionRouting.sessionKey, contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), @@ -497,7 +527,7 @@ function registerModalLifecycleHandler(params: { }) { params.register(params.matcher, async ({ ack, body }: SlackModalEventHandlerArgs) => { await ack(); - emitSlackModalLifecycleEvent({ + await emitSlackModalLifecycleEvent({ ctx: params.ctx, body: body as SlackModalBody, interactionType: params.interactionType, @@ -557,6 +587,27 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; const threadTs = typedBody.container?.thread_ts; + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: userId, + channelId, + }); + if (!auth.allowed) { + ctx.runtime.log?.( + `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + if (respond) { + try { + await respond({ + text: "You are not authorized to use this control.", + response_type: "ephemeral", + }); + } catch { + // Best-effort feedback only. + } + } + return; + } const actionSummary = summarizeAction(typedAction); const eventPayload: InteractionSummary = { interactionType: "block_action", @@ -581,7 +632,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex // Pass undefined (not "unknown") to allow proper main session fallback const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId: channelId, - channelType: undefined, + channelType: auth.channelType, }); // Build context key - only include defined values to avoid "unknown" noise From 95c6b3a912a1a02b70913df5e6d7bdabed32cb24 Mon Sep 17 00:00:00 2001 From: sten moocow Date: Wed, 25 Feb 2026 06:58:58 -0500 Subject: [PATCH 43/95] fix(telegram): recover polling after prolonged network outages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When grammY's runner exceeds maxRetryTime during a network outage, runner.task() resolves cleanly. Previously, the polling loop treated this as an intentional stop and exited permanently — killing Telegram polling for the lifetime of the gateway process. Now the outer loop detects this case and restarts with exponential backoff, so polling recovers once connectivity is restored. Also bumps maxRetryTime from 5 minutes to 60 minutes so the runner itself survives longer outages (e.g. scheduled internet downtime) without needing the outer loop restart path. --- src/telegram/monitor.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 8637f488dd6..8c93eee60c9 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -45,8 +45,10 @@ export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions Date: Thu, 26 Feb 2026 02:26:46 +0100 Subject: [PATCH 44/95] refactor(gateway): centralize trusted-proxy control-ui bypass policy --- src/gateway/server.auth.test.ts | 170 +++++++++--------- .../ws-connection/connect-policy.test.ts | 52 ++++++ .../server/ws-connection/connect-policy.ts | 16 ++ .../server/ws-connection/message-handler.ts | 27 +-- 4 files changed, 173 insertions(+), 92 deletions(-) diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 900ef34b6b4..a0cbf5d9c1e 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -105,6 +105,13 @@ const CONTROL_UI_CLIENT = { mode: GATEWAY_CLIENT_MODES.WEBCHAT, }; +const TRUSTED_PROXY_CONTROL_UI_HEADERS = { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", +} as const; + const NODE_CLIENT = { id: GATEWAY_CLIENT_NAMES.NODE_HOST, version: "1.0.0", @@ -794,89 +801,92 @@ describe("gateway server auth/connect", () => { }); }); - test("allows trusted-proxy control ui operator without device identity", async () => { - await configureTrustedProxyControlUiAuth(); - await withGatewayServer(async ({ port }) => { - const ws = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - "x-forwarded-proto": "https", - "x-forwarded-user": "peter@example.com", - }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - role: "operator", - device: null, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(true); - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message ?? "").toContain("missing scope"); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - ws.close(); - }); - }); + const trustedProxyControlUiCases: Array<{ + name: string; + role: "operator" | "node"; + withUnpairedNodeDevice: boolean; + expectedOk: boolean; + expectedErrorSubstring?: string; + expectedErrorCode?: string; + expectStatusChecks: boolean; + }> = [ + { + name: "allows trusted-proxy control ui operator without device identity", + role: "operator", + withUnpairedNodeDevice: false, + expectedOk: true, + expectStatusChecks: true, + }, + { + name: "rejects trusted-proxy control ui node role without device identity", + role: "node", + withUnpairedNodeDevice: false, + expectedOk: false, + expectedErrorSubstring: "control ui requires device identity", + expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + expectStatusChecks: false, + }, + { + name: "requires pairing for trusted-proxy control ui node role with unpaired device", + role: "node", + withUnpairedNodeDevice: true, + expectedOk: false, + expectedErrorSubstring: "pairing required", + expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED, + expectStatusChecks: false, + }, + ]; - test("rejects trusted-proxy control ui node role without device identity", async () => { - await configureTrustedProxyControlUiAuth(); - await withGatewayServer(async ({ port }) => { - const ws = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - "x-forwarded-proto": "https", - "x-forwarded-user": "peter@example.com", + for (const tc of trustedProxyControlUiCases) { + test(tc.name, async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); + const scopes = tc.withUnpairedNodeDevice ? [] : undefined; + let device: Awaited>["device"] | null = null; + if (tc.withUnpairedNodeDevice) { + const challengeNonce = await readConnectChallengeNonce(ws); + expect(challengeNonce).toBeTruthy(); + ({ device } = await createSignedDevice({ + token: null, + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + nonce: String(challengeNonce), + })); + } + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: tc.role, + scopes, + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(tc.expectedOk); + if (!tc.expectedOk) { + if (tc.expectedErrorSubstring) { + expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring); + } + if (tc.expectedErrorCode) { + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + tc.expectedErrorCode, + ); + } + ws.close(); + return; + } + if (tc.expectStatusChecks) { + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + } + ws.close(); }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - role: "node", - device: null, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("control ui requires device identity"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - ); - ws.close(); }); - }); - - test("requires pairing for trusted-proxy control ui node role with unpaired device", async () => { - await configureTrustedProxyControlUiAuth(); - await withGatewayServer(async ({ port }) => { - const ws = await openWs(port, { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - "x-forwarded-proto": "https", - "x-forwarded-user": "peter@example.com", - }); - const challengeNonce = await readConnectChallengeNonce(ws); - expect(challengeNonce).toBeTruthy(); - const { device } = await createSignedDevice({ - token: null, - role: "node", - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - nonce: String(challengeNonce), - }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - role: "node", - scopes: [], - device, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - ws.close(); - }); - }); + } test("allows localhost control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 320f90537ce..88813663a85 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { evaluateMissingDeviceIdentity, + isTrustedProxyControlUiOperatorAuth, resolveControlUiAuthPolicy, shouldSkipControlUiPairing, } from "./connect-policy.js"; @@ -186,4 +187,55 @@ describe("ws connect policy", () => { expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false); expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true); }); + + test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { + const cases: Array<{ + role: "operator" | "node"; + authMode: string; + authOk: boolean; + authMethod: string | undefined; + expected: boolean; + }> = [ + { + role: "operator", + authMode: "trusted-proxy", + authOk: true, + authMethod: "trusted-proxy", + expected: true, + }, + { + role: "node", + authMode: "trusted-proxy", + authOk: true, + authMethod: "trusted-proxy", + expected: false, + }, + { + role: "operator", + authMode: "token", + authOk: true, + authMethod: "token", + expected: false, + }, + { + role: "operator", + authMode: "trusted-proxy", + authOk: false, + authMethod: "trusted-proxy", + expected: false, + }, + ]; + + for (const tc of cases) { + expect( + isTrustedProxyControlUiOperatorAuth({ + isControlUi: true, + role: tc.role, + authMode: tc.authMode, + authOk: tc.authOk, + authMethod: tc.authMethod, + }), + ).toBe(tc.expected); + } + }); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 70dbea07505..f2467aedc98 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -43,6 +43,22 @@ export function shouldSkipControlUiPairing( return policy.allowBypass && sharedAuthOk; } +export function isTrustedProxyControlUiOperatorAuth(params: { + isControlUi: boolean; + role: GatewayRole; + authMode: string; + authOk: boolean; + authMethod: string | undefined; +}): boolean { + return ( + params.isControlUi && + params.role === "operator" && + params.authMode === "trusted-proxy" && + params.authOk && + params.authMethod === "trusted-proxy" + ); +} + export type MissingDeviceIdentityDecision = | { kind: "allow" } | { kind: "reject-control-ui-insecure-auth" } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 30d288b651d..261e9f69da2 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -75,6 +75,7 @@ import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-cont import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js"; import { evaluateMissingDeviceIdentity, + isTrustedProxyControlUiOperatorAuth, resolveControlUiAuthPolicy, shouldSkipControlUiPairing, } from "./connect-policy.js"; @@ -489,12 +490,13 @@ export function attachGatewayWsMessageHandler(params: { if (!device) { clearUnboundScopes(); } - const trustedProxyAuthOk = - isControlUi && - role === "operator" && - resolvedAuth.mode === "trusted-proxy" && - authOk && - authMethod === "trusted-proxy"; + const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ + isControlUi, + role, + authMode: resolvedAuth.mode, + authOk, + authMethod, + }); const decision = evaluateMissingDeviceIdentity({ hasDeviceIdentity: Boolean(device), role, @@ -628,12 +630,13 @@ export function attachGatewayWsMessageHandler(params: { return; } - const trustedProxyAuthOk = - isControlUi && - role === "operator" && - resolvedAuth.mode === "trusted-proxy" && - authOk && - authMethod === "trusted-proxy"; + const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ + isControlUi, + role, + authMode: resolvedAuth.mode, + authOk, + authMethod, + }); const skipPairing = shouldSkipControlUiPairing( controlUiAuthPolicy, sharedAuthOk, From acbb93be489d4a98518c151be823fc582e0c370a Mon Sep 17 00:00:00 2001 From: Ramez Date: Wed, 25 Feb 2026 19:35:40 -0600 Subject: [PATCH 45/95] fix(agents): comprehensive quota fallback fixes - session overrides + surgical cooldown logic (#23816) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: e6f2b4742b82b9fe44a7e103170c2f96565b09c5 Co-authored-by: ramezgaberiel <844893+ramezgaberiel@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/agents/model-fallback.probe.test.ts | 30 ++- src/agents/model-fallback.test.ts | 310 +++++++++++++++++++++++- src/agents/model-fallback.ts | 135 ++++++++--- 4 files changed, 422 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df4711abaf..b4b4a5b4064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. - Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 0c222ec2115..3e36366c4ad 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -163,7 +163,7 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "recovered"); }); - it("does NOT probe non-primary candidates during cooldown", async () => { + it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => { const cfg = makeCfg({ agents: { defaults: { @@ -182,25 +182,23 @@ describe("runWithModelFallback – probe logic", () => { const almostExpired = NOW + 30 * 1000; // 30s remaining mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired); - // Primary probe fails with 429 + // Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns. const run = vi .fn() .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) - .mockResolvedValue("should-not-reach"); + .mockResolvedValue("fallback-ok"); - try { - await runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - run, - }); - expect.unreachable("should have thrown since all candidates exhausted"); - } catch { - // Primary was probed (i === 0 + within margin), non-primary were skipped - expect(run).toHaveBeenCalledTimes(1); // only primary was actually called - expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini"); - } + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("fallback-ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini"); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); }); it("throttles probe when called within 30s interval", async () => { diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 16592cdb456..cd0217faafc 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -143,10 +143,22 @@ async function expectSkippedUnavailableProvider(params: { }) { const provider = `${params.providerPrefix}-${crypto.randomUUID()}`; const cfg = makeProviderFallbackCfg(provider); - const store = makeSingleProviderStore({ + const primaryStore = makeSingleProviderStore({ provider, usageStat: params.usageStat, }); + // Include fallback provider profile so the fallback is attempted (not skipped as no-profile). + const store: AuthProfileStore = { + ...primaryStore, + profiles: { + ...primaryStore.profiles, + "fallback:default": { + type: "api_key", + provider: "fallback", + key: "test-key", + }, + }, + }; const run = createFallbackOnlyRun(); const result = await runWithStoredAuth({ @@ -436,11 +448,11 @@ describe("runWithModelFallback", () => { run, }); - // Override model failed with model_not_found → falls back to configured primary. + // Override model failed with model_not_found → tries fallbacks first (same provider). expect(result.result).toBe("ok"); expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("openai"); - expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); + expect(run.mock.calls[1]?.[0]).toBe("anthropic"); + expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("skips providers when all profiles are in cooldown", async () => { @@ -794,6 +806,296 @@ describe("runWithModelFallback", () => { expect(result.provider).toBe("openai"); expect(result.model).toBe("gpt-4.1-mini"); }); + + // Tests for Bug A fix: Model fallback with session overrides + describe("fallback behavior with session model overrides", () => { + it("allows fallbacks when session model differs from config within same provider", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "google/gemini-2.5-flash"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Rate limit exceeded")) // Session model fails + .mockResolvedValueOnce("fallback success"); // First fallback succeeds + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4-20250514", // Different from config primary + run, + }); + + expect(result.result).toBe("fallback success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-20250514"); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-sonnet-4-5"); // Fallback tried + }); + + it("allows fallbacks with model version differences within same provider", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Weekly quota exceeded")) + .mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-5", // Version difference from config + run, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); + }); + + it("still skips fallbacks when using different provider than config", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: [], // Empty fallbacks to match working pattern + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error('No credentials found for profile "openai:default".')) + .mockResolvedValueOnce("config primary worked"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", // Different provider + model: "gpt-4.1-mini", + run, + }); + + // Cross-provider requests should skip configured fallbacks but still try configured primary + expect(result.result).toBe("config primary worked"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini"); // Original request + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-opus-4-6"); // Config primary as final fallback + }); + + it("uses fallbacks when session model exactly matches config primary", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Quota exceeded")) + .mockResolvedValueOnce("fallback worked"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", // Exact match + run, + }); + + expect(result.result).toBe("fallback worked"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); + }); + }); + + // Tests for Bug B fix: Rate limit vs auth/billing cooldown distinction + describe("fallback behavior with provider cooldowns", () => { + async function makeAuthStoreWithCooldown( + provider: string, + reason: "rate_limit" | "auth" | "billing", + ): Promise<{ store: AuthProfileStore; dir: string }> { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const now = Date.now(); + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: { + [`${provider}:default`]: { type: "api_key", provider, key: "test-key" }, + }, + usageStats: { + [`${provider}:default`]: + reason === "rate_limit" + ? { + // Real rate-limit cooldowns are tracked through cooldownUntil + // and failureCounts, not disabledReason. + cooldownUntil: now + 300000, + failureCounts: { rate_limit: 1 }, + } + : { + // Auth/billing issues use disabledUntil + disabledUntil: now + 300000, + disabledReason: reason, + }, + }, + }; + saveAuthProfileStore(store, tmpDir); + return { store, dir: tmpDir }; + } + + it("attempts same-provider fallbacks during rate limit cooldown", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("sonnet success"); // Fallback succeeds + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("sonnet success"); + expect(run).toHaveBeenCalledTimes(1); // Primary skipped, fallback attempted + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5"); + }); + + it("skips same-provider models on auth cooldown but still tries no-profile fallback providers", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "auth"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile"); + }); + + it("skips same-provider models on billing cooldown but still tries no-profile fallback providers", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "billing"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile"); + }); + + it("tries cross-provider fallbacks when same provider has rate limit", async () => { + // Anthropic in rate limit cooldown, Groq available + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: { + "anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" }, + "groq:default": { type: "api_key", provider: "groq", key: "test-key" }, + }, + usageStats: { + "anthropic:default": { + // Rate-limit reason is inferred from failureCounts for cooldown windows. + cooldownUntil: Date.now() + 300000, + failureCounts: { rate_limit: 2 }, + }, + // Groq not in cooldown + }, + }; + saveAuthProfileStore(store, tmpDir); + + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Still rate limited")) // Sonnet still fails + .mockResolvedValueOnce("groq success"); // Groq works + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: tmpDir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5"); // Rate limit allows attempt + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); // Cross-provider works + }); + }); }); describe("runWithImageModelFallback", () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index e59d9e9357c..da03d88d847 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -224,21 +224,21 @@ function resolveFallbackCandidates(params: { const configuredFallbacks = resolveAgentModelFallbackValues( params.cfg?.agents?.defaults?.model, ); - if (sameModelCandidate(normalizedPrimary, configuredPrimary)) { - return configuredFallbacks; - } - // Preserve resilience after failover: when current model is one of the - // configured fallback refs, keep traversing the configured fallback chain. - const isConfiguredFallback = configuredFallbacks.some((raw) => { - const resolved = resolveModelRefFromString({ - raw: String(raw ?? ""), - defaultProvider, - aliasIndex, + // When user runs a different provider than config, only use configured fallbacks + // if the current model is already in that chain (e.g. session on first fallback). + if (normalizedPrimary.provider !== configuredPrimary.provider) { + const isConfiguredFallback = configuredFallbacks.some((raw) => { + const resolved = resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider, + aliasIndex, + }); + return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; }); - return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; - }); - // Keep legacy override behavior for ad-hoc models outside configured chain. - return isConfiguredFallback ? configuredFallbacks : []; + return isConfiguredFallback ? configuredFallbacks : []; + } + // Same provider: always use full fallback chain (model version differences within provider). + return configuredFallbacks; })(); for (const raw of modelFallbacks) { @@ -306,6 +306,76 @@ export const _probeThrottleInternals = { resolveProbeThrottleKey, } as const; +type CooldownDecision = + | { + type: "skip"; + reason: FailoverReason; + error: string; + } + | { + type: "attempt"; + reason: FailoverReason; + markProbe: boolean; + }; + +function resolveCooldownDecision(params: { + candidate: ModelCandidate; + isPrimary: boolean; + requestedModel: boolean; + hasFallbackCandidates: boolean; + now: number; + probeThrottleKey: string; + authStore: ReturnType; + profileIds: string[]; +}): CooldownDecision { + const shouldProbe = shouldProbePrimaryDuringCooldown({ + isPrimary: params.isPrimary, + hasFallbackCandidates: params.hasFallbackCandidates, + now: params.now, + throttleKey: params.probeThrottleKey, + authStore: params.authStore, + profileIds: params.profileIds, + }); + + const inferredReason = + resolveProfilesUnavailableReason({ + store: params.authStore, + profileIds: params.profileIds, + now: params.now, + }) ?? "rate_limit"; + const isPersistentIssue = + inferredReason === "auth" || + inferredReason === "auth_permanent" || + inferredReason === "billing"; + if (isPersistentIssue) { + return { + type: "skip", + reason: inferredReason, + error: `Provider ${params.candidate.provider} has ${inferredReason} issue (skipping all models)`, + }; + } + + // For primary: try when requested model or when probe allows. + // For same-provider fallbacks: only relax cooldown on rate_limit, which + // is commonly model-scoped and can recover on a sibling model. + const shouldAttemptDespiteCooldown = + (params.isPrimary && (!params.requestedModel || shouldProbe)) || + (!params.isPrimary && inferredReason === "rate_limit"); + if (!shouldAttemptDespiteCooldown) { + return { + type: "skip", + reason: inferredReason, + error: `Provider ${params.candidate.provider} is in cooldown (all profiles unavailable)`, + }; + } + + return { + type: "attempt", + reason: inferredReason, + markProbe: params.isPrimary && shouldProbe, + }; +} + export async function runWithModelFallback(params: { cfg: OpenClawConfig | undefined; provider: string; @@ -342,41 +412,38 @@ export async function runWithModelFallback(params: { if (profileIds.length > 0 && !isAnyProfileAvailable) { // All profiles for this provider are in cooldown. - // For the primary model (i === 0), probe it if the soonest cooldown - // expiry is close or already past. This avoids staying on a fallback - // model long after the real rate-limit window clears. + const isPrimary = i === 0; + const requestedModel = + params.provider === candidate.provider && params.model === candidate.model; const now = Date.now(); const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir); - const shouldProbe = shouldProbePrimaryDuringCooldown({ - isPrimary: i === 0, + const decision = resolveCooldownDecision({ + candidate, + isPrimary, + requestedModel, hasFallbackCandidates, now, - throttleKey: probeThrottleKey, + probeThrottleKey, authStore, profileIds, }); - if (!shouldProbe) { - const inferredReason = - resolveProfilesUnavailableReason({ - store: authStore, - profileIds, - now, - }) ?? "rate_limit"; - // Skip without attempting + + if (decision.type === "skip") { attempts.push({ provider: candidate.provider, model: candidate.model, - error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`, - reason: inferredReason, + error: decision.error, + reason: decision.reason, }); continue; } - // Primary model probe: attempt it despite cooldown to detect recovery. - // If it fails, the error is caught below and we fall through to the - // next candidate as usual. - lastProbeAttempt.set(probeThrottleKey, now); + + if (decision.markProbe) { + lastProbeAttempt.set(probeThrottleKey, now); + } } } + try { const result = await params.run(candidate.provider, candidate.model); return { From 1f004e6640006fb7ce53f03f116e1a8b89598416 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:46:43 +0100 Subject: [PATCH 46/95] refactor(tmp): simplify trusted tmp dir state checks --- src/infra/tmp-openclaw-dir.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index 975e25b8a1a..870720b55f8 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; +const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK; type ResolvePreferredOpenClawTmpDirOptions = { accessSync?: (path: string, mode?: number) => void; @@ -66,7 +67,7 @@ export function resolvePreferredOpenClawTmpDir( return path.join(base, suffix); }; - const isTrustedPreferredDir = (st: { + const isTrustedTmpDir = (st: { isDirectory(): boolean; isSymbolicLink(): boolean; mode?: number; @@ -75,18 +76,13 @@ export function resolvePreferredOpenClawTmpDir( return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st); }; - const resolveDirState = ( - candidatePath: string, - requireWritableAccess: boolean, - ): "available" | "missing" | "invalid" => { + const resolveDirState = (candidatePath: string): "available" | "missing" | "invalid" => { try { const candidate = lstatSync(candidatePath); - if (!isTrustedPreferredDir(candidate)) { + if (!isTrustedTmpDir(candidate)) { return "invalid"; } - if (requireWritableAccess) { - accessSync(candidatePath, fs.constants.W_OK | fs.constants.X_OK); - } + accessSync(candidatePath, TMP_DIR_ACCESS_MODE); return "available"; } catch (err) { if (isNodeErrorWithCode(err, "ENOENT")) { @@ -98,7 +94,7 @@ export function resolvePreferredOpenClawTmpDir( const ensureTrustedFallbackDir = (): string => { const fallbackPath = fallback(); - const state = resolveDirState(fallbackPath, true); + const state = resolveDirState(fallbackPath); if (state === "available") { return fallbackPath; } @@ -110,13 +106,13 @@ export function resolvePreferredOpenClawTmpDir( } catch { throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`); } - if (resolveDirState(fallbackPath, true) !== "available") { + if (resolveDirState(fallbackPath) !== "available") { throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`); } return fallbackPath; }; - const existingPreferredState = resolveDirState(POSIX_OPENCLAW_TMP_DIR, true); + const existingPreferredState = resolveDirState(POSIX_OPENCLAW_TMP_DIR); if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; } @@ -125,10 +121,10 @@ export function resolvePreferredOpenClawTmpDir( } try { - accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); + accessSync("/tmp", TMP_DIR_ACCESS_MODE); // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); - if (resolveDirState(POSIX_OPENCLAW_TMP_DIR, true) !== "available") { + if (resolveDirState(POSIX_OPENCLAW_TMP_DIR) !== "available") { return ensureTrustedFallbackDir(); } return POSIX_OPENCLAW_TMP_DIR; From 347f7b9550064f5f5b33c6e07f64e85b9657b6f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:49:41 +0100 Subject: [PATCH 47/95] fix(msteams): bind file consent invokes to conversation --- CHANGELOG.md | 1 + .../src/monitor-handler.file-consent.test.ts | 220 ++++++++++++++++++ extensions/msteams/src/monitor-handler.ts | 24 +- 3 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 extensions/msteams/src/monitor-handler.file-consent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b4a5b4064..b8e6df03533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. - Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. - Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts new file mode 100644 index 00000000000..804ce58107c --- /dev/null +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -0,0 +1,220 @@ +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MSTeamsConversationStore } from "./conversation-store.js"; +import type { MSTeamsAdapter } from "./messenger.js"; +import { + type MSTeamsActivityHandler, + type MSTeamsMessageHandlerDeps, + registerMSTeamsHandlers, +} from "./monitor-handler.js"; +import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js"; +import type { MSTeamsPollStore } from "./polls.js"; +import { setMSTeamsRuntime } from "./runtime.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; + +const fileConsentMockState = vi.hoisted(() => ({ + uploadToConsentUrl: vi.fn(), +})); + +vi.mock("./file-consent.js", async () => { + const actual = await vi.importActual("./file-consent.js"); + return { + ...actual, + uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl, + }; +}); + +const runtimeStub: PluginRuntime = { + logging: { + shouldLogVerbose: () => false, + }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: () => ({ + enqueue: async () => {}, + }), + }, + }, +} as unknown as PluginRuntime; + +function createDeps(): MSTeamsMessageHandlerDeps { + const adapter: MSTeamsAdapter = { + continueConversation: async () => {}, + process: async () => {}, + }; + const conversationStore: MSTeamsConversationStore = { + upsert: async () => {}, + get: async () => null, + list: async () => [], + remove: async () => false, + findByUserId: async () => null, + }; + const pollStore: MSTeamsPollStore = { + createPoll: async () => {}, + getPoll: async () => null, + recordVote: async () => null, + }; + return { + cfg: {} as OpenClawConfig, + runtime: { + error: vi.fn(), + } as unknown as RuntimeEnv, + appId: "test-app-id", + adapter, + tokenProvider: { + getAccessToken: async () => "token", + }, + textLimit: 4000, + mediaMaxBytes: 8 * 1024 * 1024, + conversationStore, + pollStore, + log: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +} + +function createActivityHandler(): MSTeamsActivityHandler { + let handler: MSTeamsActivityHandler; + handler = { + onMessage: () => handler, + onMembersAdded: () => handler, + run: async () => {}, + }; + return handler; +} + +function createInvokeContext(params: { + conversationId: string; + uploadId: string; + action: "accept" | "decline"; +}): { context: MSTeamsTurnContext; sendActivity: ReturnType } { + const sendActivity = vi.fn(async () => ({ id: "activity-id" })); + const uploadInfo = + params.action === "accept" + ? { + name: "secret.txt", + uploadUrl: "https://upload.example.com/put", + contentUrl: "https://content.example.com/file", + uniqueId: "unique-id", + fileType: "txt", + } + : undefined; + return { + context: { + activity: { + type: "invoke", + name: "fileConsent/invoke", + conversation: { id: params.conversationId }, + value: { + type: "fileUpload", + action: params.action, + uploadInfo, + context: { uploadId: params.uploadId }, + }, + }, + sendActivity, + sendActivities: async () => [], + } as unknown as MSTeamsTurnContext, + sendActivity, + }; +} + +describe("msteams file consent invoke authz", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + clearPendingUploads(); + fileConsentMockState.uploadToConsentUrl.mockReset(); + fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); + }); + + it("uploads when invoke conversation matches pending upload conversation", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:victim@thread.v2;messageid=abc123", + uploadId, + action: "accept", + }); + + await handler.run?.(context); + + expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); + expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://upload.example.com/put", + }), + ); + expect(getPendingUpload(uploadId)).toBeUndefined(); + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + }); + + it("rejects cross-conversation accept invoke and keeps pending upload", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:attacker@thread.v2", + uploadId, + action: "accept", + }); + + await handler.run?.(context); + + expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); + expect(getPendingUpload(uploadId)).toBeDefined(); + expect(sendActivity).toHaveBeenCalledWith( + "The file upload request has expired. Please try sending the file again.", + ); + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + }); + + it("ignores cross-conversation decline invoke and keeps pending upload", async () => { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: "19:victim@thread.v2", + }); + const deps = createDeps(); + const handler = registerMSTeamsHandlers(createActivityHandler(), deps); + const { context, sendActivity } = createInvokeContext({ + conversationId: "19:attacker@thread.v2", + uploadId, + action: "decline", + }); + + await handler.run?.(context); + + expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); + expect(getPendingUpload(uploadId)).toBeDefined(); + expect(sendActivity).toHaveBeenCalledTimes(1); + expect(sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + }), + ); + }); +}); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index d4b848fde5a..086b82d496a 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import type { MSTeamsConversationStore } from "./conversation-store.js"; import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js"; +import { normalizeMSTeamsConversationId } from "./inbound.js"; import type { MSTeamsAdapter } from "./messenger.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; @@ -42,6 +43,8 @@ async function handleFileConsentInvoke( context: MSTeamsTurnContext, log: MSTeamsMonitorLogger, ): Promise { + const expiredUploadMessage = + "The file upload request has expired. Please try sending the file again."; const activity = context.activity; if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") { return false; @@ -57,9 +60,24 @@ async function handleFileConsentInvoke( typeof consentResponse.context?.uploadId === "string" ? consentResponse.context.uploadId : undefined; + const pendingFile = getPendingUpload(uploadId); + if (pendingFile) { + const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId); + const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); + if (!invokeConversationId || pendingConversationId !== invokeConversationId) { + log.info("file consent conversation mismatch", { + uploadId, + expectedConversationId: pendingConversationId, + receivedConversationId: invokeConversationId || undefined, + }); + if (consentResponse.action === "accept") { + await context.sendActivity(expiredUploadMessage); + } + return true; + } + } if (consentResponse.action === "accept" && consentResponse.uploadInfo) { - const pendingFile = getPendingUpload(uploadId); if (pendingFile) { log.debug?.("user accepted file consent, uploading", { uploadId, @@ -101,9 +119,7 @@ async function handleFileConsentInvoke( } } else { log.debug?.("pending file not found for consent", { uploadId }); - await context.sendActivity( - "The file upload request has expired. Please try sending the file again.", - ); + await context.sendActivity(expiredUploadMessage); } } else { // User declined From b8bb8ab3ca421ed1405833c8c3e134d3ab2236e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:59:10 +0100 Subject: [PATCH 48/95] docs: clarify personal-by-default onboarding security notice --- CHANGELOG.md | 1 + docs/start/onboarding.md | 5 +++++ src/wizard/onboarding.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e6df03533..f67fa020bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. +- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. ### Fixes diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index e9f2edeb363..679ab059f45 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -29,6 +29,11 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb + +Security trust model: + +- By default, OpenClaw is a personal agent: one trusted operator boundary. +- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index df826b62ccf..301375fbb59 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -31,15 +31,21 @@ async function requireRiskAcknowledgement(params: { "Security warning — please read.", "", "OpenClaw is a hobby project and still in beta. Expect sharp edges.", + "By default, OpenClaw is a personal agent: one trusted operator boundary.", "This bot can read files and run actions if tools are enabled.", "A bad prompt can trick it into doing unsafe things.", "", - "If you’re not comfortable with basic security and access control, don’t run OpenClaw.", + "OpenClaw is not a hostile multi-tenant boundary by default.", + "If multiple users can message one tool-enabled agent, they share that delegated tool authority.", + "", + "If you’re not comfortable with security hardening and access control, don’t run OpenClaw.", "Ask someone experienced to help before enabling tools or exposing it to the internet.", "", "Recommended baseline:", "- Pairing/allowlists + mention gating.", + "- Multi-user/shared inbox: split trust boundaries (separate gateway/credentials, ideally separate OS users/hosts).", "- Sandbox + least-privilege tools.", + "- Shared inboxes: isolate DM sessions (`session.dmScope: per-channel-peer`) and keep tool access minimal.", "- Keep secrets out of the agent’s reachable filesystem.", "- Use the strongest available model for any bot with tools or untrusted inboxes.", "", @@ -53,7 +59,8 @@ async function requireRiskAcknowledgement(params: { ); const ok = await params.prompter.confirm({ - message: "I understand this is powerful and inherently risky. Continue?", + message: + "I understand this is personal-by-default and shared/multi-user use requires lock-down. Continue?", initialValue: false, }); if (!ok) { From 00fc1f56f1a0dc675efd02414ea1315fab686668 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 20:44:43 +0530 Subject: [PATCH 49/95] perf(android): remove startup bc provider registration --- .../app/src/main/java/ai/openclaw/android/NodeApp.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt index 2be9ee71a2c..ab5e159cf47 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt @@ -2,23 +2,12 @@ package ai.openclaw.android import android.app.Application import android.os.StrictMode -import android.util.Log -import java.security.Security class NodeApp : Application() { val runtime: NodeRuntime by lazy { NodeRuntime(this) } override fun onCreate() { super.onCreate() - // Register Bouncy Castle as highest-priority provider for Ed25519 support - try { - val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") - .getDeclaredConstructor().newInstance() as java.security.Provider - Security.removeProvider("BC") - Security.insertProviderAt(bcProvider, 1) - } catch (it: Throwable) { - Log.e("NodeApp", "Failed to register Bouncy Castle provider", it) - } if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder() From 8d681997930c547aef407447f152e83d3b0a6772 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 20:44:46 +0530 Subject: [PATCH 50/95] perf(android): cache device identity and speed hex encoding --- .../android/gateway/DeviceIdentityStore.kt | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt index ff651c6c17b..68830772f9a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt @@ -3,11 +3,7 @@ package ai.openclaw.android.gateway import android.content.Context import android.util.Base64 import java.io.File -import java.security.KeyFactory -import java.security.KeyPairGenerator import java.security.MessageDigest -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -22,21 +18,26 @@ data class DeviceIdentity( class DeviceIdentityStore(context: Context) { private val json = Json { ignoreUnknownKeys = true } private val identityFile = File(context.filesDir, "openclaw/identity/device.json") + @Volatile private var cachedIdentity: DeviceIdentity? = null @Synchronized fun loadOrCreate(): DeviceIdentity { + cachedIdentity?.let { return it } val existing = load() if (existing != null) { val derived = deriveDeviceId(existing.publicKeyRawBase64) if (derived != null && derived != existing.deviceId) { val updated = existing.copy(deviceId = derived) save(updated) + cachedIdentity = updated return updated } + cachedIdentity = existing return existing } val fresh = generate() save(fresh) + cachedIdentity = fresh return fresh } @@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) { } } - private fun stripSpkiPrefix(spki: ByteArray): ByteArray { - if (spki.size == ED25519_SPKI_PREFIX.size + 32 && - spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) - ) { - return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) - } - return spki - } - private fun sha256Hex(data: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) + val out = CharArray(digest.size * 2) + var i = 0 for (byte in digest) { - out.append(String.format("%02x", byte)) + val v = byte.toInt() and 0xff + out[i++] = HEX[v ushr 4] + out[i++] = HEX[v and 0x0f] } - return out.toString() + return String(out) } private fun base64UrlEncode(data: ByteArray): String { @@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) { } companion object { - private val ED25519_SPKI_PREFIX = - byteArrayOf( - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ) + private val HEX = "0123456789abcdef".toCharArray() } } From 4a07c89816db0ab67b2e6a5d2a669b5c73afe82a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 20:44:50 +0530 Subject: [PATCH 51/95] perf(android): make gateway token writes async --- .../app/src/main/java/ai/openclaw/android/SecurePrefs.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index f03e2b56e0b..1637c928f4a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -153,7 +153,7 @@ class SecurePrefs(context: Context) { fun setGatewayToken(value: String) { val trimmed = value.trim() - prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) } + prefs.edit { putString("gateway.manual.token", trimmed) } _gatewayToken.value = trimmed } From b49c2cbdd9c3108ae665e48d23be9dc31ff39f56 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 21:34:15 +0530 Subject: [PATCH 52/95] perf(android): tighten startup path and add perf tooling --- .../java/ai/openclaw/android/MainActivity.kt | 8 +- .../java/ai/openclaw/android/SecurePrefs.kt | 113 +++++++------ apps/android/benchmark/build.gradle.kts | 36 ++++ .../benchmark/StartupMacrobenchmark.kt | 76 +++++++++ apps/android/build.gradle.kts | 1 + .../android/scripts/perf-startup-benchmark.sh | 124 ++++++++++++++ apps/android/scripts/perf-startup-hotspots.sh | 154 ++++++++++++++++++ apps/android/settings.gradle.kts | 1 + 8 files changed, 454 insertions(+), 59 deletions(-) create mode 100644 apps/android/benchmark/build.gradle.kts create mode 100644 apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt create mode 100755 apps/android/scripts/perf-startup-benchmark.sh create mode 100755 apps/android/scripts/perf-startup-hotspots.sh diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt index 21d0f15ff7a..b90427672c6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt @@ -1,9 +1,7 @@ package ai.openclaw.android -import android.content.pm.ApplicationInfo import android.os.Bundle import android.view.WindowManager -import android.webkit.WebView import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -25,9 +23,6 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) - val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 - WebView.setWebContentsDebuggingEnabled(isDebuggable) - NodeForegroundService.start(this) permissionRequester = PermissionRequester(this) screenCaptureRequester = ScreenCaptureRequester(this) viewModel.camera.attachLifecycleOwner(this) @@ -55,6 +50,9 @@ class MainActivity : ComponentActivity() { } } } + + // Keep startup path lean: start foreground service after first frame. + window.decorView.post { NodeForegroundService.start(this) } } override fun onStart() { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 1637c928f4a..96e4572955e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -20,19 +20,21 @@ class SecurePrefs(context: Context) { val defaultWakeWords: List = listOf("openclaw", "claude") private const val displayNameKey = "node.displayName" private const val voiceWakeModeKey = "voiceWake.mode" + private const val plainPrefsName = "openclaw.node" + private const val securePrefsName = "openclaw.node.secure" } private val appContext = context.applicationContext private val json = Json { ignoreUnknownKeys = true } + private val plainPrefs: SharedPreferences = + appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE) - private val masterKey = - MasterKey.Builder(context) + private val masterKey by lazy { + MasterKey.Builder(appContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - - private val prefs: SharedPreferences by lazy { - createPrefs(appContext, "openclaw.node.secure") } + private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) } private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) val instanceId: StateFlow = _instanceId @@ -41,52 +43,51 @@ class SecurePrefs(context: Context) { MutableStateFlow(loadOrMigrateDisplayName(context = context)) val displayName: StateFlow = _displayName - private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) + private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true)) val cameraEnabled: StateFlow = _cameraEnabled private val _locationMode = - MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) + MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off"))) val locationMode: StateFlow = _locationMode private val _locationPreciseEnabled = - MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) + MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true)) val locationPreciseEnabled: StateFlow = _locationPreciseEnabled - private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) + private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true)) val preventSleep: StateFlow = _preventSleep private val _manualEnabled = - MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false)) + MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false)) val manualEnabled: StateFlow = _manualEnabled private val _manualHost = - MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "") + MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "") val manualHost: StateFlow = _manualHost private val _manualPort = - MutableStateFlow(prefs.getInt("gateway.manual.port", 18789)) + MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789)) val manualPort: StateFlow = _manualPort private val _manualTls = - MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) + MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true)) val manualTls: StateFlow = _manualTls - private val _gatewayToken = - MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") + private val _gatewayToken = MutableStateFlow("") val gatewayToken: StateFlow = _gatewayToken private val _onboardingCompleted = - MutableStateFlow(prefs.getBoolean("onboarding.completed", false)) + MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false)) val onboardingCompleted: StateFlow = _onboardingCompleted private val _lastDiscoveredStableId = MutableStateFlow( - prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", + plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "", ) val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId private val _canvasDebugStatusEnabled = - MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) + MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false)) val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled private val _wakeWords = MutableStateFlow(loadWakeWords()) @@ -95,65 +96,65 @@ class SecurePrefs(context: Context) { private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) val voiceWakeMode: StateFlow = _voiceWakeMode - private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) + private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false)) val talkEnabled: StateFlow = _talkEnabled fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() - prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } + plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } _lastDiscoveredStableId.value = trimmed } fun setDisplayName(value: String) { val trimmed = value.trim() - prefs.edit { putString(displayNameKey, trimmed) } + plainPrefs.edit { putString(displayNameKey, trimmed) } _displayName.value = trimmed } fun setCameraEnabled(value: Boolean) { - prefs.edit { putBoolean("camera.enabled", value) } + plainPrefs.edit { putBoolean("camera.enabled", value) } _cameraEnabled.value = value } fun setLocationMode(mode: LocationMode) { - prefs.edit { putString("location.enabledMode", mode.rawValue) } + plainPrefs.edit { putString("location.enabledMode", mode.rawValue) } _locationMode.value = mode } fun setLocationPreciseEnabled(value: Boolean) { - prefs.edit { putBoolean("location.preciseEnabled", value) } + plainPrefs.edit { putBoolean("location.preciseEnabled", value) } _locationPreciseEnabled.value = value } fun setPreventSleep(value: Boolean) { - prefs.edit { putBoolean("screen.preventSleep", value) } + plainPrefs.edit { putBoolean("screen.preventSleep", value) } _preventSleep.value = value } fun setManualEnabled(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.enabled", value) } + plainPrefs.edit { putBoolean("gateway.manual.enabled", value) } _manualEnabled.value = value } fun setManualHost(value: String) { val trimmed = value.trim() - prefs.edit { putString("gateway.manual.host", trimmed) } + plainPrefs.edit { putString("gateway.manual.host", trimmed) } _manualHost.value = trimmed } fun setManualPort(value: Int) { - prefs.edit { putInt("gateway.manual.port", value) } + plainPrefs.edit { putInt("gateway.manual.port", value) } _manualPort.value = value } fun setManualTls(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.tls", value) } + plainPrefs.edit { putBoolean("gateway.manual.tls", value) } _manualTls.value = value } fun setGatewayToken(value: String) { val trimmed = value.trim() - prefs.edit { putString("gateway.manual.token", trimmed) } + securePrefs.edit { putString("gateway.manual.token", trimmed) } _gatewayToken.value = trimmed } @@ -162,62 +163,67 @@ class SecurePrefs(context: Context) { } fun setOnboardingCompleted(value: Boolean) { - prefs.edit { putBoolean("onboarding.completed", value) } + plainPrefs.edit { putBoolean("onboarding.completed", value) } _onboardingCompleted.value = value } fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } + plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) } _canvasDebugStatusEnabled.value = value } fun loadGatewayToken(): String? { - val manual = _gatewayToken.value.trim() + val manual = + _gatewayToken.value.trim().ifEmpty { + val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty() + if (stored.isNotEmpty()) _gatewayToken.value = stored + stored + } if (manual.isNotEmpty()) return manual val key = "gateway.token.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() + val stored = securePrefs.getString(key, null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveGatewayToken(token: String) { val key = "gateway.token.${_instanceId.value}" - prefs.edit { putString(key, token.trim()) } + securePrefs.edit { putString(key, token.trim()) } } fun loadGatewayPassword(): String? { val key = "gateway.password.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() + val stored = securePrefs.getString(key, null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveGatewayPassword(password: String) { val key = "gateway.password.${_instanceId.value}" - prefs.edit { putString(key, password.trim()) } + securePrefs.edit { putString(key, password.trim()) } } fun loadGatewayTlsFingerprint(stableId: String): String? { val key = "gateway.tls.$stableId" - return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } + return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } } fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { val key = "gateway.tls.$stableId" - prefs.edit { putString(key, fingerprint.trim()) } + plainPrefs.edit { putString(key, fingerprint.trim()) } } fun getString(key: String): String? { - return prefs.getString(key, null) + return securePrefs.getString(key, null) } fun putString(key: String, value: String) { - prefs.edit { putString(key, value) } + securePrefs.edit { putString(key, value) } } fun remove(key: String) { - prefs.edit { remove(key) } + securePrefs.edit { remove(key) } } - private fun createPrefs(context: Context, name: String): SharedPreferences { + private fun createSecurePrefs(context: Context, name: String): SharedPreferences { return EncryptedSharedPreferences.create( context, name, @@ -228,21 +234,21 @@ class SecurePrefs(context: Context) { } private fun loadOrCreateInstanceId(): String { - val existing = prefs.getString("node.instanceId", null)?.trim() + val existing = plainPrefs.getString("node.instanceId", null)?.trim() if (!existing.isNullOrBlank()) return existing val fresh = UUID.randomUUID().toString() - prefs.edit { putString("node.instanceId", fresh) } + plainPrefs.edit { putString("node.instanceId", fresh) } return fresh } private fun loadOrMigrateDisplayName(context: Context): String { - val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() + val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty() if (existing.isNotEmpty() && existing != "Android Node") return existing val candidate = DeviceNames.bestDefaultNodeName(context).trim() val resolved = candidate.ifEmpty { "Android Node" } - prefs.edit { putString(displayNameKey, resolved) } + plainPrefs.edit { putString(displayNameKey, resolved) } return resolved } @@ -250,34 +256,34 @@ class SecurePrefs(context: Context) { val sanitized = WakeWords.sanitize(words, defaultWakeWords) val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString() - prefs.edit { putString("voiceWake.triggerWords", encoded) } + plainPrefs.edit { putString("voiceWake.triggerWords", encoded) } _wakeWords.value = sanitized } fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } + plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) } _voiceWakeMode.value = mode } fun setTalkEnabled(value: Boolean) { - prefs.edit { putBoolean("talk.enabled", value) } + plainPrefs.edit { putBoolean("talk.enabled", value) } _talkEnabled.value = value } private fun loadVoiceWakeMode(): VoiceWakeMode { - val raw = prefs.getString(voiceWakeModeKey, null) + val raw = plainPrefs.getString(voiceWakeModeKey, null) val resolved = VoiceWakeMode.fromRawValue(raw) // Default ON (foreground) when unset. if (raw.isNullOrBlank()) { - prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } + plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } } return resolved } private fun loadWakeWords(): List { - val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() + val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim() if (raw.isNullOrEmpty()) return defaultWakeWords return try { val element = json.parseToJsonElement(raw) @@ -295,5 +301,4 @@ class SecurePrefs(context: Context) { defaultWakeWords } } - } diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts new file mode 100644 index 00000000000..99d1d8e4c60 --- /dev/null +++ b/apps/android/benchmark/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("com.android.test") +} + +android { + namespace = "ai.openclaw.android.benchmark" + compileSdk = 36 + + defaultConfig { + minSdk = 31 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR" + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +dependencies { + implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1") + implementation("androidx.test.ext:junit:1.2.1") + implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06") +} diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt new file mode 100644 index 00000000000..46181f6a9a1 --- /dev/null +++ b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt @@ -0,0 +1,76 @@ +package ai.openclaw.android.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StartupMacrobenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + private val packageName = "ai.openclaw.android" + + @Test + fun coldStartup() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(StartupTimingMetric()), + startupMode = StartupMode.COLD, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + pressHome() + startActivityAndWait() + } + } + } + + @Test + fun startupAndScrollFrameTiming() { + runBenchmarkOrSkip { + benchmarkRule.measureRepeated( + packageName = packageName, + metrics = listOf(FrameTimingMetric()), + startupMode = StartupMode.WARM, + compilationMode = CompilationMode.None(), + iterations = 10, + ) { + startActivityAndWait() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val x = device.displayWidth / 2 + val yStart = (device.displayHeight * 0.8f).toInt() + val yEnd = (device.displayHeight * 0.25f).toInt() + repeat(4) { + device.swipe(x, yStart, x, yEnd, 24) + device.waitForIdle() + } + } + } + } + + private fun runBenchmarkOrSkip(run: () -> Unit) { + try { + run() + } catch (err: IllegalStateException) { + val message = err.message.orEmpty() + val knownDeviceIssue = + message.contains("Unable to confirm activity launch completion") || + message.contains("no renderthread slices", ignoreCase = true) + if (knownDeviceIssue) { + assumeTrue("Skipping benchmark on this device: $message", false) + } + throw err + } + } +} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index bea7b46b2c2..1d191c9e375 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("com.android.application") version "9.0.1" apply false + id("com.android.test") version "9.0.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false } diff --git a/apps/android/scripts/perf-startup-benchmark.sh b/apps/android/scripts/perf-startup-benchmark.sh new file mode 100755 index 00000000000..70342d3cba4 --- /dev/null +++ b/apps/android/scripts/perf-startup-benchmark.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +RESULTS_DIR="$ANDROID_DIR/benchmark/results" +CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup" +BASELINE_JSON="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-benchmark.sh [--baseline ] + +Runs cold-start macrobenchmark only, then prints a compact summary. +Also saves a timestamped snapshot JSON under benchmark/results/. +If --baseline is omitted, compares against latest previous snapshot when available. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --baseline) + BASELINE_JSON="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +mkdir -p "$RESULTS_DIR" + +run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)" +trap 'rm -f "$run_log"' EXIT + +cd "$ANDROID_DIR" + +./gradlew :benchmark:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \ + --console=plain \ + >"$run_log" 2>&1 + +latest_json="$( + find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \ + -name '*benchmarkData.json' -type f \ + | while IFS= read -r file; do + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- +)" + +if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then + echo "benchmarkData.json not found after run." >&2 + tail -n 120 "$run_log" >&2 + exit 1 +fi + +timestamp="$(date +%Y%m%d-%H%M%S)" +snapshot_json="$RESULTS_DIR/startup-$timestamp.json" +cp "$latest_json" "$snapshot_json" + +median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")" +min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")" +max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")" +cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")" +device="$(jq -r '.context.build.model' "$snapshot_json")" +sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")" +runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")" + +printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \ + "$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk" +echo "snapshot_json=$snapshot_json" + +if [[ -z "$BASELINE_JSON" ]]; then + BASELINE_JSON="$( + find "$RESULTS_DIR" -name 'startup-*.json' -type f \ + | while IFS= read -r file; do + if [[ "$file" == "$snapshot_json" ]]; then + continue + fi + printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file" + done \ + | sort -nr \ + | head -n1 \ + | cut -f2- + )" +fi + +if [[ -n "$BASELINE_JSON" ]]; then + if [[ ! -f "$BASELINE_JSON" ]]; then + echo "Baseline file missing: $BASELINE_JSON" >&2 + exit 1 + fi + base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")" + delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')" + delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')" + echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%" +fi diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh new file mode 100755 index 00000000000..787d5fac300 --- /dev/null +++ b/apps/android/scripts/perf-startup-hotspots.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +PACKAGE="ai.openclaw.android" +ACTIVITY=".MainActivity" +DURATION_SECONDS="10" +OUTPUT_PERF_DATA="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/perf-startup-hotspots.sh [--package ] [--activity ] [--duration ] [--out ] + +Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries. +Default package/activity target OpenClaw Android startup. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --package) + PACKAGE="${2:-}" + shift 2 + ;; + --activity) + ACTIVITY="${2:-}" + shift 2 + ;; + --duration) + DURATION_SECONDS="${2:-}" + shift 2 + ;; + --out) + OUTPUT_PERF_DATA="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v uv >/dev/null 2>&1; then + echo "uv required but missing." >&2 + exit 1 +fi + +if ! command -v adb >/dev/null 2>&1; then + echo "adb required but missing." >&2 + exit 1 +fi + +if [[ -z "$OUTPUT_PERF_DATA" ]]; then + OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data" +fi + +device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')" +if [[ "$device_count" -lt 1 ]]; then + echo "No connected Android device (adb state=device)." >&2 + exit 1 +fi + +simpleperf_dir="" +if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf" +elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then + simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf" +else + latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)" + if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then + simpleperf_dir="$latest_simpleperf" + fi +fi + +if [[ -z "$simpleperf_dir" ]]; then + echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2 + exit 1 +fi + +app_profiler="$simpleperf_dir/app_profiler.py" +report_py="$simpleperf_dir/report.py" +ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)" + +tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)" +trap 'rm -rf "$tmp_dir"' EXIT + +capture_log="$tmp_dir/capture.log" +dso_csv="$tmp_dir/dso.csv" +symbols_csv="$tmp_dir/symbols.csv" +children_txt="$tmp_dir/children.txt" + +cd "$ANDROID_DIR" +./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1 + +if ! uv run --no-project python3 "$app_profiler" \ + -p "$PACKAGE" \ + -a "$ACTIVITY" \ + -o "$OUTPUT_PERF_DATA" \ + --ndk_path "$ndk_path" \ + -r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \ + >"$capture_log" 2>&1; then + echo "simpleperf capture failed. tail(capture_log):" >&2 + tail -n 120 "$capture_log" >&2 + exit 1 +fi + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$dso_csv" 2>"$tmp_dir/report-dso.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --sort dso,symbol \ + --csv \ + --csv-separator "|" \ + --include-process-name "$PACKAGE" \ + >"$symbols_csv" 2>"$tmp_dir/report-symbols.err" + +uv run --no-project python3 "$report_py" \ + -i "$OUTPUT_PERF_DATA" \ + --children \ + --sort dso,symbol \ + -n \ + --percent-limit 0.2 \ + --include-process-name "$PACKAGE" \ + >"$children_txt" 2>"$tmp_dir/report-children.err" + +clean_csv() { + awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1" +} + +echo "perf_data=$OUTPUT_PERF_DATA" +echo +echo "top_dso_self:" +clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}' +echo +echo "top_symbols_self:" +clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}' +echo +echo "app_path_clues_children:" +rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts index b3b43a44550..25e5d09bbe1 100644 --- a/apps/android/settings.gradle.kts +++ b/apps/android/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "OpenClawNodeAndroid" include(":app") +include(":benchmark") From 3175640ea2dee69f9f2a5bc25c8183cebf23846f Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 25 Feb 2026 21:34:31 +0530 Subject: [PATCH 53/95] docs(android): add perf CLI workflow docs --- apps/android/README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/android/README.md b/apps/android/README.md index 799109c0a0f..4a9951e6441 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -34,6 +34,40 @@ cd apps/android `gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. +## Macrobenchmark (Startup + Frame Timing) + +```bash +cd apps/android +./gradlew :benchmark:connectedDebugAndroidTest +``` + +Reports are written under: + +- `apps/android/benchmark/build/reports/androidTests/connected/` + +## Perf CLI (low-noise) + +Deterministic startup measurement + hotspot extraction with compact CLI output: + +```bash +cd apps/android +./scripts/perf-startup-benchmark.sh +./scripts/perf-startup-hotspots.sh +``` + +Benchmark script behavior: + +- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations). +- Prints median/min/max/COV in one line. +- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`. +- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline `). + +Hotspot script behavior: + +- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`. +- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView). +- Writes raw `perf.data` path for deeper follow-up if needed. + ## Run on a Real Android Phone (USB) 1) On phone, enable **Developer options** + **USB debugging**. From 410ba918fb7b60b9a777b20589d9494683ae9456 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 07:44:15 +0530 Subject: [PATCH 54/95] fix(android): hydrate gateway token state on init --- .../app/src/main/java/ai/openclaw/android/NodeRuntime.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 15d99ffb931..02e9b136091 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -450,6 +450,10 @@ class NodeRuntime(context: Context) { prefs.setVoiceWakeMode(VoiceWakeMode.Off) } + scope.launch { + prefs.loadGatewayToken() + } + scope.launch { prefs.talkEnabled.collect { enabled -> micCapture.setMicEnabled(enabled) From 958cafc54f8d0638cf3342a7b2c453aebec1fc7d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 07:49:44 +0530 Subject: [PATCH 55/95] fix: add changelog note for android startup perf (#26659) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67fa020bcf..01023da69a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. +- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus. - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. From 069bbf9741bcbcad2db54349bf02668f66cf5eb0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:20:40 +0000 Subject: [PATCH 56/95] fix(slack): land #26878 allowlist channel ID case-insensitive match (thanks @lbo728) Land contributor PR #26878 from @lbo728; include changelog credit and regression tests. Co-authored-by: lbo728 --- CHANGELOG.md | 1 + src/slack/monitor/channel-config.ts | 8 ++++++++ src/slack/monitor/monitor.test.ts | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01023da69a3..df98ab8f376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. - Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. - Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. +- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. - Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts index 15ba7c3b146..b594a34d43b 100644 --- a/src/slack/monitor/channel-config.ts +++ b/src/slack/monitor/channel-config.ts @@ -96,8 +96,16 @@ export function resolveSlackChannelConfig(params: { const keys = Object.keys(entries); const normalizedName = channelName ? normalizeSlackSlug(channelName) : ""; const directName = channelName ? channelName.trim() : ""; + // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345) but + // operators commonly write them in lowercase in their config. Add both + // case variants so the lookup is case-insensitive without requiring a full + // entry-scan. buildChannelKeyCandidates deduplicates identical keys. + const channelIdLower = channelId.toLowerCase(); + const channelIdUpper = channelId.toUpperCase(); const candidates = buildChannelKeyCandidates( channelId, + channelIdLower !== channelId ? channelIdLower : undefined, + channelIdUpper !== channelId ? channelIdUpper : undefined, channelName ? `#${directName}` : undefined, directName, normalizedName, diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 3262873718d..3da7f08164e 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -60,6 +60,27 @@ describe("resolveSlackChannelConfig", () => { matchSource: "direct", }); }); + + it("matches channel config key stored in lowercase when Slack delivers uppercase channel ID", () => { + // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345). + // Users commonly copy them in lowercase from docs or older CLI output. + const res = resolveSlackChannelConfig({ + channelId: "C0ABC12345", + channels: { c0abc12345: { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ allowed: true, requireMention: false }); + }); + + it("matches channel config key stored in uppercase when user types lowercase channel ID", () => { + // Defensive: also handle the inverse direction. + const res = resolveSlackChannelConfig({ + channelId: "c0abc12345", + channels: { C0ABC12345: { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ allowed: true, requireMention: false }); + }); }); const baseParams = () => ({ From b786d11fea377d97d5c07cadee7a0f8bc8e0a168 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:32:36 +0100 Subject: [PATCH 57/95] refactor(telegram): simplify polling restart flow --- src/telegram/monitor.test.ts | 173 ++++++++++++++++++++++------------- src/telegram/monitor.ts | 136 ++++++++++++++------------- 2 files changed, 184 insertions(+), 125 deletions(-) diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 49fbcc13155..4e59f6c0c6a 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -67,6 +67,36 @@ const { startTelegramWebhookSpy } = vi.hoisted(() => ({ startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })), })); +type RunnerStub = { + task: () => Promise; + stop: ReturnType void | Promise>>; + isRunning: () => boolean; +}; + +const makeRunnerStub = (overrides: Partial = {}): RunnerStub => ({ + task: overrides.task ?? (() => Promise.resolve()), + stop: overrides.stop ?? vi.fn<() => void | Promise>(), + isRunning: overrides.isRunning ?? (() => false), +}); + +async function monitorWithAutoAbort( + opts: Omit[0], "abortSignal"> = {}, +) { + const abort = new AbortController(); + runSpy.mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); + await monitorTelegramProvider({ + token: "tok", + ...opts, + abortSignal: abort.signal, + }); +} + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -149,7 +179,7 @@ describe("monitorTelegramProvider (grammY)", () => { Object.values(api).forEach((fn) => { fn?.mockReset?.(); }); - await monitorTelegramProvider({ token: "tok" }); + await monitorWithAutoAbort(); expect(handlers.message).toBeDefined(); await handlers.message?.({ message: { @@ -172,7 +202,7 @@ describe("monitorTelegramProvider (grammY)", () => { channels: { telegram: {} }, }); - await monitorTelegramProvider({ token: "tok" }); + await monitorWithAutoAbort(); expect(runSpy).toHaveBeenCalledWith( expect.anything(), @@ -180,7 +210,7 @@ describe("monitorTelegramProvider (grammY)", () => { sink: { concurrency: 3 }, runner: expect.objectContaining({ silent: true, - maxRetryTime: 5 * 60 * 1000, + maxRetryTime: 60 * 60 * 1000, retryInterval: "exponential", }), }), @@ -191,7 +221,7 @@ describe("monitorTelegramProvider (grammY)", () => { Object.values(api).forEach((fn) => { fn?.mockReset?.(); }); - await monitorTelegramProvider({ token: "tok" }); + await monitorWithAutoAbort(); await handlers.message?.({ message: { message_id: 2, @@ -205,24 +235,27 @@ describe("monitorTelegramProvider (grammY)", () => { }); it("retries on recoverable undici fetch errors", async () => { + const abort = new AbortController(); const networkError = Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("connect timeout"), { code: "UND_ERR_CONNECT_TIMEOUT", }), }); runSpy - .mockImplementationOnce(() => ({ - task: () => Promise.reject(networkError), - stop: vi.fn(), - isRunning: (): boolean => false, - })) - .mockImplementationOnce(() => ({ - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: (): boolean => false, - })); + .mockImplementationOnce(() => + makeRunnerStub({ + task: () => Promise.reject(networkError), + }), + ) + .mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); - await monitorTelegramProvider({ token: "tok" }); + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(computeBackoff).toHaveBeenCalled(); expect(sleepWithAbort).toHaveBeenCalled(); @@ -230,6 +263,7 @@ describe("monitorTelegramProvider (grammY)", () => { }); it("deletes webhook before starting polling", async () => { + const abort = new AbortController(); const order: string[] = []; api.deleteWebhook.mockReset(); api.deleteWebhook.mockImplementationOnce(async () => { @@ -238,20 +272,21 @@ describe("monitorTelegramProvider (grammY)", () => { }); runSpy.mockImplementationOnce(() => { order.push("run"); - return { - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: () => false, - }; + return makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }); }); - await monitorTelegramProvider({ token: "tok" }); + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(api.deleteWebhook).toHaveBeenCalledWith({ drop_pending_updates: false }); expect(order).toEqual(["deleteWebhook", "run"]); }); it("retries recoverable deleteWebhook failures before polling", async () => { + const abort = new AbortController(); const cleanupError = Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("connect timeout"), { code: "UND_ERR_CONNECT_TIMEOUT", @@ -259,13 +294,15 @@ describe("monitorTelegramProvider (grammY)", () => { }); api.deleteWebhook.mockReset(); api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true); - runSpy.mockImplementationOnce(() => ({ - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: () => false, - })); + runSpy.mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); - await monitorTelegramProvider({ token: "tok" }); + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(api.deleteWebhook).toHaveBeenCalledTimes(2); expect(computeBackoff).toHaveBeenCalled(); @@ -274,6 +311,7 @@ describe("monitorTelegramProvider (grammY)", () => { }); it("retries setup-time recoverable errors before starting polling", async () => { + const abort = new AbortController(); const setupError = Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("connect timeout"), { code: "UND_ERR_CONNECT_TIMEOUT", @@ -281,13 +319,15 @@ describe("monitorTelegramProvider (grammY)", () => { }); createTelegramBotErrors.push(setupError); - runSpy.mockImplementationOnce(() => ({ - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: () => false, - })); + runSpy.mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); - await monitorTelegramProvider({ token: "tok" }); + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(computeBackoff).toHaveBeenCalled(); expect(sleepWithAbort).toHaveBeenCalled(); @@ -295,6 +335,7 @@ describe("monitorTelegramProvider (grammY)", () => { }); it("awaits runner.stop before retrying after recoverable polling error", async () => { + const abort = new AbortController(); const recoverableError = Object.assign(new TypeError("fetch failed"), { cause: Object.assign(new Error("connect timeout"), { code: "UND_ERR_CONNECT_TIMEOUT", @@ -307,21 +348,22 @@ describe("monitorTelegramProvider (grammY)", () => { }); runSpy - .mockImplementationOnce(() => ({ - task: () => Promise.reject(recoverableError), - stop: firstStop, - isRunning: () => false, - })) + .mockImplementationOnce(() => + makeRunnerStub({ + task: () => Promise.reject(recoverableError), + stop: firstStop, + }), + ) .mockImplementationOnce(() => { expect(firstStopped).toBe(true); - return { - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: () => false, - }; + return makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }); }); - await monitorTelegramProvider({ token: "tok" }); + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(firstStop).toHaveBeenCalled(); expect(computeBackoff).toHaveBeenCalled(); @@ -330,16 +372,17 @@ describe("monitorTelegramProvider (grammY)", () => { }); it("surfaces non-recoverable errors", async () => { - runSpy.mockImplementationOnce(() => ({ - task: () => Promise.reject(new Error("bad token")), - stop: vi.fn(), - isRunning: (): boolean => false, - })); + runSpy.mockImplementationOnce(() => + makeRunnerStub({ + task: () => Promise.reject(new Error("bad token")), + }), + ); await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token"); }); it("force-restarts polling when unhandled network rejection stalls runner", async () => { + const abort = new AbortController(); let running = true; let releaseTask: (() => void) | undefined; const stop = vi.fn(async () => { @@ -348,21 +391,25 @@ describe("monitorTelegramProvider (grammY)", () => { }); runSpy - .mockImplementationOnce(() => ({ - task: () => - new Promise((resolve) => { - releaseTask = resolve; - }), - stop, - isRunning: () => running, - })) - .mockImplementationOnce(() => ({ - task: () => Promise.resolve(), - stop: vi.fn(), - isRunning: () => false, - })); + .mockImplementationOnce(() => + makeRunnerStub({ + task: () => + new Promise((resolve) => { + releaseTask = resolve; + }), + stop, + isRunning: () => running, + }), + ) + .mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); - const monitor = monitorTelegramProvider({ token: "tok" }); + const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1)); expect(emitUnhandledRejection(new TypeError("fetch failed"))).toBe(true); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 8c93eee60c9..579db8ad3a1 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -45,9 +45,8 @@ export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions; + const isGetUpdatesConflict = (err: unknown) => { if (!err || typeof err !== "object") { return false; @@ -188,21 +189,11 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { let restartAttempts = 0; let webhookCleared = false; const runnerOptions = createTelegramRunnerOptions(cfg); - const waitBeforeRetryOnRecoverableSetupError = async ( - err: unknown, - logPrefix: string, - ): Promise => { - if (opts.abortSignal?.aborted) { - return false; - } - if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) { - throw err; - } + const waitBeforeRestart = async (buildLine: (delay: string) => string): Promise => { restartAttempts += 1; const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); - (opts.runtime?.error ?? console.error)( - `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${formatDurationPrecise(delayMs)}.`, - ); + const delay = formatDurationPrecise(delayMs); + log(buildLine(delay)); try { await sleepWithAbort(delayMs, opts.abortSignal); } catch (sleepErr) { @@ -214,10 +205,24 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { return true; }; - while (!opts.abortSignal?.aborted) { - let bot; + const waitBeforeRetryOnRecoverableSetupError = async ( + err: unknown, + logPrefix: string, + ): Promise => { + if (opts.abortSignal?.aborted) { + return false; + } + if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) { + throw err; + } + return waitBeforeRestart( + (delay) => `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${delay}.`, + ); + }; + + const createPollingBot = async (): Promise => { try { - bot = createTelegramBot({ + return createTelegramBot({ token, runtime: opts.runtime, proxyFetch, @@ -234,31 +239,34 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { "Telegram setup network error", ); if (!shouldRetry) { - return; + return undefined; } - continue; + return undefined; } + }; - if (!webhookCleared) { - try { - await withTelegramApiErrorLogging({ - operation: "deleteWebhook", - runtime: opts.runtime, - fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }), - }); - webhookCleared = true; - } catch (err) { - const shouldRetry = await waitBeforeRetryOnRecoverableSetupError( - err, - "Telegram webhook cleanup failed", - ); - if (!shouldRetry) { - return; - } - continue; - } + const ensureWebhookCleanup = async (bot: TelegramBot): Promise<"ready" | "retry" | "exit"> => { + if (webhookCleared) { + return "ready"; } + try { + await withTelegramApiErrorLogging({ + operation: "deleteWebhook", + runtime: opts.runtime, + fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }), + }); + webhookCleared = true; + return "ready"; + } catch (err) { + const shouldRetry = await waitBeforeRetryOnRecoverableSetupError( + err, + "Telegram webhook cleanup failed", + ); + return shouldRetry ? "retry" : "exit"; + } + }; + const runPollingCycle = async (bot: TelegramBot): Promise<"continue" | "exit"> => { const runner = run(bot, runnerOptions); activeRunner = runner; let stopPromise: Promise | undefined; @@ -280,23 +288,16 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { // runner.task() returns a promise that resolves when the runner stops await runner.task(); if (opts.abortSignal?.aborted) { - return; + return "exit"; } - // The runner stopped on its own. This can happen when grammY's - // maxRetryTime is exceeded (e.g. prolonged network outage). - // Instead of exiting permanently, restart with backoff so polling - // recovers once connectivity is restored. - restartAttempts += 1; - const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); const reason = forceRestarted ? "unhandled network error" : "runner stopped (maxRetryTime exceeded or graceful stop)"; forceRestarted = false; - log( - `Telegram polling runner stopped (${reason}); restarting in ${formatDurationPrecise(delayMs)}.`, + const shouldRestart = await waitBeforeRestart( + (delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`, ); - await sleepWithAbort(delayMs, opts.abortSignal); - continue; + return shouldRestart ? "continue" : "exit"; } catch (err) { forceRestarted = false; if (opts.abortSignal?.aborted) { @@ -307,25 +308,36 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { if (!isConflict && !isRecoverable) { throw err; } - restartAttempts += 1; - const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); const reason = isConflict ? "getUpdates conflict" : "network error"; const errMsg = formatErrorMessage(err); - (opts.runtime?.error ?? console.error)( - `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`, + const shouldRestart = await waitBeforeRestart( + (delay) => `Telegram ${reason}: ${errMsg}; retrying in ${delay}.`, ); - try { - await sleepWithAbort(delayMs, opts.abortSignal); - } catch (sleepErr) { - if (opts.abortSignal?.aborted) { - return; - } - throw sleepErr; - } + return shouldRestart ? "continue" : "exit"; } finally { opts.abortSignal?.removeEventListener("abort", stopOnAbort); await stopRunner(); } + }; + + while (!opts.abortSignal?.aborted) { + const bot = await createPollingBot(); + if (!bot) { + continue; + } + + const cleanupState = await ensureWebhookCleanup(bot); + if (cleanupState === "retry") { + continue; + } + if (cleanupState === "exit") { + return; + } + + const state = await runPollingCycle(bot); + if (state === "exit") { + return; + } } } finally { unregisterHandler(); From 7b4fe6d9bc85483c1820b33548e80715cc4cb64b Mon Sep 17 00:00:00 2001 From: Junyi Date: Sat, 7 Feb 2026 21:48:14 +0800 Subject: [PATCH 58/95] style(chat): UI: add mobile layout for chat compose actions - Stack chat compose row vertically on mobile (max-width: 640px) - Change action buttons to vertical layout with full width - Improve mobile UX for send and session control buttons --- ui/src/styles/chat/layout.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 4a5c4cdfa46..25fa6742b4a 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -452,6 +452,24 @@ grid-template-columns: 1fr; } + /* Mobile: stack compose row vertically */ + .chat-compose__row { + flex-direction: column; + gap: 8px; + } + + /* Mobile: stack action buttons vertically */ + .chat-compose__actions { + flex-direction: column; + width: 100%; + gap: 8px; + } + + /* Mobile: full-width buttons */ + .chat-compose .chat-compose__actions .btn { + width: 100%; + } + .chat-controls { flex-wrap: wrap; gap: 8px; From 260bec5985008e33529a8c10ea1126a80a635535 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 08:03:46 +0530 Subject: [PATCH 59/95] fix: add changelog for chat compose mobile layout (#11167) (thanks @junyiz) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df98ab8f376..8f82aa875ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. - Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus. - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. From baf656bc6fd7f83b6033e6dbc2548ec75028641f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:34:47 +0100 Subject: [PATCH 60/95] fix: block IPv6 multicast SSRF bypass --- CHANGELOG.md | 1 + src/infra/net/ssrf.test.ts | 3 +++ src/shared/net/ip.test.ts | 4 +++- src/shared/net/ip.ts | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f82aa875ee..b55a00e44f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. +- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 5826669196d..e823b35be31 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -38,6 +38,9 @@ const privateIpCases = [ "fe80::1%lo0", "fd00::1", "fec0::1", + "ff02::1", + "ff05::1:3", + "[ff02::1]", "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", ]; diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index 73d385832f0..a8e4c9bd8e8 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -45,8 +45,10 @@ describe("shared ip helpers", () => { } }); - it("treats deprecated site-local IPv6 as private/internal", () => { + it("treats blocked IPv6 classes as private/internal", () => { expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("ff02::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("[ff05::1:3]")).toBe(true); expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); }); }); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 2342bdedafe..d1f1c0a9069 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -27,6 +27,7 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "loopback", "linkLocal", "uniqueLocal", + "multicast", ]); const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; export type Ipv4SpecialUseBlockOptions = { From 03e689fc89bbecbcd02876a95957ef1ad9caa176 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:40:42 +0100 Subject: [PATCH 61/95] fix(security): bind system.run approvals to argv identity --- CHANGELOG.md | 1 + .../bash-tools.exec-approval-request.ts | 6 ++ src/agents/bash-tools.exec-host-node.ts | 1 + src/agents/tools/nodes-tool.ts | 4 +- src/gateway/exec-approval-manager.ts | 1 + .../node-invoke-system-run-approval.test.ts | 61 ++++++++++++++++++- .../node-invoke-system-run-approval.ts | 17 +++++- src/gateway/protocol/schema/exec-approvals.ts | 1 + src/gateway/server-methods/exec-approval.ts | 5 ++ src/infra/exec-approvals.ts | 1 + src/infra/system-run-command.test.ts | 4 ++ src/infra/system-run-command.ts | 9 ++- 12 files changed, 102 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a00e44f2..9874384fd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 83323845c0c..cda30757e26 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -8,6 +8,7 @@ import { callGatewayTool } from "./tools/gateway.js"; export type RequestExecApprovalDecisionParams = { id: string; command: string; + commandArgv?: string[]; cwd: string; nodeId?: string; host: "gateway" | "node"; @@ -62,6 +63,7 @@ export async function registerExecApprovalRequest( { id: params.id, command: params.command, + commandArgv: params.commandArgv, cwd: params.cwd, nodeId: params.nodeId, host: params.host, @@ -116,6 +118,7 @@ export async function requestExecApprovalDecision( export async function requestExecApprovalDecisionForHost(params: { approvalId: string; command: string; + commandArgv?: string[]; workdir: string; host: "gateway" | "node"; nodeId?: string; @@ -128,6 +131,7 @@ export async function requestExecApprovalDecisionForHost(params: { return await requestExecApprovalDecision({ id: params.approvalId, command: params.command, + commandArgv: params.commandArgv, cwd: params.workdir, nodeId: params.nodeId, host: params.host, @@ -142,6 +146,7 @@ export async function requestExecApprovalDecisionForHost(params: { export async function registerExecApprovalRequestForHost(params: { approvalId: string; command: string; + commandArgv?: string[]; workdir: string; host: "gateway" | "node"; nodeId?: string; @@ -154,6 +159,7 @@ export async function registerExecApprovalRequestForHost(params: { return await registerExecApprovalRequest({ id: params.approvalId, command: params.command, + commandArgv: params.commandArgv, cwd: params.workdir, nodeId: params.nodeId, host: params.host, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 5a45c869292..47f2931b980 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -194,6 +194,7 @@ export async function executeNodeHostCommand( const registration = await registerExecApprovalRequestForHost({ approvalId, command: params.command, + commandArgv: argv, workdir: params.workdir, host: "node", nodeId, diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index c17ff9f9c48..4cfd84dc474 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -18,6 +18,7 @@ import { } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { formatExecCommand } from "../../infra/system-run-command.js"; import { imageMimeFromFormat } from "../../media/mime.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; @@ -473,7 +474,7 @@ export function createNodesTool(options?: { // Node requires approval – create a pending approval request on // the gateway and wait for the user to approve/deny via the UI. const APPROVAL_TIMEOUT_MS = 120_000; - const cmdText = command.join(" "); + const cmdText = formatExecCommand(command); const approvalId = crypto.randomUUID(); const approvalResult = await callGatewayTool( "exec.approval.request", @@ -481,6 +482,7 @@ export function createNodesTool(options?: { { id: approvalId, command: cmdText, + commandArgv: command, cwd, nodeId, host: "node", diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 5e582d42a03..127d5feae09 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -6,6 +6,7 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000; export type ExecApprovalRequestPayload = { command: string; + commandArgv?: string[] | null; cwd?: string | null; nodeId?: string | null; host?: string | null; diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 196b5947f45..c2d3cbe1dfa 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -13,13 +13,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }, }; - function makeRecord(command: string): ExecApprovalRecord { + function makeRecord(command: string, commandArgv?: string[] | null): ExecApprovalRecord { return { id: "approval-1", request: { host: "node", nodeId: "node-1", command, + commandArgv: commandArgv ?? null, cwd: null, agentId: null, sessionKey: null, @@ -139,6 +140,64 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + + test("rejects trailing-space argv mismatch against legacy command-only approval", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["runner "], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(makeRecord("runner")), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("approval id does not match request"); + expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH"); + }); + + test("enforces commandArgv identity when approval includes argv binding", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(makeRecord("echo SAFE", ["echo SAFE"])), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("approval id does not match request"); + expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH"); + }); + + test("accepts matching commandArgv binding for trailing-space argv", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["runner "], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(makeRecord('"runner "', ["runner "])), + nowMs: now, + }); + expectAllowOnceForwardingResult(result); + }); test("consumes allow-once approvals and blocks same runId replay", async () => { const approvalManager = new ExecApprovalManager(); const runId = "approval-replay-1"; diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index d5600adf032..9623eb1b518 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -55,6 +55,7 @@ function clientHasApprovals(client: ApprovalClient | null): boolean { function approvalMatchesRequest( cmdText: string, + argv: string[], params: SystemRunParamsLike, record: ExecApprovalRecord, ): boolean { @@ -62,7 +63,19 @@ function approvalMatchesRequest( return false; } - if (!cmdText || record.request.command !== cmdText) { + const requestedArgv = Array.isArray(record.request.commandArgv) + ? record.request.commandArgv + : null; + if (requestedArgv) { + if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) { + return false; + } + for (let i = 0; i < requestedArgv.length; i += 1) { + if (requestedArgv[i] !== argv[i]) { + return false; + } + } + } else if (!cmdText || record.request.command !== cmdText) { return false; } @@ -237,7 +250,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: { }; } - if (!approvalMatchesRequest(cmdText, p, snapshot)) { + if (!approvalMatchesRequest(cmdText, cmdTextResolution.argv, p, snapshot)) { return { ok: false, message: "approval id does not match request", diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index a7c5fcf09bb..1482ae4cfef 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -89,6 +89,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, + commandArgv: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])), cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), host: Type.Optional(Type.Union([Type.String(), Type.Null()])), diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index d1cfc9ec0d9..555348bc777 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -43,6 +43,7 @@ export function createExecApprovalHandlers( const p = params as { id?: string; command: string; + commandArgv?: string[] | null; cwd?: string; nodeId?: string; host?: string; @@ -60,6 +61,9 @@ export function createExecApprovalHandlers( const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null; const host = typeof p.host === "string" ? p.host.trim() : ""; const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; + const commandArgv = Array.isArray(p.commandArgv) + ? p.commandArgv.map((entry) => String(entry)) + : null; if (host === "node" && !nodeId) { respond( false, @@ -78,6 +82,7 @@ export function createExecApprovalHandlers( } const request = { command: p.command, + commandArgv, cwd: p.cwd ?? null, nodeId: host === "node" ? nodeId : null, host: host || null, diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index be4264e22ec..688972d8361 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -15,6 +15,7 @@ export type ExecApprovalRequest = { id: string; request: { command: string; + commandArgv?: string[] | null; cwd?: string | null; nodeId?: string | null; host?: string | null; diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 7186823d84b..7f7d4fee96c 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -21,6 +21,10 @@ describe("system run command helpers", () => { expect(formatExecCommand(["echo", "hi there"])).toBe('echo "hi there"'); }); + test("formatExecCommand preserves trailing whitespace in argv tokens", () => { + expect(formatExecCommand(["runner "])).toBe('"runner "'); + }); + test("extractShellCommandFromArgv extracts sh -lc command", () => { expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe("echo hi"); }); diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index b03d715fc72..dc54bf7b561 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -35,15 +35,14 @@ export type ResolvedSystemRunCommand = export function formatExecCommand(argv: string[]): string { return argv .map((arg) => { - const trimmed = arg.trim(); - if (!trimmed) { + if (arg.length === 0) { return '""'; } - const needsQuotes = /\s|"/.test(trimmed); + const needsQuotes = /\s|"/.test(arg); if (!needsQuotes) { - return trimmed; + return arg; } - return `"${trimmed.replace(/"/g, '\\"')}"`; + return `"${arg.replace(/"/g, '\\"')}"`; }) .join(" "); } From 53fcfdf794bb3e001bea542fe597847529e2f05e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:41:58 +0100 Subject: [PATCH 62/95] fix(telegram): preserve finalized previews on mixed text+voice turns --- CHANGELOG.md | 1 + src/telegram/bot-message-dispatch.test.ts | 46 +++++++++++++++++++++++ src/telegram/bot-message-dispatch.ts | 7 +++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9874384fd66..afed51965f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. - Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. - Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. +- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042) - Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. - Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 75a8fb6b9af..7e82adafec2 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -691,6 +691,52 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it.each(["partial", "block"] as const)( + "keeps finalized text preview when the next assistant message is media-only (%s mode)", + async (streamMode) => { + let answerMessageId: number | undefined = 1001; + const answerDraftStream = { + update: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => answerMessageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn().mockImplementation(() => { + answerMessageId = undefined; + }), + }; + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce(() => answerDraftStream) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "First message preview" }); + await dispatcherOptions.deliver({ text: "First message final" }, { kind: "final" }); + await replyOptions?.onAssistantMessageStart?.(); + await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/voice.ogg" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); + const bot = createBot(); + + await dispatchWithContext({ context: createContext(), streamMode, bot }); + + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 1001, + "First message final", + expect.any(Object), + ); + const deleteMessageCalls = ( + bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } } + ).deleteMessage.mock.calls; + expect(deleteMessageCalls).not.toContainEqual([123, 1001]); + }, + ); + it("maps finals correctly when archived preview id arrives during final flush", async () => { let answerMessageId: number | undefined; let answerDraftParams: diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index f45b79fb9ab..5b000a8dcd0 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -567,7 +567,10 @@ export const dispatchTelegramMessage = async ({ reasoningStepState.resetForNextStep(); if (answerLane.hasStreamedMessage) { const previewMessageId = answerLane.stream?.messageId(); - if (typeof previewMessageId === "number") { + // Only archive previews that still need a matching final text update. + // Once a preview has already been finalized, archiving it here causes + // cleanup to delete a user-visible final message on later media-only turns. + if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) { archivedAnswerPreviews.push({ messageId: previewMessageId, textSnapshot: answerLane.lastPartialText, @@ -576,6 +579,8 @@ export const dispatchTelegramMessage = async ({ answerLane.stream?.forceNewMessage(); } resetDraftLaneState(answerLane); + // New assistant message boundary: this lane now tracks a fresh preview lifecycle. + finalizedPreviewByLane.answer = false; } : undefined, onReasoningEnd: reasoningLane.stream From 04d91d0319b82fd4de91ed05e9fc5219ff2ab64e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:42:22 +0100 Subject: [PATCH 63/95] fix(security): block workspace hardlink alias escapes --- CHANGELOG.md | 1 + src/agents/apply-patch.test.ts | 36 +++++++++++++++++++ src/agents/apply-patch.ts | 2 ++ src/agents/pi-tools.workspace-paths.test.ts | 40 +++++++++++++++++++++ src/agents/sandbox-paths.ts | 34 +++++++----------- src/agents/sandbox/fs-bridge.test.ts | 36 +++++++++++++++++++ src/agents/sandbox/fs-bridge.ts | 10 ++++++ src/infra/hardlink-guards.ts | 38 ++++++++++++++++++++ 8 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 src/infra/hardlink-guards.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index afed51965f9..cba652419f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. +- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 5a2dae87e75..79d0aa0c07b 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -159,6 +159,42 @@ describe("applyPatch", () => { }); }); + it("rejects hardlink alias escapes by default", async () => { + if (process.platform === "win32") { + return; + } + await withTempDir(async (dir) => { + const outside = path.join( + path.dirname(dir), + `outside-hardlink-${process.pid}-${Date.now()}.txt`, + ); + const linkPath = path.join(dir, "hardlink.txt"); + await fs.writeFile(outside, "initial\n", "utf8"); + try { + try { + await fs.link(outside, linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + const patch = `*** Begin Patch +*** Update File: hardlink.txt +@@ +-initial ++pwned +*** End Patch`; + await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/hardlink|sandbox/i); + const outsideContents = await fs.readFile(outside, "utf8"); + expect(outsideContents).toBe("initial\n"); + } finally { + await fs.rm(linkPath, { force: true }); + await fs.rm(outside, { force: true }); + } + }); + }); + it("allows symlinks that resolve within cwd by default", async () => { await withTempDir(async (dir) => { const target = path.join(dir, "target.txt"); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index fecf4cf03bc..4b147fd79fb 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -266,6 +266,7 @@ async function resolvePatchPath( cwd: options.cwd, root: options.cwd, allowFinalSymlink: purpose === "unlink", + allowFinalHardlink: purpose === "unlink", }); } return { @@ -282,6 +283,7 @@ async function resolvePatchPath( cwd: options.cwd, root: options.cwd, allowFinalSymlink: purpose === "unlink", + allowFinalHardlink: purpose === "unlink", }) ).resolved : resolvePathFromCwd(filePath, options.cwd); diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 6fe98ff03f8..4efa494555e 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -151,6 +151,46 @@ describe("workspace path resolution", () => { ).rejects.toThrow(/Path escapes sandbox root/i); }); }); + + it("rejects hardlinked file aliases when workspaceOnly is enabled", async () => { + if (process.platform === "win32") { + return; + } + await withTempDir("openclaw-ws-", async (workspaceDir) => { + const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } }; + const tools = createOpenClawCodingTools({ workspaceDir, config: cfg }); + const { readTool, writeTool } = expectReadWriteEditTools(tools); + const outsidePath = path.join( + path.dirname(workspaceDir), + `outside-hardlink-${process.pid}-${Date.now()}.txt`, + ); + const hardlinkPath = path.join(workspaceDir, "linked.txt"); + await fs.writeFile(outsidePath, "top-secret", "utf8"); + try { + try { + await fs.link(outsidePath, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await expect(readTool.execute("ws-read-hardlink", { path: "linked.txt" })).rejects.toThrow( + /hardlink|sandbox/i, + ); + await expect( + writeTool.execute("ws-write-hardlink", { + path: "linked.txt", + content: "pwned", + }), + ).rejects.toThrow(/hardlink|sandbox/i); + expect(await fs.readFile(outsidePath, "utf8")).toBe("top-secret"); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsidePath, { force: true }); + } + }); + }); }); describe("sandboxed workspace paths", () => { diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 761106e8574..b50e90c3241 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; +import { assertNoHardlinkedFinalPath } from "../infra/hardlink-guards.js"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; @@ -62,11 +63,18 @@ export async function assertSandboxPath(params: { cwd: string; root: string; allowFinalSymlink?: boolean; + allowFinalHardlink?: boolean; }) { const resolved = resolveSandboxPath(params); await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), { allowFinalSymlink: params.allowFinalSymlink, }); + await assertNoHardlinkedFinalPath({ + filePath: resolved.resolved, + root: path.resolve(params.root), + boundaryLabel: "sandbox root", + allowFinalHardlink: params.allowFinalHardlink, + }); return resolved; } @@ -195,27 +203,11 @@ async function assertNoTmpAliasEscape(params: { tmpRoot: string; }): Promise { await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot); - await assertNoHardlinkedFinalPath(params.filePath, params.tmpRoot); -} - -async function assertNoHardlinkedFinalPath(filePath: string, tmpRoot: string): Promise { - let stat: Awaited>; - try { - stat = await fs.stat(filePath); - } catch (err) { - if (isNotFoundPathError(err)) { - return; - } - throw err; - } - if (!stat.isFile()) { - return; - } - if (stat.nlink > 1) { - throw new Error( - `Hardlinked tmp media path is not allowed under tmp root (${shortPath(tmpRoot)}): ${shortPath(filePath)}`, - ); - } + await assertNoHardlinkedFinalPath({ + filePath: params.filePath, + root: params.tmpRoot, + boundaryLabel: "tmp root", + }); } async function assertNoSymlinkEscape( diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index d3bcd735e9e..f5c9aaedd6d 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -195,6 +195,42 @@ describe("sandbox fs bridge shell compatibility", () => { await fs.rm(stateDir, { recursive: true, force: true }); }); + it("rejects pre-existing host hardlink escapes before docker exec", async () => { + if (process.platform === "win32") { + return; + } + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-hardlink-")); + const workspaceDir = path.join(stateDir, "workspace"); + const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, "classified"); + const hardlinkPath = path.join(workspaceDir, "link.txt"); + try { + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + + await expect(bridge.readFile({ filePath: "link.txt" })).rejects.toThrow(/hardlink|sandbox/i); + expect(mockedExecDockerRaw).not.toHaveBeenCalled(); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("rejects container-canonicalized paths outside allowed mounts", async () => { mockedExecDockerRaw.mockImplementation(async (args) => { const script = getDockerScript(args); diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 226fc39ca1d..18991f60da6 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { assertNoHardlinkedFinalPath } from "../../infra/hardlink-guards.js"; import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; import { @@ -21,6 +22,7 @@ type RunCommandOptions = { type PathSafetyOptions = { action: string; allowFinalSymlink?: boolean; + allowFinalHardlink?: boolean; requireWritable?: boolean; }; @@ -151,6 +153,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { action: "remove files", requireWritable: true, allowFinalSymlink: true, + allowFinalHardlink: true, }); const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter( Boolean, @@ -176,6 +179,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { action: "rename files", requireWritable: true, allowFinalSymlink: true, + allowFinalHardlink: true, }); await this.assertPathSafety(to, { action: "rename files", @@ -257,6 +261,12 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { rootPath: lexicalMount.hostRoot, allowFinalSymlink: options.allowFinalSymlink === true, }); + await assertNoHardlinkedFinalPath({ + filePath: target.hostPath, + root: lexicalMount.hostRoot, + boundaryLabel: "sandbox mount root", + allowFinalHardlink: options.allowFinalHardlink === true, + }); const canonicalContainerPath = await this.resolveCanonicalContainerPath({ containerPath: target.containerPath, diff --git a/src/infra/hardlink-guards.ts b/src/infra/hardlink-guards.ts new file mode 100644 index 00000000000..9681bc09b78 --- /dev/null +++ b/src/infra/hardlink-guards.ts @@ -0,0 +1,38 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import { isNotFoundPathError } from "./path-guards.js"; + +export async function assertNoHardlinkedFinalPath(params: { + filePath: string; + root: string; + boundaryLabel: string; + allowFinalHardlink?: boolean; +}): Promise { + if (params.allowFinalHardlink) { + return; + } + let stat: Awaited>; + try { + stat = await fs.stat(params.filePath); + } catch (err) { + if (isNotFoundPathError(err)) { + return; + } + throw err; + } + if (!stat.isFile()) { + return; + } + if (stat.nlink > 1) { + throw new Error( + `Hardlinked path is not allowed under ${params.boundaryLabel} (${shortPath(params.root)}): ${shortPath(params.filePath)}`, + ); + } +} + +function shortPath(value: string) { + if (value.startsWith(os.homedir())) { + return `~${value.slice(os.homedir().length)}`; + } + return value; +} From 61b3246a7f44d2da498844e4cb448e5574253d0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:43:30 +0100 Subject: [PATCH 64/95] fix(ssrf): unify ipv6 special-use blocking --- src/infra/net/fetch-guard.ssrf.test.ts | 1 + src/infra/net/ssrf.test.ts | 5 ++--- src/infra/net/ssrf.ts | 4 ++-- src/shared/net/ip-test-fixtures.ts | 1 + src/shared/net/ip.test.ts | 6 ++++-- src/shared/net/ip.ts | 10 +++++++--- 6 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 src/shared/net/ip-test-fixtures.ts diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index a03afba325f..223695c1a53 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -18,6 +18,7 @@ describe("fetchWithSsrFGuard hardening", () => { it("blocks private and legacy loopback literals before fetch", async () => { const blockedUrls = [ "http://127.0.0.1:8080/internal", + "http://[ff02::1]/internal", "http://0177.0.0.1:8080/internal", "http://0x7f000001/internal", ]; diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index e823b35be31..2698bf3db9e 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js"; import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; @@ -38,9 +39,7 @@ const privateIpCases = [ "fe80::1%lo0", "fd00::1", "fec0::1", - "ff02::1", - "ff05::1:3", - "[ff02::1]", + ...blockedIpv6MulticastLiterals, "2001:db8:1234::5efe:127.0.0.1", "2001:db8:1234:1:200:5efe:7f00:1", ]; diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index b84469390c0..8ba29b38e2a 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -4,11 +4,11 @@ import { Agent, type Dispatcher } from "undici"; import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, + isBlockedSpecialUseIpv6Address, isCanonicalDottedDecimalIPv4, type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, - isPrivateOrLoopbackIpAddress, parseCanonicalIpAddress, parseLooseIpAddress, } from "../../shared/net/ip.js"; @@ -120,7 +120,7 @@ export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolea if (isIpv4Address(strictIp)) { return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } - if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { + if (isBlockedSpecialUseIpv6Address(strictIp)) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); diff --git a/src/shared/net/ip-test-fixtures.ts b/src/shared/net/ip-test-fixtures.ts new file mode 100644 index 00000000000..d2fa9cd5436 --- /dev/null +++ b/src/shared/net/ip-test-fixtures.ts @@ -0,0 +1 @@ +export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const; diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index a8e4c9bd8e8..f89fb03f7ef 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js"; import { extractEmbeddedIpv4FromIpv6, isCanonicalDottedDecimalIPv4, @@ -47,8 +48,9 @@ describe("shared ip helpers", () => { it("treats blocked IPv6 classes as private/internal", () => { expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true); - expect(isPrivateOrLoopbackIpAddress("ff02::1")).toBe(true); - expect(isPrivateOrLoopbackIpAddress("[ff05::1:3]")).toBe(true); + for (const literal of blockedIpv6MulticastLiterals) { + expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true); + } expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); }); }); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index d1f1c0a9069..c386c687898 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -22,7 +22,7 @@ const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ "carrierGradeNat", ]); -const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ +const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ "unspecified", "loopback", "linkLocal", @@ -228,11 +228,15 @@ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { if (isIpv4Address(normalized)) { return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range()); } - if (PRIVATE_OR_LOOPBACK_IPV6_RANGES.has(normalized.range())) { + return isBlockedSpecialUseIpv6Address(normalized); +} + +export function isBlockedSpecialUseIpv6Address(address: ipaddr.IPv6): boolean { + if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(address.range())) { return true; } // ipaddr.js does not classify deprecated site-local fec0::/10 as private. - return (normalized.parts[0] & 0xffc0) === 0xfec0; + return (address.parts[0] & 0xffc0) === 0xfec0; } export function isRfc1918Ipv4Address(raw: string | undefined): boolean { From 75dfb71e4e8b7c2feba5a8ca662f92ea840e0147 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:47:52 +0100 Subject: [PATCH 65/95] fix(slack): gate pin/reaction system events by sender auth --- CHANGELOG.md | 3 +- src/slack/monitor/events/pins.test.ts | 170 +++++++++++++++++++++ src/slack/monitor/events/pins.ts | 24 +-- src/slack/monitor/events/reactions.test.ts | 30 ++++ src/slack/monitor/events/reactions.ts | 53 ++----- 5 files changed, 227 insertions(+), 53 deletions(-) create mode 100644 src/slack/monitor/events/pins.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cba652419f4..6a46b277942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ Docs: https://docs.openclaw.ai - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Security/Discord + Slack reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. diff --git a/src/slack/monitor/events/pins.test.ts b/src/slack/monitor/events/pins.test.ts new file mode 100644 index 00000000000..3bdae247613 --- /dev/null +++ b/src/slack/monitor/events/pins.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SlackMonitorContext } from "../context.js"; +import { registerSlackPinEvents } from "./pins.js"; + +const enqueueSystemEventMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); + +vi.mock("../../../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), +})); + +type SlackPinHandler = (args: { event: Record; body: unknown }) => Promise; + +function createPinContext(overrides?: { + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + channelType?: "im" | "channel"; + channelUsers?: string[]; +}) { + let addedHandler: SlackPinHandler | null = null; + let removedHandler: SlackPinHandler | null = null; + const channelType = overrides?.channelType ?? "im"; + const app = { + event: vi.fn((name: string, handler: SlackPinHandler) => { + if (name === "pin_added") { + addedHandler = handler; + } else if (name === "pin_removed") { + removedHandler = handler; + } + }), + }; + const ctx = { + app, + runtime: { error: vi.fn() }, + dmEnabled: true, + dmPolicy: overrides?.dmPolicy ?? "open", + defaultRequireMention: true, + channelsConfig: overrides?.channelUsers + ? { + C1: { + users: overrides.channelUsers, + allow: true, + }, + } + : undefined, + groupPolicy: "open", + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: false, + shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), + isChannelAllowed: vi.fn().mockReturnValue(true), + resolveChannelName: vi.fn().mockResolvedValue({ + name: channelType === "im" ? "direct" : "general", + type: channelType, + }), + resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), + resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), + } as unknown as SlackMonitorContext; + registerSlackPinEvents({ ctx }); + return { + ctx, + getAddedHandler: () => addedHandler, + getRemovedHandler: () => removedHandler, + }; +} + +function makePinEvent(overrides?: { user?: string; channel?: string }) { + return { + type: "pin_added", + user: overrides?.user ?? "U1", + channel_id: overrides?.channel ?? "D1", + event_ts: "123.456", + item: { + type: "message", + message: { + ts: "123.456", + }, + }, + }; +} + +describe("registerSlackPinEvents", () => { + it("enqueues DM pin system events when dmPolicy is open", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ dmPolicy: "open" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks DM pin system events when dmPolicy is disabled", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ dmPolicy: "disabled" }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent(), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("blocks DM pin system events for unauthorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "allowlist", + allowFrom: ["U2"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM pin system events for authorized senders in allowlist mode", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "allowlist", + allowFrom: ["U1"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ user: "U1" }), + body: {}, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + + it("blocks channel pin events for users outside channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createPinContext({ + dmPolicy: "open", + channelType: "channel", + channelUsers: ["U_OWNER"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/slack/monitor/events/pins.ts b/src/slack/monitor/events/pins.ts index 2613bc35e24..89d0e2264e8 100644 --- a/src/slack/monitor/events/pins.ts +++ b/src/slack/monitor/events/pins.ts @@ -1,6 +1,7 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger } from "../../../globals.js"; +import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; @@ -22,19 +23,20 @@ async function handleSlackPinEvent(params: { const payload = event as SlackPinEvent; const channelId = payload.channel_id; - const channelInfo = channelId ? await ctx.resolveChannelName(channelId) : {}; - if ( - !ctx.isChannelAllowed({ - channelId, - channelName: channelInfo?.name, - channelType: channelInfo?.type, - }) - ) { + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: payload.user, + channelId, + }); + if (!auth.allowed) { + logVerbose( + `slack: drop pin sender ${payload.user ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); return; } const label = resolveSlackChannelLabel({ channelId, - channelName: channelInfo?.name, + channelName: auth.channelName, }); const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {}; const userLabel = userInfo?.name ?? payload.user ?? "someone"; @@ -42,7 +44,7 @@ async function handleSlackPinEvent(params: { const messageId = payload.item?.message?.ts ?? payload.event_ts; const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId, - channelType: channelInfo?.type ?? undefined, + channelType: auth.channelType, }); enqueueSystemEvent(`Slack: ${userLabel} ${action} a ${itemType} in ${label}.`, { sessionKey, diff --git a/src/slack/monitor/events/reactions.test.ts b/src/slack/monitor/events/reactions.test.ts index 815ca1c65b2..bb64fbb5b4a 100644 --- a/src/slack/monitor/events/reactions.test.ts +++ b/src/slack/monitor/events/reactions.test.ts @@ -22,6 +22,7 @@ function createReactionContext(overrides?: { dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; allowFrom?: string[]; channelType?: "im" | "channel"; + channelUsers?: string[]; }) { let addedHandler: SlackReactionHandler | null = null; let removedHandler: SlackReactionHandler | null = null; @@ -38,7 +39,17 @@ function createReactionContext(overrides?: { const ctx = { app, runtime: { error: vi.fn() }, + dmEnabled: true, dmPolicy: overrides?.dmPolicy ?? "open", + defaultRequireMention: true, + channelsConfig: overrides?.channelUsers + ? { + C1: { + users: overrides.channelUsers, + allow: true, + }, + } + : undefined, groupPolicy: "open", allowFrom: overrides?.allowFrom ?? [], allowNameMatching: false, @@ -160,4 +171,23 @@ describe("registerSlackReactionEvents", () => { expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); }); + + it("blocks channel reaction events for users outside channel users allowlist", async () => { + enqueueSystemEventMock.mockClear(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + const { getAddedHandler } = createReactionContext({ + dmPolicy: "open", + channelType: "channel", + channelUsers: ["U_OWNER"], + }); + const addedHandler = getAddedHandler(); + expect(addedHandler).toBeTruthy(); + + await addedHandler!({ + event: makeReactionEvent({ channel: "C1", user: "U_ATTACKER" }), + body: {}, + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/slack/monitor/events/reactions.ts b/src/slack/monitor/events/reactions.ts index 5007c6aad93..844b6c94080 100644 --- a/src/slack/monitor/events/reactions.ts +++ b/src/slack/monitor/events/reactions.ts @@ -1,9 +1,7 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import { danger, logVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { resolveDmGroupAccessWithLists } from "../../../security/dm-policy-shared.js"; -import { resolveSlackAllowListMatch } from "../allow-list.js"; -import { resolveSlackEffectiveAllowFrom } from "../auth.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; @@ -18,50 +16,23 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } return; } - const channelInfo = item.channel ? await ctx.resolveChannelName(item.channel) : {}; - const channelType = channelInfo?.type; - if ( - !ctx.isChannelAllowed({ - channelId: item.channel, - channelName: channelInfo?.name, - channelType, - }) - ) { + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId: event.user, + channelId: item.channel, + }); + if (!auth.allowed) { + logVerbose( + `slack: drop reaction sender ${event.user ?? "unknown"} channel=${item.channel ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); return; } const channelLabel = resolveSlackChannelLabel({ channelId: item.channel, - channelName: channelInfo?.name, + channelName: auth.channelName, }); const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined; - if (channelType === "im") { - if (!event.user) { - return; - } - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); - const access = resolveDmGroupAccessWithLists({ - isGroup: false, - dmPolicy: ctx.dmPolicy, - groupPolicy: ctx.groupPolicy, - allowFrom: allowFromLower, - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: (allowList) => - resolveSlackAllowListMatch({ - allowList, - id: event.user, - name: actorInfo?.name, - allowNameMatching: ctx.allowNameMatching, - }).allowed, - }); - if (access.decision !== "allow") { - logVerbose( - `slack: drop reaction sender ${event.user} (dmPolicy=${ctx.dmPolicy}, decision=${access.decision}, reason=${access.reason})`, - ); - return; - } - } const actorLabel = actorInfo?.name ?? event.user; const emojiLabel = event.reaction ?? "emoji"; const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined; @@ -70,7 +41,7 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId: item.channel, - channelType, + channelType: auth.channelType, }); enqueueSystemEvent(text, { sessionKey, From 92eb3dfc9d25ca2600d6687cd025922193d3dba5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:54:27 +0100 Subject: [PATCH 66/95] refactor(security): unify exec approval request matching --- src/gateway/exec-approval-manager.ts | 18 +--- ...e-invoke-system-run-approval-match.test.ts | 94 +++++++++++++++++++ .../node-invoke-system-run-approval-match.ts | 51 ++++++++++ .../node-invoke-system-run-approval.test.ts | 4 +- .../node-invoke-system-run-approval.ts | 61 +++--------- src/gateway/protocol/schema/exec-approvals.ts | 2 +- src/gateway/server-methods/exec-approval.ts | 4 +- src/infra/exec-approvals.ts | 26 ++--- 8 files changed, 182 insertions(+), 78 deletions(-) create mode 100644 src/gateway/node-invoke-system-run-approval-match.test.ts create mode 100644 src/gateway/node-invoke-system-run-approval-match.ts diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 127d5feae09..320b4da0b1f 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -1,21 +1,13 @@ import { randomUUID } from "node:crypto"; -import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; +import type { + ExecApprovalDecision, + ExecApprovalRequestPayload as InfraExecApprovalRequestPayload, +} from "../infra/exec-approvals.js"; // Grace period to keep resolved entries for late awaitDecision calls const RESOLVED_ENTRY_GRACE_MS = 15_000; -export type ExecApprovalRequestPayload = { - command: string; - commandArgv?: string[] | null; - cwd?: string | null; - nodeId?: string | null; - host?: string | null; - security?: string | null; - ask?: string | null; - agentId?: string | null; - resolvedPath?: string | null; - sessionKey?: string | null; -}; +export type ExecApprovalRequestPayload = InfraExecApprovalRequestPayload; export type ExecApprovalRecord = { id: string; diff --git a/src/gateway/node-invoke-system-run-approval-match.test.ts b/src/gateway/node-invoke-system-run-approval-match.test.ts new file mode 100644 index 00000000000..f5f093426c6 --- /dev/null +++ b/src/gateway/node-invoke-system-run-approval-match.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; +import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js"; + +describe("approvalMatchesSystemRunRequest", () => { + test("matches legacy command text when binding fields match", () => { + const result = approvalMatchesSystemRunRequest({ + cmdText: "echo SAFE", + argv: ["echo", "SAFE"], + request: { + host: "node", + command: "echo SAFE", + cwd: "/tmp", + agentId: "agent-1", + sessionKey: "session-1", + }, + binding: { + cwd: "/tmp", + agentId: "agent-1", + sessionKey: "session-1", + }, + }); + expect(result).toBe(true); + }); + + test("rejects legacy command mismatch", () => { + const result = approvalMatchesSystemRunRequest({ + cmdText: "echo PWNED", + argv: ["echo", "PWNED"], + request: { + host: "node", + command: "echo SAFE", + }, + binding: { + cwd: null, + agentId: null, + sessionKey: null, + }, + }); + expect(result).toBe(false); + }); + + test("enforces exact argv binding when commandArgv is set", () => { + const result = approvalMatchesSystemRunRequest({ + cmdText: "echo SAFE", + argv: ["echo", "SAFE"], + request: { + host: "node", + command: "echo SAFE", + commandArgv: ["echo", "SAFE"], + }, + binding: { + cwd: null, + agentId: null, + sessionKey: null, + }, + }); + expect(result).toBe(true); + }); + + test("rejects argv mismatch even when command text matches", () => { + const result = approvalMatchesSystemRunRequest({ + cmdText: "echo SAFE", + argv: ["echo", "SAFE"], + request: { + host: "node", + command: "echo SAFE", + commandArgv: ["echo SAFE"], + }, + binding: { + cwd: null, + agentId: null, + sessionKey: null, + }, + }); + expect(result).toBe(false); + }); + + test("rejects non-node host requests", () => { + const result = approvalMatchesSystemRunRequest({ + cmdText: "echo SAFE", + argv: ["echo", "SAFE"], + request: { + host: "gateway", + command: "echo SAFE", + }, + binding: { + cwd: null, + agentId: null, + sessionKey: null, + }, + }); + expect(result).toBe(false); + }); +}); diff --git a/src/gateway/node-invoke-system-run-approval-match.ts b/src/gateway/node-invoke-system-run-approval-match.ts new file mode 100644 index 00000000000..3dccc9b793d --- /dev/null +++ b/src/gateway/node-invoke-system-run-approval-match.ts @@ -0,0 +1,51 @@ +import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; + +export type SystemRunApprovalBinding = { + cwd: string | null; + agentId: string | null; + sessionKey: string | null; +}; + +function argvMatchesRequest(requestedArgv: string[], argv: string[]): boolean { + if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) { + return false; + } + for (let i = 0; i < requestedArgv.length; i += 1) { + if (requestedArgv[i] !== argv[i]) { + return false; + } + } + return true; +} + +export function approvalMatchesSystemRunRequest(params: { + cmdText: string; + argv: string[]; + request: ExecApprovalRequestPayload; + binding: SystemRunApprovalBinding; +}): boolean { + if (params.request.host !== "node") { + return false; + } + + const requestedArgv = params.request.commandArgv; + if (Array.isArray(requestedArgv)) { + if (!argvMatchesRequest(requestedArgv, params.argv)) { + return false; + } + } else if (!params.cmdText || params.request.command !== params.cmdText) { + return false; + } + + if ((params.request.cwd ?? null) !== params.binding.cwd) { + return false; + } + if ((params.request.agentId ?? null) !== params.binding.agentId) { + return false; + } + if ((params.request.sessionKey ?? null) !== params.binding.sessionKey) { + return false; + } + + return true; +} diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index c2d3cbe1dfa..833bbf6f3cf 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -13,14 +13,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }, }; - function makeRecord(command: string, commandArgv?: string[] | null): ExecApprovalRecord { + function makeRecord(command: string, commandArgv?: string[]): ExecApprovalRecord { return { id: "approval-1", request: { host: "node", nodeId: "node-1", command, - commandArgv: commandArgv ?? null, + commandArgv, cwd: null, agentId: null, sessionKey: null, diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 9623eb1b518..35cd18c66b9 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -1,5 +1,6 @@ import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import type { ExecApprovalRecord } from "./exec-approval-manager.js"; +import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js"; type SystemRunParamsLike = { command?: unknown; @@ -53,53 +54,6 @@ function clientHasApprovals(client: ApprovalClient | null): boolean { return scopes.includes("operator.admin") || scopes.includes("operator.approvals"); } -function approvalMatchesRequest( - cmdText: string, - argv: string[], - params: SystemRunParamsLike, - record: ExecApprovalRecord, -): boolean { - if (record.request.host !== "node") { - return false; - } - - const requestedArgv = Array.isArray(record.request.commandArgv) - ? record.request.commandArgv - : null; - if (requestedArgv) { - if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) { - return false; - } - for (let i = 0; i < requestedArgv.length; i += 1) { - if (requestedArgv[i] !== argv[i]) { - return false; - } - } - } else if (!cmdText || record.request.command !== cmdText) { - return false; - } - - const reqCwd = record.request.cwd ?? null; - const runCwd = normalizeString(params.cwd) ?? null; - if (reqCwd !== runCwd) { - return false; - } - - const reqAgentId = record.request.agentId ?? null; - const runAgentId = normalizeString(params.agentId) ?? null; - if (reqAgentId !== runAgentId) { - return false; - } - - const reqSessionKey = record.request.sessionKey ?? null; - const runSessionKey = normalizeString(params.sessionKey) ?? null; - if (reqSessionKey !== runSessionKey) { - return false; - } - - return true; -} - function pickSystemRunParams(raw: Record): Record { // Defensive allowlist: only forward fields that the node-host `system.run` handler understands. // This prevents future internal control fields from being smuggled through the gateway. @@ -250,7 +204,18 @@ export function sanitizeSystemRunParamsForForwarding(opts: { }; } - if (!approvalMatchesRequest(cmdText, cmdTextResolution.argv, p, snapshot)) { + if ( + !approvalMatchesSystemRunRequest({ + cmdText, + argv: cmdTextResolution.argv, + request: snapshot.request, + binding: { + cwd: normalizeString(p.cwd) ?? null, + agentId: normalizeString(p.agentId) ?? null, + sessionKey: normalizeString(p.sessionKey) ?? null, + }, + }) + ) { return { ok: false, message: "approval id does not match request", diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index 1482ae4cfef..083a445a4cf 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -89,7 +89,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, - commandArgv: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])), + commandArgv: Type.Optional(Type.Array(Type.String())), cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), host: Type.Optional(Type.Union([Type.String(), Type.Null()])), diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 555348bc777..a9b3db150ce 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -43,7 +43,7 @@ export function createExecApprovalHandlers( const p = params as { id?: string; command: string; - commandArgv?: string[] | null; + commandArgv?: string[]; cwd?: string; nodeId?: string; host?: string; @@ -63,7 +63,7 @@ export function createExecApprovalHandlers( const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; const commandArgv = Array.isArray(p.commandArgv) ? p.commandArgv.map((entry) => String(entry)) - : null; + : undefined; if (host === "node" && !nodeId) { respond( false, diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 688972d8361..d78f3d137e9 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -11,20 +11,22 @@ export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; +export type ExecApprovalRequestPayload = { + command: string; + commandArgv?: string[]; + cwd?: string | null; + nodeId?: string | null; + host?: string | null; + security?: string | null; + ask?: string | null; + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; +}; + export type ExecApprovalRequest = { id: string; - request: { - command: string; - commandArgv?: string[] | null; - cwd?: string | null; - nodeId?: string | null; - host?: string | null; - security?: string | null; - ask?: string | null; - agentId?: string | null; - resolvedPath?: string | null; - sessionKey?: string | null; - }; + request: ExecApprovalRequestPayload; createdAtMs: number; expiresAtMs: number; }; From 1e7ec8bfd2e7e5e689c6e5b6f1808b3251a5fe0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:43:22 +0000 Subject: [PATCH 67/95] fix(routing): preserve explicit cron account and bound message defaults Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com> Co-authored-by: stakeswky <64798754+stakeswky@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cron/delivery.test.ts | 18 ++++++++++ src/cron/delivery.ts | 13 +++++++ .../isolated-agent/delivery-target.test.ts | 35 +++++++++++++++++++ src/cron/isolated-agent/delivery-target.ts | 6 ++++ src/cron/isolated-agent/run.ts | 1 + src/cron/types.ts | 1 + src/gateway/protocol/schema/cron.ts | 1 + .../outbound/message-action-runner.test.ts | 28 +++++++++++++++ src/infra/outbound/message-action-runner.ts | 11 +++++- 10 files changed, 114 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a46b277942..7c605803a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. +- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. - Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index 6eaa5c66707..495e99d0039 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -54,4 +54,22 @@ describe("resolveCronDeliveryPlan", () => { expect(plan.channel).toBeUndefined(); expect(plan.to).toBe("https://example.invalid/cron"); }); + + it("threads delivery.accountId when explicitly configured", () => { + const plan = resolveCronDeliveryPlan( + makeJob({ + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + accountId: " bot-a ", + }, + }), + ); + expect(plan.mode).toBe("announce"); + expect(plan.requested).toBe(true); + expect(plan.channel).toBe("telegram"); + expect(plan.to).toBe("123"); + expect(plan.accountId).toBe("bot-a"); + }); }); diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index 377cdb49b2f..9022d09fd5f 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -4,6 +4,7 @@ export type CronDeliveryPlan = { mode: CronDeliveryMode; channel?: CronMessageChannel; to?: string; + accountId?: string; source: "delivery" | "payload"; requested: boolean; }; @@ -27,6 +28,14 @@ function normalizeTo(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +function normalizeAccountId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const payload = job.payload.kind === "agentTurn" ? job.payload : null; const delivery = job.delivery; @@ -50,6 +59,9 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { (delivery as { channel?: unknown } | undefined)?.channel, ); const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to); + const deliveryAccountId = normalizeAccountId( + (delivery as { accountId?: unknown } | undefined)?.accountId, + ); const channel = deliveryChannel ?? payloadChannel ?? "last"; const to = deliveryTo ?? payloadTo; @@ -59,6 +71,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { mode: resolvedMode, channel: resolvedMode === "announce" ? channel : undefined, to, + accountId: deliveryAccountId, source: "delivery", requested: resolvedMode === "announce", }; diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index ad1df42bb47..b28239adda8 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -299,4 +299,39 @@ describe("resolveDeliveryTarget", () => { expect(result.to).toBe("987654"); expect(result.ok).toBe(true); }); + + it("explicit delivery.accountId overrides session-derived accountId", async () => { + setMainSessionEntry({ + sessionId: "sess-5", + updatedAt: 1000, + lastChannel: "telegram", + lastTo: "chat-999", + lastAccountId: "default", + }); + + const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { + channel: "telegram", + to: "chat-999", + accountId: "bot-b", + }); + + expect(result.ok).toBe(true); + expect(result.accountId).toBe("bot-b"); + }); + + it("explicit delivery.accountId overrides bindings-derived accountId", async () => { + setMainSessionEntry(undefined); + const cfg = makeCfg({ + bindings: [{ agentId: AGENT_ID, match: { channel: "telegram", accountId: "bound" } }], + }); + + const result = await resolveDeliveryTarget(cfg, AGENT_ID, { + channel: "telegram", + to: "chat-777", + accountId: "explicit", + }); + + expect(result.ok).toBe(true); + expect(result.accountId).toBe("explicit"); + }); }); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 0aa26188120..1af69ee027a 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -43,6 +43,7 @@ export async function resolveDeliveryTarget( channel?: "last" | ChannelId; to?: string; sessionKey?: string; + accountId?: string; }, ): Promise { const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; @@ -114,6 +115,11 @@ export async function resolveDeliveryTarget( } } + // Explicit delivery account should override inferred session/binding account. + if (jobPayload.accountId) { + accountId = jobPayload.accountId; + } + // Carry threadId when it was explicitly set (from :topic: parsing or config) // or when delivering to the same recipient as the session's last conversation. // Session-derived threadIds are dropped when the target differs to prevent diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index a4a14bc26b8..751ea2bc13e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -314,6 +314,7 @@ export async function runCronIsolatedAgentTurn(params: { channel: deliveryPlan.channel ?? "last", to: deliveryPlan.to, sessionKey: params.job.sessionKey, + accountId: deliveryPlan.accountId, }); const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now); diff --git a/src/cron/types.ts b/src/cron/types.ts index 837cba2168e..4480b22ae6b 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -22,6 +22,7 @@ export type CronDelivery = { mode: CronDeliveryMode; channel?: CronMessageChannel; to?: string; + accountId?: string; bestEffort?: boolean; }; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index dae3b340d7e..7e0ebe54917 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -138,6 +138,7 @@ export const CronPayloadPatchSchema = Type.Union([ const CronDeliverySharedProperties = { channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), + accountId: Type.Optional(NonEmptyString), bestEffort: Type.Optional(Type.Boolean()), }; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 6fdec33ab49..cf3ddabcead 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -1021,4 +1021,32 @@ describe("runMessageAction accountId defaults", () => { expect(ctx.accountId).toBe("ops"); expect(ctx.params.accountId).toBe("ops"); }); + + it("falls back to the agent's bound account when accountId is omitted", async () => { + await runMessageAction({ + cfg: { + bindings: [{ agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } }], + } as OpenClawConfig, + action: "send", + params: { + channel: "discord", + target: "channel:123", + message: "hi", + }, + agentId: "agent-b", + }); + + expect(handleAction).toHaveBeenCalled(); + const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as + | { + accountId?: string | null; + params: Record; + } + | undefined; + if (!ctx) { + throw new Error("expected action context"); + } + expect(ctx.accountId).toBe("account-b"); + expect(ctx.params.accountId).toBe("account-b"); + }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 57032e27de8..2693d110306 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,8 @@ import type { } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { buildChannelAccountBindings } from "../../routing/bindings.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -753,7 +755,14 @@ export async function runMessageAction( } const channel = await resolveChannel(cfg, params); - const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + if (!accountId && resolvedAgentId) { + const byAgent = buildChannelAccountBindings(cfg).get(channel); + const boundAccountIds = byAgent?.get(normalizeAgentId(resolvedAgentId)); + if (boundAccountIds && boundAccountIds.length > 0) { + accountId = boundAccountIds[0]; + } + } if (accountId) { params.accountId = accountId; } From ee594e2fdb718da52e87a41d0414b16c322a9af6 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 25 Feb 2026 21:56:53 -0500 Subject: [PATCH 68/95] fix(telegram): webhook hang - tests and fix (openclaw#26933) thanks @huntharo Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/telegram/webhook.test.ts | 568 ++++++++++++++++++++++++++++++++++- src/telegram/webhook.ts | 240 +++++++++++---- 3 files changed, 739 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c605803a44..149a517fd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts index 2c943a4be6f..0117c55823a 100644 --- a/src/telegram/webhook.test.ts +++ b/src/telegram/webhook.test.ts @@ -1,24 +1,26 @@ +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { request } from "node:http"; +import { setTimeout as sleep } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; import { startTelegramWebhook } from "./webhook.js"; -const handlerSpy = vi.hoisted(() => - vi.fn( - (_req: unknown, res: { writeHead: (status: number) => void; end: (body?: string) => void }) => { - res.writeHead(200); - res.end("ok"); - }, - ), -); +const handlerSpy = vi.hoisted(() => vi.fn((..._args: unknown[]): unknown => undefined)); const setWebhookSpy = vi.hoisted(() => vi.fn()); +const deleteWebhookSpy = vi.hoisted(() => vi.fn(async () => true)); +const initSpy = vi.hoisted(() => vi.fn(async () => undefined)); const stopSpy = vi.hoisted(() => vi.fn()); const webhookCallbackSpy = vi.hoisted(() => vi.fn(() => handlerSpy)); const createTelegramBotSpy = vi.hoisted(() => vi.fn(() => ({ - api: { setWebhook: setWebhookSpy }, + init: initSpy, + api: { setWebhook: setWebhookSpy, deleteWebhook: deleteWebhookSpy }, stop: stopSpy, })), ); +const WEBHOOK_POST_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 8_000; + vi.mock("grammy", async (importOriginal) => { const actual = await importOriginal(); return { @@ -31,8 +33,178 @@ vi.mock("./bot.js", () => ({ createTelegramBot: createTelegramBotSpy, })); +async function fetchWithTimeout( + input: string, + init: Omit, + timeoutMs: number, +): Promise { + const abort = new AbortController(); + const timer = setTimeout(() => { + abort.abort(); + }, timeoutMs); + try { + return await fetch(input, { ...init, signal: abort.signal }); + } finally { + clearTimeout(timer); + } +} + +async function postWebhookJson(params: { + url: string; + payload: string; + secret?: string; + timeoutMs?: number; +}): Promise { + return await fetchWithTimeout( + params.url, + { + method: "POST", + headers: { + "content-type": "application/json", + ...(params.secret ? { "x-telegram-bot-api-secret-token": params.secret } : {}), + }, + body: params.payload, + }, + params.timeoutMs ?? 5_000, + ); +} + +function createDeterministicRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0; + return state / 4_294_967_296; + }; +} + +async function postWebhookPayloadWithChunkPlan(params: { + port: number; + path: string; + payload: string; + secret: string; + mode: "single" | "random-chunked"; + timeoutMs?: number; +}): Promise<{ statusCode: number; body: string }> { + const payloadBuffer = Buffer.from(params.payload, "utf-8"); + return await new Promise((resolve, reject) => { + let bytesQueued = 0; + let chunksQueued = 0; + let phase: "writing" | "awaiting-response" = "writing"; + let settled = false; + const finishResolve = (value: { statusCode: number; body: string }) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(value); + }; + const finishReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }; + + const req = request( + { + hostname: "127.0.0.1", + port: params.port, + path: params.path, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(payloadBuffer.length), + "x-telegram-bot-api-secret-token": params.secret, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + finishResolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf-8"), + }); + }); + }, + ); + + const timeout = setTimeout(() => { + finishReject( + new Error( + `webhook post timed out after ${params.timeoutMs ?? 15_000}ms (phase=${phase}, bytesQueued=${bytesQueued}, chunksQueued=${chunksQueued}, totalBytes=${payloadBuffer.length})`, + ), + ); + req.destroy(); + }, params.timeoutMs ?? 15_000); + + req.on("error", (error) => { + finishReject(error); + }); + + const writeAll = async () => { + if (params.mode === "single") { + req.end(payloadBuffer); + return; + } + + const rng = createDeterministicRng(26156); + let offset = 0; + while (offset < payloadBuffer.length) { + const remaining = payloadBuffer.length - offset; + const nextSize = Math.max(1, Math.min(remaining, 1 + Math.floor(rng() * 8_192))); + const chunk = payloadBuffer.subarray(offset, offset + nextSize); + const canContinue = req.write(chunk); + offset += nextSize; + bytesQueued = offset; + chunksQueued += 1; + if (chunksQueued % 10 === 0) { + await sleep(1 + Math.floor(rng() * 3)); + } + if (!canContinue) { + // Windows CI occasionally stalls on waiting for drain indefinitely. + // Bound the wait, then continue queuing this small (~1MB) payload. + await Promise.race([once(req, "drain"), sleep(25)]); + } + } + phase = "awaiting-response"; + req.end(); + }; + + void writeAll().catch((error) => { + finishReject(error); + }); + }); +} + +function createNearLimitTelegramPayload(): { payload: string; sizeBytes: number } { + const maxBytes = 1_024 * 1_024; + const targetBytes = maxBytes - 4_096; + const shell = { update_id: 77_777, message: { text: "" } }; + const shellSize = Buffer.byteLength(JSON.stringify(shell), "utf-8"); + const textLength = Math.max(1, targetBytes - shellSize); + const pattern = "the quick brown fox jumps over the lazy dog "; + const repeats = Math.ceil(textLength / pattern.length); + const text = pattern.repeat(repeats).slice(0, textLength); + const payload = JSON.stringify({ + update_id: 77_777, + message: { text }, + }); + return { payload, sizeBytes: Buffer.byteLength(payload, "utf-8") }; +} + +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + describe("startTelegramWebhook", () => { it("starts server, registers webhook, and serves health", async () => { + initSpy.mockClear(); createTelegramBotSpy.mockClear(); webhookCallbackSpy.mockClear(); const abort = new AbortController(); @@ -59,6 +231,7 @@ describe("startTelegramWebhook", () => { const health = await fetch(`${url}/healthz`); expect(health.status).toBe(200); + expect(initSpy).toHaveBeenCalledTimes(1); expect(setWebhookSpy).toHaveBeenCalled(); expect(webhookCallbackSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -66,7 +239,7 @@ describe("startTelegramWebhook", () => { setWebhook: expect.any(Function), }), }), - "http", + "callback", { secretToken: "secret", onTimeout: "return", @@ -101,7 +274,13 @@ describe("startTelegramWebhook", () => { if (!addr || typeof addr === "string") { throw new Error("no addr"); } - await fetch(`http://127.0.0.1:${addr.port}/hook`, { method: "POST" }); + const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } }); + const response = await postWebhookJson({ + url: `http://127.0.0.1:${addr.port}/hook`, + payload, + secret: "secret", + }); + expect(response.status).toBe(200); expect(handlerSpy).toHaveBeenCalled(); abort.abort(); }); @@ -113,4 +292,371 @@ describe("startTelegramWebhook", () => { }), ).rejects.toThrow(/requires a non-empty secret token/i); }); + + it("registers webhook using the bound listening port when port is 0", async () => { + setWebhookSpy.mockClear(); + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret: "secret", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + try { + const addr = server.address(); + if (!addr || typeof addr === "string") { + throw new Error("no addr"); + } + expect(addr.port).toBeGreaterThan(0); + expect(setWebhookSpy).toHaveBeenCalledTimes(1); + expect(setWebhookSpy).toHaveBeenCalledWith( + `http://127.0.0.1:${addr.port}/hook`, + expect.objectContaining({ + secret_token: "secret", + }), + ); + } finally { + abort.abort(); + } + }); + + it("keeps webhook payload readable when callback delays body read", async () => { + handlerSpy.mockImplementationOnce(async (...args: unknown[]) => { + const [update, reply] = args as [unknown, (json: string) => Promise]; + await sleep(50); + await reply(JSON.stringify(update)); + }); + + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret: "secret", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + try { + const addr = server.address(); + if (!addr || typeof addr === "string") { + throw new Error("no addr"); + } + + const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } }); + const res = await postWebhookJson({ + url: `http://127.0.0.1:${addr.port}/hook`, + payload, + secret: "secret", + }); + expect(res.status).toBe(200); + const responseBody = await res.text(); + expect(JSON.parse(responseBody)).toEqual(JSON.parse(payload)); + } finally { + abort.abort(); + } + }); + + it("keeps webhook payload readable across multiple delayed reads", async () => { + const seenPayloads: string[] = []; + const delayedHandler = async (...args: unknown[]) => { + const [update, reply] = args as [unknown, (json: string) => Promise]; + await sleep(50); + seenPayloads.push(JSON.stringify(update)); + await reply("ok"); + }; + handlerSpy.mockImplementationOnce(delayedHandler).mockImplementationOnce(delayedHandler); + + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret: "secret", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + try { + const addr = server.address(); + if (!addr || typeof addr === "string") { + throw new Error("no addr"); + } + + const payloads = [ + JSON.stringify({ update_id: 1, message: { text: "first" } }), + JSON.stringify({ update_id: 2, message: { text: "second" } }), + ]; + + for (const payload of payloads) { + const res = await postWebhookJson({ + url: `http://127.0.0.1:${addr.port}/hook`, + payload, + secret: "secret", + }); + expect(res.status).toBe(200); + } + + expect(seenPayloads.map((x) => JSON.parse(x))).toEqual(payloads.map((x) => JSON.parse(x))); + } finally { + abort.abort(); + } + }); + + it("processes a second request after first-request delayed-init data loss", async () => { + const seenUpdates: unknown[] = []; + webhookCallbackSpy.mockImplementationOnce( + () => + vi.fn( + ( + update: unknown, + reply: (json: string) => Promise, + _secretHeader: string | undefined, + _unauthorized: () => Promise, + ) => { + seenUpdates.push(update); + void (async () => { + await sleep(50); + await reply("ok"); + })(); + }, + ) as unknown as typeof handlerSpy, + ); + + const secret = "secret"; + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret, + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("no addr"); + } + + const firstPayload = JSON.stringify({ update_id: 100, message: { text: "first" } }); + const secondPayload = JSON.stringify({ update_id: 101, message: { text: "second" } }); + const firstResponse = await postWebhookPayloadWithChunkPlan({ + port: address.port, + path: "/hook", + payload: firstPayload, + secret, + mode: "single", + timeoutMs: WEBHOOK_POST_TIMEOUT_MS, + }); + const secondResponse = await postWebhookPayloadWithChunkPlan({ + port: address.port, + path: "/hook", + payload: secondPayload, + secret, + mode: "single", + timeoutMs: WEBHOOK_POST_TIMEOUT_MS, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + expect(seenUpdates).toEqual([JSON.parse(firstPayload), JSON.parse(secondPayload)]); + } finally { + abort.abort(); + } + }); + + it("handles near-limit payload with random chunk writes and event-loop yields", async () => { + const seenUpdates: Array<{ update_id: number; message: { text: string } }> = []; + webhookCallbackSpy.mockImplementationOnce( + () => + vi.fn( + ( + update: unknown, + reply: (json: string) => Promise, + _secretHeader: string | undefined, + _unauthorized: () => Promise, + ) => { + seenUpdates.push(update as { update_id: number; message: { text: string } }); + void reply("ok"); + }, + ) as unknown as typeof handlerSpy, + ); + + const { payload, sizeBytes } = createNearLimitTelegramPayload(); + expect(sizeBytes).toBeLessThan(1_024 * 1_024); + expect(sizeBytes).toBeGreaterThan(256 * 1_024); + const expected = JSON.parse(payload) as { update_id: number; message: { text: string } }; + + const secret = "secret"; + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret, + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("no addr"); + } + + const response = await postWebhookPayloadWithChunkPlan({ + port: address.port, + path: "/hook", + payload, + secret, + mode: "random-chunked", + timeoutMs: WEBHOOK_POST_TIMEOUT_MS, + }); + + expect(response.statusCode).toBe(200); + expect(seenUpdates).toHaveLength(1); + expect(seenUpdates[0]?.update_id).toBe(expected.update_id); + expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length); + expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text)); + } finally { + abort.abort(); + } + }); + + it("handles near-limit payload written in a single request write", async () => { + const seenUpdates: Array<{ update_id: number; message: { text: string } }> = []; + webhookCallbackSpy.mockImplementationOnce( + () => + vi.fn( + ( + update: unknown, + reply: (json: string) => Promise, + _secretHeader: string | undefined, + _unauthorized: () => Promise, + ) => { + seenUpdates.push(update as { update_id: number; message: { text: string } }); + void reply("ok"); + }, + ) as unknown as typeof handlerSpy, + ); + + const { payload, sizeBytes } = createNearLimitTelegramPayload(); + expect(sizeBytes).toBeLessThan(1_024 * 1_024); + expect(sizeBytes).toBeGreaterThan(256 * 1_024); + const expected = JSON.parse(payload) as { update_id: number; message: { text: string } }; + + const secret = "secret"; + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret, + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("no addr"); + } + + const response = await postWebhookPayloadWithChunkPlan({ + port: address.port, + path: "/hook", + payload, + secret, + mode: "single", + timeoutMs: WEBHOOK_POST_TIMEOUT_MS, + }); + + expect(response.statusCode).toBe(200); + expect(seenUpdates).toHaveLength(1); + expect(seenUpdates[0]?.update_id).toBe(expected.update_id); + expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length); + expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text)); + } finally { + abort.abort(); + } + }); + + it("rejects payloads larger than 1MB before invoking webhook handler", async () => { + handlerSpy.mockClear(); + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + secret: "secret", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("no addr"); + } + + const responseOrError = await new Promise< + | { kind: "response"; statusCode: number; body: string } + | { kind: "error"; code: string | undefined } + >((resolve) => { + const req = request( + { + hostname: "127.0.0.1", + port: address.port, + path: "/hook", + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(1_024 * 1_024 + 2_048), + "x-telegram-bot-api-secret-token": "secret", + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + kind: "response", + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf-8"), + }); + }); + }, + ); + req.on("error", (error: NodeJS.ErrnoException) => { + resolve({ kind: "error", code: error.code }); + }); + req.end("{}"); + }); + + if (responseOrError.kind === "response") { + expect(responseOrError.statusCode).toBe(413); + expect(responseOrError.body).toBe("Payload too large"); + } else { + expect(responseOrError.code).toBeOneOf(["ECONNRESET", "EPIPE"]); + } + expect(handlerSpy).not.toHaveBeenCalled(); + } finally { + abort.abort(); + } + }); + + it("de-registers webhook when shutting down", async () => { + deleteWebhookSpy.mockClear(); + const abort = new AbortController(); + await startTelegramWebhook({ + token: "tok", + secret: "secret", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + + abort.abort(); + await sleep(25); + + expect(deleteWebhookSpy).toHaveBeenCalledTimes(1); + expect(deleteWebhookSpy).toHaveBeenCalledWith({ drop_pending_updates: false }); + }); }); diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 9eb3c73d7f4..0fd887f956c 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -3,7 +3,7 @@ import { webhookCallback } from "grammy"; import type { OpenClawConfig } from "../config/config.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { installRequestBodyLimitGuard } from "../infra/http-body.js"; +import { readJsonBodyWithLimit } from "../infra/http-body.js"; import { logWebhookError, logWebhookProcessed, @@ -21,6 +21,59 @@ const TELEGRAM_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const TELEGRAM_WEBHOOK_CALLBACK_TIMEOUT_MS = 10_000; +async function listenHttpServer(params: { + server: ReturnType; + port: number; + host: string; +}) { + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + params.server.off("error", onError); + reject(err); + }; + params.server.once("error", onError); + params.server.listen(params.port, params.host, () => { + params.server.off("error", onError); + resolve(); + }); + }); +} + +function resolveWebhookPublicUrl(params: { + configuredPublicUrl?: string; + server: ReturnType; + path: string; + host: string; + port: number; +}) { + if (params.configuredPublicUrl) { + return params.configuredPublicUrl; + } + const address = params.server.address(); + if (address && typeof address !== "string") { + const resolvedHost = + params.host === "0.0.0.0" || address.address === "0.0.0.0" || address.address === "::" + ? "localhost" + : address.address; + return `http://${resolvedHost}:${address.port}${params.path}`; + } + const fallbackHost = params.host === "0.0.0.0" ? "localhost" : params.host; + return `http://${fallbackHost}:${params.port}${params.path}`; +} + +async function initializeTelegramWebhookBot(params: { + bot: ReturnType; + runtime: RuntimeEnv; + abortSignal?: AbortSignal; +}) { + const initSignal = params.abortSignal as Parameters<(typeof params.bot)["init"]>[0]; + await withTelegramApiErrorLogging({ + operation: "getMe", + runtime: params.runtime, + fn: () => params.bot.init(initSignal), + }); +} + export async function startTelegramWebhook(opts: { token: string; accountId?: string; @@ -55,7 +108,12 @@ export async function startTelegramWebhook(opts: { config: opts.config, accountId: opts.accountId, }); - const handler = webhookCallback(bot, "http", { + await initializeTelegramWebhookBot({ + bot, + runtime, + abortSignal: opts.abortSignal, + }); + const handler = webhookCallback(bot, "callback", { secretToken: secret, onTimeout: "return", timeoutMilliseconds: TELEGRAM_WEBHOOK_CALLBACK_TIMEOUT_MS, @@ -66,6 +124,14 @@ export async function startTelegramWebhook(opts: { } const server = createServer((req, res) => { + const respondText = (statusCode: number, text = "") => { + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(text); + }; + if (req.url === healthPath) { res.writeHead(200); res.end("ok"); @@ -80,69 +146,125 @@ export async function startTelegramWebhook(opts: { if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); } - const guard = installRequestBodyLimitGuard(req, res, { - maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES, - timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS, - responseFormat: "text", - }); - if (guard.isTripped()) { - return; - } - const handled = handler(req, res); - if (handled && typeof handled.catch === "function") { - void handled - .then(() => { - if (diagnosticsEnabled) { - logWebhookProcessed({ - channel: "telegram", - updateType: "telegram-post", - durationMs: Date.now() - startTime, - }); - } - }) - .catch((err) => { - if (guard.isTripped()) { - return; - } - const errMsg = formatErrorMessage(err); - if (diagnosticsEnabled) { - logWebhookError({ - channel: "telegram", - updateType: "telegram-post", - error: errMsg, - }); - } - runtime.log?.(`webhook handler failed: ${errMsg}`); - if (!res.headersSent) { - res.writeHead(500); - } - res.end(); - }) - .finally(() => { - guard.dispose(); + void (async () => { + const body = await readJsonBodyWithLimit(req, { + maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES, + timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS, + emptyObjectOnEmpty: false, + }); + if (!body.ok) { + if (body.code === "PAYLOAD_TOO_LARGE") { + respondText(413, body.error); + return; + } + if (body.code === "REQUEST_BODY_TIMEOUT") { + respondText(408, body.error); + return; + } + if (body.code === "CONNECTION_CLOSED") { + respondText(400, body.error); + return; + } + respondText(400, body.error); + return; + } + + let replied = false; + const reply = async (json: string) => { + if (replied) { + return; + } + replied = true; + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(json); + }; + const unauthorized = async () => { + if (replied) { + return; + } + replied = true; + respondText(401, "unauthorized"); + }; + const secretHeaderRaw = req.headers["x-telegram-bot-api-secret-token"]; + const secretHeader = Array.isArray(secretHeaderRaw) ? secretHeaderRaw[0] : secretHeaderRaw; + + await handler(body.value, reply, secretHeader, unauthorized); + if (!replied) { + respondText(200); + } + + if (diagnosticsEnabled) { + logWebhookProcessed({ + channel: "telegram", + updateType: "telegram-post", + durationMs: Date.now() - startTime, }); - return; + } + })().catch((err) => { + const errMsg = formatErrorMessage(err); + if (diagnosticsEnabled) { + logWebhookError({ + channel: "telegram", + updateType: "telegram-post", + error: errMsg, + }); + } + runtime.log?.(`webhook handler failed: ${errMsg}`); + respondText(500); + }); + }); + + await listenHttpServer({ + server, + port, + host, + }); + + const publicUrl = resolveWebhookPublicUrl({ + configuredPublicUrl: opts.publicUrl, + server, + path, + host, + port, + }); + + try { + await withTelegramApiErrorLogging({ + operation: "setWebhook", + runtime, + fn: () => + bot.api.setWebhook(publicUrl, { + secret_token: secret, + allowed_updates: resolveTelegramAllowedUpdates(), + }), + }); + } catch (err) { + server.close(); + void bot.stop(); + if (diagnosticsEnabled) { + stopDiagnosticHeartbeat(); } - guard.dispose(); - }); + throw err; + } - const publicUrl = - opts.publicUrl ?? `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; - - await withTelegramApiErrorLogging({ - operation: "setWebhook", - runtime, - fn: () => - bot.api.setWebhook(publicUrl, { - secret_token: secret, - allowed_updates: resolveTelegramAllowedUpdates(), - }), - }); - - await new Promise((resolve) => server.listen(port, host, resolve)); runtime.log?.(`webhook listening on ${publicUrl}`); + let shutDown = false; const shutdown = () => { + if (shutDown) { + return; + } + shutDown = true; + void withTelegramApiErrorLogging({ + operation: "deleteWebhook", + runtime, + fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }), + }).catch(() => { + // withTelegramApiErrorLogging has already emitted the failure. + }); server.close(); void bot.stop(); if (diagnosticsEnabled) { From 8a006a32603b0a2cd7ecd9cb7f858e1b96332035 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:56:40 +0100 Subject: [PATCH 69/95] feat(heartbeat): add directPolicy and restore default direct delivery --- CHANGELOG.md | 5 ++ docs/gateway/configuration-reference.md | 3 +- docs/gateway/configuration.md | 3 +- docs/gateway/heartbeat.md | 6 +- docs/gateway/troubleshooting.md | 2 +- docs/start/openclaw.md | 2 +- src/config/config.plugin-validation.test.ts | 28 +++++++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + ...tbeat-runner.returns-default-unset.test.ts | 24 ++++++ src/infra/outbound/targets.test.ts | 81 ++++++++++++++++--- src/infra/outbound/targets.ts | 2 +- 14 files changed, 149 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 149a517fd64..d0fa2607a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ Docs: https://docs.openclaw.ai - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. +- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. + +### Breaking + +- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override). ### Fixes diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b03a0daa4fc..8d147b23fd7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -800,6 +800,7 @@ Periodic heartbeat runs. includeReasoning: false, session: "main", to: "+15555550123", + directPolicy: "allow", // allow (default) | block target: "none", // default: none | options: last | whatsapp | telegram | discord | ... prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, @@ -812,7 +813,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. -- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped. +- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3f7403d4647..ff3179d28e2 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -239,7 +239,8 @@ When validation fails: ``` - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked) + - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index cf7ea489c40..70f4b968233 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,7 +215,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. -- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). +- `directPolicy`: controls direct/DM delivery behavior: + - `allow` (default): allow direct/DM heartbeat delivery. + - `block`: suppress direct/DM delivery (`reason=dm-blocked`). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -236,7 +238,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. -- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped. +- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 23483076102..45963f15579 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -174,7 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. -- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design). +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`. Related: diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 058f2fa67fe..671efe420c7 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,7 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. -- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery. +- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index d9e6b3190e1..62584f138de 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -234,4 +234,32 @@ describe("config plugin validation", () => { }); } }); + + it("accepts heartbeat directPolicy enum values", async () => { + const home = await createCaseHome(); + const res = validateInHome(home, { + agents: { + defaults: { heartbeat: { target: "last", directPolicy: "block" } }, + list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects invalid heartbeat directPolicy values", async () => { + const home = await createCaseHome(); + const res = validateInHome(home, { + agents: { + defaults: { heartbeat: { directPolicy: "maybe" } }, + list: [{ id: "pi" }], + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + const hasIssue = res.issues.some( + (issue) => issue.path === "agents.defaults.heartbeat.directPolicy", + ); + expect(hasIssue).toBe(true); + } + }); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a479ec0a853..f32433e1333 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1238,6 +1238,10 @@ export const FIELD_HELP: Record = { "Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.", "channels.defaults.heartbeat.useIndicator": "Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.", + "agents.defaults.heartbeat.directPolicy": + 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.', + "agents.list.*.heartbeat.directPolicy": + 'Per-agent override for heartbeat direct/DM delivery policy; use "block" for agents that should only send heartbeat alerts to non-DM destinations.', "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.telegram.botToken": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cd28b1fafb8..8c0c6350d7b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -402,6 +402,8 @@ export const FIELD_LABELS: Record = { "Compaction Memory Flush Soft Threshold", "agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt", "agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt", + "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", + "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index e8eac685086..afc65e3daec 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -213,6 +213,8 @@ export type AgentDefaultsConfig = { session?: string; /** Delivery target ("last", "none", or a channel id). */ target?: "last" | "none" | ChannelId; + /** Direct/DM delivery policy. Default: "allow". */ + directPolicy?: "allow" | "block"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). Supports :topic:NNN suffix for Telegram topics. */ to?: string; /** Optional account id for multi-account channels. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index c477cc1743b..9df0776b956 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -26,6 +26,7 @@ export const HeartbeatSchema = z session: z.string().optional(), includeReasoning: z.boolean().optional(), target: z.string().optional(), + directPolicy: z.union([z.literal("allow"), z.literal("block")]).optional(), to: z.string().optional(), accountId: z.string().optional(), prompt: z.string().optional(), diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 0ec2afcafdd..c4f45b5e039 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -325,6 +325,30 @@ describe("resolveHeartbeatDeliveryTarget", () => { lastAccountId: undefined, }, }, + { + name: "allow direct target by default", + cfg: { agents: { defaults: { heartbeat: { target: "last" } } } }, + entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + expected: { + channel: "telegram", + to: "5232990709", + accountId: undefined, + lastChannel: "telegram", + lastAccountId: undefined, + }, + }, + { + name: "block direct target when directPolicy is block", + cfg: { agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } } } }, + entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + expected: { + channel: "none", + reason: "dm-blocked", + accountId: undefined, + lastChannel: "telegram", + lastAccountId: undefined, + }, + }, ]; for (const testCase of cases) { expect( diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 8f120702de0..cbad502cdde 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("63448508"); }); - it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => { + it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -317,12 +317,34 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("slack"); + expect(resolved.to).toBe("user:U123"); + expect(resolved.threadId).toBeUndefined(); + }); + + it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-outbound", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); expect(resolved.threadId).toBeUndefined(); }); - it("blocks heartbeat delivery to Discord DMs", () => { + it("allows heartbeat delivery to Discord DMs by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -337,11 +359,11 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); + expect(resolved.channel).toBe("discord"); + expect(resolved.to).toBe("user:12345"); }); - it("blocks heartbeat delivery to Telegram direct chats", () => { + it("allows heartbeat delivery to Telegram direct chats by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -356,6 +378,26 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("5232990709"); + }); + + it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); }); @@ -379,7 +421,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("-1001234567890"); }); - it("blocks heartbeat delivery to WhatsApp direct chats", () => { + it("allows heartbeat delivery to WhatsApp direct chats by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -394,8 +436,8 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+15551234567"); }); it("keeps heartbeat delivery to WhatsApp groups", () => { @@ -417,7 +459,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("120363140186826074@g.us"); }); - it("uses session chatType hint when target parser cannot classify", () => { + it("uses session chatType hint when target parser cannot classify and allows direct by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -433,6 +475,27 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("imessage"); + expect(resolved.to).toBe("chat-guid-unknown-shape"); + }); + + it("blocks session chatType direct hints when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index d9411e2223c..89e68e57566 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -330,7 +330,7 @@ export function resolveHeartbeatDeliveryTarget(params: { to: resolved.to, sessionChatType: sessionChatTypeHint, }); - if (deliveryChatType === "direct") { + if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") { return buildNoHeartbeatDeliveryTarget({ reason: "dm-blocked", accountId: effectiveAccountId, From de61e9c9771899d76710251c2f445a75d6488644 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:59:08 +0100 Subject: [PATCH 70/95] refactor(security): unify path alias guard policies --- src/agents/apply-patch.ts | 13 ++--- src/agents/sandbox-paths.ts | 79 ++++++----------------------- src/agents/sandbox/fs-bridge.ts | 83 +++++------------------------- src/infra/hardlink-guards.ts | 4 +- src/infra/path-alias-guards.ts | 89 +++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 142 deletions(-) create mode 100644 src/infra/path-alias-guards.ts diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index 4b147fd79fb..4f1487d34ea 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js"; import { applyUpdateHunk } from "./apply-patch-update.js"; import { assertSandboxPath, resolveSandboxInputPath } from "./sandbox-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -154,7 +155,7 @@ export async function applyPatch( } if (hunk.kind === "delete") { - const target = await resolvePatchPath(hunk.path, options, "unlink"); + const target = await resolvePatchPath(hunk.path, options, PATH_ALIAS_POLICIES.unlinkTarget); await fileOps.remove(target.resolved); recordSummary(summary, seen, "deleted", target.display); continue; @@ -253,7 +254,7 @@ async function ensureDir(filePath: string, ops: PatchFileOps) { async function resolvePatchPath( filePath: string, options: ApplyPatchOptions, - purpose: "readWrite" | "unlink" = "readWrite", + aliasPolicy: PathAliasPolicy = PATH_ALIAS_POLICIES.strict, ): Promise<{ resolved: string; display: string }> { if (options.sandbox) { const resolved = options.sandbox.bridge.resolvePath({ @@ -265,8 +266,8 @@ async function resolvePatchPath( filePath: resolved.hostPath, cwd: options.cwd, root: options.cwd, - allowFinalSymlink: purpose === "unlink", - allowFinalHardlink: purpose === "unlink", + allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink, }); } return { @@ -282,8 +283,8 @@ async function resolvePatchPath( filePath, cwd: options.cwd, root: options.cwd, - allowFinalSymlink: purpose === "unlink", - allowFinalHardlink: purpose === "unlink", + allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink, }) ).resolved : resolvePathFromCwd(filePath, options.cwd); diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index b50e90c3241..7cb026c28a4 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -1,9 +1,8 @@ -import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; -import { assertNoHardlinkedFinalPath } from "../infra/hardlink-guards.js"; -import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js"; +import { isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; @@ -62,18 +61,19 @@ export async function assertSandboxPath(params: { filePath: string; cwd: string; root: string; - allowFinalSymlink?: boolean; - allowFinalHardlink?: boolean; + allowFinalSymlinkForUnlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; }) { const resolved = resolveSandboxPath(params); - await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), { - allowFinalSymlink: params.allowFinalSymlink, - }); - await assertNoHardlinkedFinalPath({ - filePath: resolved.resolved, - root: path.resolve(params.root), + const policy: PathAliasPolicy = { + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: params.allowFinalHardlinkForUnlink, + }; + await assertNoPathAliasEscape({ + absolutePath: resolved.resolved, + rootPath: path.resolve(params.root), boundaryLabel: "sandbox root", - allowFinalHardlink: params.allowFinalHardlink, + policy, }); return resolved; } @@ -202,62 +202,13 @@ async function assertNoTmpAliasEscape(params: { filePath: string; tmpRoot: string; }): Promise { - await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot); - await assertNoHardlinkedFinalPath({ - filePath: params.filePath, - root: params.tmpRoot, + await assertNoPathAliasEscape({ + absolutePath: params.filePath, + rootPath: params.tmpRoot, boundaryLabel: "tmp root", }); } -async function assertNoSymlinkEscape( - relative: string, - root: string, - options?: { allowFinalSymlink?: boolean }, -) { - if (!relative) { - return; - } - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - const part = parts[idx]; - const isLast = idx === parts.length - 1; - current = path.join(current, part); - try { - const stat = await fs.lstat(current); - if (stat.isSymbolicLink()) { - // Unlinking a symlink itself is safe even if it points outside the root. What we - // must prevent is traversing through a symlink to reach targets outside root. - if (options?.allowFinalSymlink && isLast) { - return; - } - const target = await tryRealpath(current); - if (!isPathInside(rootReal, target)) { - throw new Error( - `Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`, - ); - } - current = target; - } - } catch (err) { - if (isNotFoundPathError(err)) { - return; - } - throw err; - } - } -} - -async function tryRealpath(value: string): Promise { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } -} - function shortPath(value: string) { if (value.startsWith(os.homedir())) { return `~${value.slice(os.homedir().length)}`; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 18991f60da6..23ebcce51b1 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,7 +1,8 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { assertNoHardlinkedFinalPath } from "../../infra/hardlink-guards.js"; -import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; +import { + assertNoPathAliasEscape, + PATH_ALIAS_POLICIES, + type PathAliasPolicy, +} from "../../infra/path-alias-guards.js"; import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; import { buildSandboxFsMounts, @@ -21,8 +22,7 @@ type RunCommandOptions = { type PathSafetyOptions = { action: string; - allowFinalSymlink?: boolean; - allowFinalHardlink?: boolean; + aliasPolicy?: PathAliasPolicy; requireWritable?: boolean; }; @@ -152,8 +152,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.assertPathSafety(target, { action: "remove files", requireWritable: true, - allowFinalSymlink: true, - allowFinalHardlink: true, + aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget, }); const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter( Boolean, @@ -178,8 +177,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.assertPathSafety(from, { action: "rename files", requireWritable: true, - allowFinalSymlink: true, - allowFinalHardlink: true, + aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget, }); await this.assertPathSafety(to, { action: "rename files", @@ -256,21 +254,16 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ); } - await assertNoHostSymlinkEscape({ + await assertNoPathAliasEscape({ absolutePath: target.hostPath, rootPath: lexicalMount.hostRoot, - allowFinalSymlink: options.allowFinalSymlink === true, - }); - await assertNoHardlinkedFinalPath({ - filePath: target.hostPath, - root: lexicalMount.hostRoot, boundaryLabel: "sandbox mount root", - allowFinalHardlink: options.allowFinalHardlink === true, + policy: options.aliasPolicy, }); const canonicalContainerPath = await this.resolveCanonicalContainerPath({ containerPath: target.containerPath, - allowFinalSymlink: options.allowFinalSymlink === true, + allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true, }); const canonicalMount = this.resolveMountByContainerPath(canonicalContainerPath); if (!canonicalMount) { @@ -297,7 +290,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async resolveCanonicalContainerPath(params: { containerPath: string; - allowFinalSymlink: boolean; + allowFinalSymlinkForUnlink: boolean; }): Promise { const script = [ "set -eu", @@ -318,7 +311,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { 'printf "%s%s\\n" "$canonical" "$suffix"', ].join("\n"); const result = await this.runCommand(script, { - args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], }); const canonical = result.stdout.toString("utf8").trim(); if (!canonical.startsWith("/")) { @@ -361,53 +354,3 @@ function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { } return "other"; } - -async function assertNoHostSymlinkEscape(params: { - absolutePath: string; - rootPath: string; - allowFinalSymlink: boolean; -}): Promise { - const root = path.resolve(params.rootPath); - const target = path.resolve(params.absolutePath); - if (!isPathInside(root, target)) { - throw new Error(`Sandbox path escapes mount root (${root}): ${params.absolutePath}`); - } - const relative = path.relative(root, target); - if (!relative) { - return; - } - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - current = path.join(current, parts[idx] ?? ""); - const isLast = idx === parts.length - 1; - try { - const stat = await fs.lstat(current); - if (!stat.isSymbolicLink()) { - continue; - } - if (params.allowFinalSymlink && isLast) { - return; - } - const symlinkTarget = await tryRealpath(current); - if (!isPathInside(rootReal, symlinkTarget)) { - throw new Error(`Symlink escapes sandbox mount root (${rootReal}): ${current}`); - } - current = symlinkTarget; - } catch (error) { - if (isNotFoundPathError(error)) { - return; - } - throw error; - } - } -} - -async function tryRealpath(value: string): Promise { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } -} diff --git a/src/infra/hardlink-guards.ts b/src/infra/hardlink-guards.ts index 9681bc09b78..ad99729b463 100644 --- a/src/infra/hardlink-guards.ts +++ b/src/infra/hardlink-guards.ts @@ -6,9 +6,9 @@ export async function assertNoHardlinkedFinalPath(params: { filePath: string; root: string; boundaryLabel: string; - allowFinalHardlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; }): Promise { - if (params.allowFinalHardlink) { + if (params.allowFinalHardlinkForUnlink) { return; } let stat: Awaited>; diff --git a/src/infra/path-alias-guards.ts b/src/infra/path-alias-guards.ts new file mode 100644 index 00000000000..86d08a3e44a --- /dev/null +++ b/src/infra/path-alias-guards.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js"; +import { isNotFoundPathError, isPathInside } from "./path-guards.js"; + +export type PathAliasPolicy = { + allowFinalSymlinkForUnlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; +}; + +export const PATH_ALIAS_POLICIES = { + strict: Object.freeze({ + allowFinalSymlinkForUnlink: false, + allowFinalHardlinkForUnlink: false, + }), + unlinkTarget: Object.freeze({ + allowFinalSymlinkForUnlink: true, + allowFinalHardlinkForUnlink: true, + }), +} as const; + +export async function assertNoPathAliasEscape(params: { + absolutePath: string; + rootPath: string; + boundaryLabel: string; + policy?: PathAliasPolicy; +}): Promise { + const root = path.resolve(params.rootPath); + const target = path.resolve(params.absolutePath); + if (!isPathInside(root, target)) { + throw new Error( + `Path escapes ${params.boundaryLabel} (${shortPath(root)}): ${shortPath(params.absolutePath)}`, + ); + } + const relative = path.relative(root, target); + if (relative) { + const rootReal = await tryRealpath(root); + const parts = relative.split(path.sep).filter(Boolean); + let current = root; + for (let idx = 0; idx < parts.length; idx += 1) { + current = path.join(current, parts[idx] ?? ""); + const isLast = idx === parts.length - 1; + try { + const stat = await fs.lstat(current); + if (!stat.isSymbolicLink()) { + continue; + } + if (params.policy?.allowFinalSymlinkForUnlink && isLast) { + return; + } + const symlinkTarget = await tryRealpath(current); + if (!isPathInside(rootReal, symlinkTarget)) { + throw new Error( + `Symlink escapes ${params.boundaryLabel} (${shortPath(rootReal)}): ${shortPath(current)}`, + ); + } + current = symlinkTarget; + } catch (error) { + if (isNotFoundPathError(error)) { + break; + } + throw error; + } + } + } + + await assertNoHardlinkedFinalPath({ + filePath: target, + root, + boundaryLabel: params.boundaryLabel, + allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink, + }); +} + +async function tryRealpath(value: string): Promise { + try { + return await fs.realpath(value); + } catch { + return path.resolve(value); + } +} + +function shortPath(value: string) { + if (value.startsWith(os.homedir())) { + return `~${value.slice(os.homedir().length)}`; + } + return value; +} From 4ada143794789dd8ee7eff523ae520034f321ae1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:59:30 +0100 Subject: [PATCH 71/95] docs(heartbeat): add directPolicy to config examples --- docs/gateway/configuration-examples.md | 1 + docs/gateway/heartbeat.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index d3838bbdae6..abc010ce8fe 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -273,6 +273,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. every: "30m", model: "anthropic/claude-sonnet-4-5", target: "last", + directPolicy: "allow", // allow (default) | block to: "+15555550123", prompt: "HEARTBEAT", ackMaxChars: 300, diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 70f4b968233..a4f4aa64ea9 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -32,6 +32,7 @@ Example config: heartbeat: { every: "30m", target: "last", // explicit delivery to last contact (default is "none") + directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress // activeHours: { start: "08:00", end: "24:00" }, // includeReasoning: true, // optional: send separate `Reasoning:` message too }, From e16e8f5af2ce51565d39e9a56c6e5247b07a32b6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:01:19 +0100 Subject: [PATCH 72/95] refactor(slack): share system-event ingress and test harness --- src/slack/monitor/events/pins.test.ts | 58 +++---------------- src/slack/monitor/events/pins.ts | 32 ++++------ src/slack/monitor/events/reactions.test.ts | 58 +++---------------- src/slack/monitor/events/reactions.ts | 34 +++++------ .../monitor/events/system-event-context.ts | 44 ++++++++++++++ .../events/system-event-test-harness.ts | 56 ++++++++++++++++++ 6 files changed, 144 insertions(+), 138 deletions(-) create mode 100644 src/slack/monitor/events/system-event-context.ts create mode 100644 src/slack/monitor/events/system-event-test-harness.ts diff --git a/src/slack/monitor/events/pins.test.ts b/src/slack/monitor/events/pins.test.ts index 3bdae247613..00c2528bbdb 100644 --- a/src/slack/monitor/events/pins.test.ts +++ b/src/slack/monitor/events/pins.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { SlackMonitorContext } from "../context.js"; import { registerSlackPinEvents } from "./pins.js"; +import { + createSlackSystemEventTestHarness, + type SlackSystemEventTestOverrides, +} from "./system-event-test-harness.js"; const enqueueSystemEventMock = vi.fn(); const readAllowFromStoreMock = vi.fn(); @@ -15,55 +18,12 @@ vi.mock("../../../pairing/pairing-store.js", () => ({ type SlackPinHandler = (args: { event: Record; body: unknown }) => Promise; -function createPinContext(overrides?: { - dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; - allowFrom?: string[]; - channelType?: "im" | "channel"; - channelUsers?: string[]; -}) { - let addedHandler: SlackPinHandler | null = null; - let removedHandler: SlackPinHandler | null = null; - const channelType = overrides?.channelType ?? "im"; - const app = { - event: vi.fn((name: string, handler: SlackPinHandler) => { - if (name === "pin_added") { - addedHandler = handler; - } else if (name === "pin_removed") { - removedHandler = handler; - } - }), - }; - const ctx = { - app, - runtime: { error: vi.fn() }, - dmEnabled: true, - dmPolicy: overrides?.dmPolicy ?? "open", - defaultRequireMention: true, - channelsConfig: overrides?.channelUsers - ? { - C1: { - users: overrides.channelUsers, - allow: true, - }, - } - : undefined, - groupPolicy: "open", - allowFrom: overrides?.allowFrom ?? [], - allowNameMatching: false, - shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), - isChannelAllowed: vi.fn().mockReturnValue(true), - resolveChannelName: vi.fn().mockResolvedValue({ - name: channelType === "im" ? "direct" : "general", - type: channelType, - }), - resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), - resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), - } as unknown as SlackMonitorContext; - registerSlackPinEvents({ ctx }); +function createPinContext(overrides?: SlackSystemEventTestOverrides) { + const harness = createSlackSystemEventTestHarness(overrides); + registerSlackPinEvents({ ctx: harness.ctx }); return { - ctx, - getAddedHandler: () => addedHandler, - getRemovedHandler: () => removedHandler, + getAddedHandler: () => harness.getHandler("pin_added") as SlackPinHandler | null, + getRemovedHandler: () => harness.getHandler("pin_removed") as SlackPinHandler | null, }; } diff --git a/src/slack/monitor/events/pins.ts b/src/slack/monitor/events/pins.ts index 89d0e2264e8..9a63aa4a972 100644 --- a/src/slack/monitor/events/pins.ts +++ b/src/slack/monitor/events/pins.ts @@ -1,10 +1,9 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger, logVerbose } from "../../../globals.js"; +import { danger } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { authorizeSlackSystemEventSender } from "../auth.js"; -import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; +import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; async function handleSlackPinEvent(params: { ctx: SlackMonitorContext; @@ -23,33 +22,26 @@ async function handleSlackPinEvent(params: { const payload = event as SlackPinEvent; const channelId = payload.channel_id; - const auth = await authorizeSlackSystemEventSender({ + const ingressContext = await authorizeAndResolveSlackSystemEventContext({ ctx, senderId: payload.user, channelId, + eventKind: "pin", }); - if (!auth.allowed) { - logVerbose( - `slack: drop pin sender ${payload.user ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, - ); + if (!ingressContext) { return; } - const label = resolveSlackChannelLabel({ - channelId, - channelName: auth.channelName, - }); const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {}; const userLabel = userInfo?.name ?? payload.user ?? "someone"; const itemType = payload.item?.type ?? "item"; const messageId = payload.item?.message?.ts ?? payload.event_ts; - const sessionKey = ctx.resolveSlackSystemEventSessionKey({ - channelId, - channelType: auth.channelType, - }); - enqueueSystemEvent(`Slack: ${userLabel} ${action} a ${itemType} in ${label}.`, { - sessionKey, - contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`, - }); + enqueueSystemEvent( + `Slack: ${userLabel} ${action} a ${itemType} in ${ingressContext.channelLabel}.`, + { + sessionKey: ingressContext.sessionKey, + contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`, + }, + ); } catch (err) { ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${String(err)}`)); } diff --git a/src/slack/monitor/events/reactions.test.ts b/src/slack/monitor/events/reactions.test.ts index bb64fbb5b4a..e95a1ec5a8c 100644 --- a/src/slack/monitor/events/reactions.test.ts +++ b/src/slack/monitor/events/reactions.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { SlackMonitorContext } from "../context.js"; import { registerSlackReactionEvents } from "./reactions.js"; +import { + createSlackSystemEventTestHarness, + type SlackSystemEventTestOverrides, +} from "./system-event-test-harness.js"; const enqueueSystemEventMock = vi.fn(); const readAllowFromStoreMock = vi.fn(); @@ -18,55 +21,12 @@ type SlackReactionHandler = (args: { body: unknown; }) => Promise; -function createReactionContext(overrides?: { - dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; - allowFrom?: string[]; - channelType?: "im" | "channel"; - channelUsers?: string[]; -}) { - let addedHandler: SlackReactionHandler | null = null; - let removedHandler: SlackReactionHandler | null = null; - const channelType = overrides?.channelType ?? "im"; - const app = { - event: vi.fn((name: string, handler: SlackReactionHandler) => { - if (name === "reaction_added") { - addedHandler = handler; - } else if (name === "reaction_removed") { - removedHandler = handler; - } - }), - }; - const ctx = { - app, - runtime: { error: vi.fn() }, - dmEnabled: true, - dmPolicy: overrides?.dmPolicy ?? "open", - defaultRequireMention: true, - channelsConfig: overrides?.channelUsers - ? { - C1: { - users: overrides.channelUsers, - allow: true, - }, - } - : undefined, - groupPolicy: "open", - allowFrom: overrides?.allowFrom ?? [], - allowNameMatching: false, - shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false), - isChannelAllowed: vi.fn().mockReturnValue(true), - resolveChannelName: vi.fn().mockResolvedValue({ - name: channelType === "im" ? "direct" : "general", - type: channelType, - }), - resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }), - resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"), - } as unknown as SlackMonitorContext; - registerSlackReactionEvents({ ctx }); +function createReactionContext(overrides?: SlackSystemEventTestOverrides) { + const harness = createSlackSystemEventTestHarness(overrides); + registerSlackReactionEvents({ ctx: harness.ctx }); return { - ctx, - getAddedHandler: () => addedHandler, - getRemovedHandler: () => removedHandler, + getAddedHandler: () => harness.getHandler("reaction_added") as SlackReactionHandler | null, + getRemovedHandler: () => harness.getHandler("reaction_removed") as SlackReactionHandler | null, }; } diff --git a/src/slack/monitor/events/reactions.ts b/src/slack/monitor/events/reactions.ts index 844b6c94080..07dcf0f8be3 100644 --- a/src/slack/monitor/events/reactions.ts +++ b/src/slack/monitor/events/reactions.ts @@ -1,10 +1,9 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; -import { danger, logVerbose } from "../../../globals.js"; +import { danger } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; -import { authorizeSlackSystemEventSender } from "../auth.js"; -import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; import type { SlackReactionEvent } from "../types.js"; +import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; @@ -16,35 +15,30 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext } return; } - const auth = await authorizeSlackSystemEventSender({ + const ingressContext = await authorizeAndResolveSlackSystemEventContext({ ctx, senderId: event.user, channelId: item.channel, + eventKind: "reaction", }); - if (!auth.allowed) { - logVerbose( - `slack: drop reaction sender ${event.user ?? "unknown"} channel=${item.channel ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, - ); + if (!ingressContext) { return; } - const channelLabel = resolveSlackChannelLabel({ - channelId: item.channel, - channelName: auth.channelName, - }); - const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined; + const actorInfoPromise: Promise<{ name?: string } | undefined> = event.user + ? ctx.resolveUserName(event.user) + : Promise.resolve(undefined); + const authorInfoPromise: Promise<{ name?: string } | undefined> = event.item_user + ? ctx.resolveUserName(event.item_user) + : Promise.resolve(undefined); + const [actorInfo, authorInfo] = await Promise.all([actorInfoPromise, authorInfoPromise]); const actorLabel = actorInfo?.name ?? event.user; const emojiLabel = event.reaction ?? "emoji"; - const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined; const authorLabel = authorInfo?.name ?? event.item_user; - const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`; + const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${ingressContext.channelLabel} msg ${item.ts}`; const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - const sessionKey = ctx.resolveSlackSystemEventSessionKey({ - channelId: item.channel, - channelType: auth.channelType, - }); enqueueSystemEvent(text, { - sessionKey, + sessionKey: ingressContext.sessionKey, contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`, }); } catch (err) { diff --git a/src/slack/monitor/events/system-event-context.ts b/src/slack/monitor/events/system-event-context.ts new file mode 100644 index 00000000000..5df48dfd167 --- /dev/null +++ b/src/slack/monitor/events/system-event-context.ts @@ -0,0 +1,44 @@ +import { logVerbose } from "../../../globals.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; +import { resolveSlackChannelLabel } from "../channel-config.js"; +import type { SlackMonitorContext } from "../context.js"; + +export type SlackAuthorizedSystemEventContext = { + channelLabel: string; + sessionKey: string; +}; + +export async function authorizeAndResolveSlackSystemEventContext(params: { + ctx: SlackMonitorContext; + senderId?: string; + channelId?: string; + channelType?: string | null; + eventKind: string; +}): Promise { + const { ctx, senderId, channelId, channelType, eventKind } = params; + const auth = await authorizeSlackSystemEventSender({ + ctx, + senderId, + channelId, + channelType, + }); + if (!auth.allowed) { + logVerbose( + `slack: drop ${eventKind} sender ${senderId ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + return undefined; + } + + const channelLabel = resolveSlackChannelLabel({ + channelId, + channelName: auth.channelName, + }); + const sessionKey = ctx.resolveSlackSystemEventSessionKey({ + channelId, + channelType: auth.channelType, + }); + return { + channelLabel, + sessionKey, + }; +} diff --git a/src/slack/monitor/events/system-event-test-harness.ts b/src/slack/monitor/events/system-event-test-harness.ts new file mode 100644 index 00000000000..73a50d0444c --- /dev/null +++ b/src/slack/monitor/events/system-event-test-harness.ts @@ -0,0 +1,56 @@ +import type { SlackMonitorContext } from "../context.js"; + +export type SlackSystemEventHandler = (args: { + event: Record; + body: unknown; +}) => Promise; + +export type SlackSystemEventTestOverrides = { + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + allowFrom?: string[]; + channelType?: "im" | "channel"; + channelUsers?: string[]; +}; + +export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTestOverrides) { + const handlers: Record = {}; + const channelType = overrides?.channelType ?? "im"; + const app = { + event: (name: string, handler: SlackSystemEventHandler) => { + handlers[name] = handler; + }, + }; + const ctx = { + app, + runtime: { error: () => {} }, + dmEnabled: true, + dmPolicy: overrides?.dmPolicy ?? "open", + defaultRequireMention: true, + channelsConfig: overrides?.channelUsers + ? { + C1: { + users: overrides.channelUsers, + allow: true, + }, + } + : undefined, + groupPolicy: "open", + allowFrom: overrides?.allowFrom ?? [], + allowNameMatching: false, + shouldDropMismatchedSlackEvent: () => false, + isChannelAllowed: () => true, + resolveChannelName: async () => ({ + name: channelType === "im" ? "direct" : "general", + type: channelType, + }), + resolveUserName: async () => ({ name: "alice" }), + resolveSlackSystemEventSessionKey: () => "agent:main:main", + } as unknown as SlackMonitorContext; + + return { + ctx, + getHandler(name: string): SlackSystemEventHandler | null { + return handlers[name] ?? null; + }, + }; +} From b37dc42240726c4778ea193d172cfbb74bfa9912 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 03:07:27 +0000 Subject: [PATCH 73/95] fix(cron): suppress fallback summary after attempted announce delivery --- CHANGELOG.md | 1 + ...p-recipient-besteffortdeliver-true.test.ts | 28 +++++++++++++++++++ src/cron/isolated-agent/delivery-dispatch.ts | 16 +++++++++++ src/cron/isolated-agent/run.ts | 24 +++++++++++++--- ...runs-one-shot-main-job-disables-it.test.ts | 22 +++++++++++++++ src/cron/service/state.ts | 5 ++++ src/cron/service/timer.ts | 23 ++++++++++----- 7 files changed, 108 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fa2607a19..717451a2e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. +- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018) - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. - Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 7d2dc3cf07a..01a407692e0 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -56,6 +56,7 @@ async function expectBestEffortTelegramNotDelivered( expect(res.status).toBe("ok"); expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); }); @@ -287,6 +288,33 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("marks attempted when announce delivery reports false and best-effort is enabled", async () => { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "hello from cron" }]); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: true, + }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + it("ignores structured direct delivery failures when best-effort is enabled", async () => { await expectBestEffortTelegramNotDelivered({ text: "hello from cron", diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 697c0e2b8a8..1feae211df8 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -117,6 +117,7 @@ type DispatchCronDeliveryParams = { export type DispatchCronDeliveryState = { result?: RunCronAgentTurnResult; delivered: boolean; + deliveryAttempted: boolean; summary?: string; outputText?: string; synthesizedText?: string; @@ -134,6 +135,7 @@ export async function dispatchCronDelivery( // `true` means we confirmed at least one outbound send reached the target. // Keep this strict so timer fallback can safely decide whether to wake main. let delivered = params.skipMessagingToolDelivery; + let deliveryAttempted = params.skipMessagingToolDelivery; const failDeliveryTarget = (error: string) => params.withRunSession({ status: "error", @@ -141,6 +143,7 @@ export async function dispatchCronDelivery( errorKind: "delivery-target", summary, outputText, + deliveryAttempted, ...params.telemetry, }); @@ -162,9 +165,11 @@ export async function dispatchCronDelivery( return params.withRunSession({ status: "error", error: params.abortReason(), + deliveryAttempted, ...params.telemetry, }); } + deliveryAttempted = true; const deliveryResults = await deliverOutboundPayloads({ cfg: params.cfgWithAgentDefaults, channel: delivery.channel, @@ -187,6 +192,7 @@ export async function dispatchCronDelivery( summary, outputText, error: String(err), + deliveryAttempted, ...params.telemetry, }); } @@ -277,9 +283,11 @@ export async function dispatchCronDelivery( return params.withRunSession({ status: "error", error: params.abortReason(), + deliveryAttempted, ...params.telemetry, }); } + deliveryAttempted = true; const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: params.agentSessionKey, childRunId: `${params.job.id}:${params.runSessionId}:${params.runStartedAt}`, @@ -315,6 +323,7 @@ export async function dispatchCronDelivery( summary, outputText, error: message, + deliveryAttempted, ...params.telemetry, }); } @@ -327,6 +336,7 @@ export async function dispatchCronDelivery( summary, outputText, error: String(err), + deliveryAttempted, ...params.telemetry, }); } @@ -345,6 +355,7 @@ export async function dispatchCronDelivery( return { result: failDeliveryTarget(params.resolvedDelivery.error.message), delivered, + deliveryAttempted, summary, outputText, synthesizedText, @@ -357,9 +368,11 @@ export async function dispatchCronDelivery( status: "ok", summary, outputText, + deliveryAttempted, ...params.telemetry, }), delivered, + deliveryAttempted, summary, outputText, synthesizedText, @@ -383,6 +396,7 @@ export async function dispatchCronDelivery( return { result: directResult, delivered, + deliveryAttempted, summary, outputText, synthesizedText, @@ -395,6 +409,7 @@ export async function dispatchCronDelivery( return { result: announceResult, delivered, + deliveryAttempted, summary, outputText, synthesizedText, @@ -406,6 +421,7 @@ export async function dispatchCronDelivery( return { delivered, + deliveryAttempted, summary, outputText, synthesizedText, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 751ea2bc13e..10b8b5c7414 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -77,6 +77,12 @@ export type RunCronAgentTurnResult = { * messages. See: https://github.com/openclaw/openclaw/issues/15692 */ delivered?: boolean; + /** + * `true` when cron attempted announce/direct delivery for this run. + * This is tracked separately from `delivered` because some announce paths + * cannot guarantee a final delivery ack synchronously. + */ + deliveryAttempted?: boolean; } & CronRunOutcome & CronRunTelemetry; @@ -565,7 +571,7 @@ export async function runCronIsolatedAgentTurn(params: { const embeddedRunError = hasErrorPayload ? (lastErrorPayloadText ?? "cron isolated run returned an error payload") : undefined; - const resolveRunOutcome = (params?: { delivered?: boolean }) => + const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) => withRunSession({ status: hasErrorPayload ? "error" : "ok", ...(hasErrorPayload @@ -574,6 +580,7 @@ export async function runCronIsolatedAgentTurn(params: { summary, outputText, delivered: params?.delivered, + deliveryAttempted: params?.deliveryAttempted, ...telemetry, }); @@ -619,14 +626,23 @@ export async function runCronIsolatedAgentTurn(params: { withRunSession, }); if (deliveryResult.result) { + const resultWithDeliveryMeta: RunCronAgentTurnResult = { + ...deliveryResult.result, + deliveryAttempted: + deliveryResult.result.deliveryAttempted ?? deliveryResult.deliveryAttempted, + }; if (!hasErrorPayload || deliveryResult.result.status !== "ok") { - return deliveryResult.result; + return resultWithDeliveryMeta; } - return resolveRunOutcome({ delivered: deliveryResult.result.delivered }); + return resolveRunOutcome({ + delivered: deliveryResult.result.delivered, + deliveryAttempted: resultWithDeliveryMeta.deliveryAttempted, + }); } const delivered = deliveryResult.delivered; + const deliveryAttempted = deliveryResult.deliveryAttempted; summary = deliveryResult.summary; outputText = deliveryResult.outputText; - return resolveRunOutcome({ delivered }); + return resolveRunOutcome({ delivered, deliveryAttempted }); } diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 027a464357d..37079addef0 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -625,6 +625,28 @@ describe("CronService", () => { await store.cleanup(); }); + it("does not post isolated summary to main when announce delivery was attempted", async () => { + const runIsolatedAgentJob = vi.fn(async () => ({ + status: "ok" as const, + summary: "done", + delivered: false, + deliveryAttempted: true, + })); + const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = + await createIsolatedAnnounceHarness(runIsolatedAgentJob); + await runIsolatedAnnounceJobAndWait({ + cron, + events, + name: "weekly attempted", + status: "ok", + }); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + cron.stop(); + await store.cleanup(); + }); + it("migrates legacy payload.provider to payload.channel on load", async () => { const rawJob = createLegacyDeliveryMigrationJob({ id: "legacy-1", diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 19b139b3703..3ad9cc1f591 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -80,6 +80,11 @@ export type CronServiceDeps = { * https://github.com/openclaw/openclaw/issues/15692 */ delivered?: boolean; + /** + * `true` when announce/direct delivery was attempted for this run, even + * if the final per-message ack status is uncertain. + */ + deliveryAttempted?: boolean; } & CronRunOutcome & CronRunTelemetry >; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 34cdab97f5a..acb3f3037d3 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -41,6 +41,7 @@ type TimedCronRunOutcome = CronRunOutcome & CronRunTelemetry & { jobId: string; delivered?: boolean; + deliveryAttempted?: boolean; startedAt: number; endedAt: number; }; @@ -606,7 +607,9 @@ export async function executeJobCore( state: CronServiceState, job: CronJob, abortSignal?: AbortSignal, -): Promise { +): Promise< + CronRunOutcome & CronRunTelemetry & { delivered?: boolean; deliveryAttempted?: boolean } +> { const resolveAbortError = () => ({ status: "error" as const, error: timeoutErrorMessage(), @@ -729,17 +732,22 @@ export async function executeJobCore( return { status: "error", error: timeoutErrorMessage() }; } - // Post a short summary back to the main session — but only when the - // isolated run did NOT already deliver its output to the target channel. - // When `res.delivered` is true the announce flow (or direct outbound - // delivery) already sent the result, so posting the summary to main - // would wake the main agent and cause a duplicate message. + // Post a short summary back to the main session only when announce + // delivery was requested and we are confident no outbound delivery path + // ran. If delivery was attempted but final ack is uncertain, suppress the + // main summary to avoid duplicate user-facing sends. // See: https://github.com/openclaw/openclaw/issues/15692 const summaryText = res.summary?.trim(); const deliveryPlan = resolveCronDeliveryPlan(job); const suppressMainSummary = res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested; - if (summaryText && deliveryPlan.requested && !res.delivered && !suppressMainSummary) { + if ( + summaryText && + deliveryPlan.requested && + !res.delivered && + res.deliveryAttempted !== true && + !suppressMainSummary + ) { const prefix = "Cron"; const label = res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`; @@ -762,6 +770,7 @@ export async function executeJobCore( error: res.error, summary: res.summary, delivered: res.delivered, + deliveryAttempted: res.deliveryAttempted, sessionId: res.sessionId, sessionKey: res.sessionKey, model: res.model, From 8a97803474fb4589e377e51dd724c48e4ea36403 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:11:27 +0100 Subject: [PATCH 74/95] fix(agents): normalize malformed tool results in adapter (#27007) --- CHANGELOG.md | 1 + src/agents/pi-tool-definition-adapter.test.ts | 51 +++++++++++++++++ src/agents/pi-tool-definition-adapter.ts | 56 ++++++++++++++++++- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717451a2e72..8f6b560f460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. - Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. - Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index 1b11bbf49be..6def07167cb 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -25,6 +25,15 @@ async function executeThrowingTool(name: string, callId: string) { return await def.execute(callId, {}, undefined, undefined, extensionContext); } +async function executeTool(tool: AgentTool, callId: string) { + const defs = toToolDefinitions([tool]); + const def = defs[0]; + if (!def) { + throw new Error("missing tool definition"); + } + return await def.execute(callId, {}, undefined, undefined, extensionContext); +} + describe("pi tool definition adapter", () => { it("wraps tool errors into a tool result", async () => { const result = await executeThrowingTool("boom", "call1"); @@ -46,4 +55,46 @@ describe("pi tool definition adapter", () => { error: "nope", }); }); + + it("coerces details-only tool results to include content", async () => { + const tool = { + name: "memory_query", + label: "Memory Query", + description: "returns details only", + parameters: Type.Object({}), + execute: (async () => ({ + details: { + hits: [{ id: "a1", score: 0.9 }], + }, + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call3"); + expect(result.details).toEqual({ + hits: [{ id: "a1", score: 0.9 }], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"hits"'); + }); + + it("coerces non-standard object results to include content", async () => { + const tool = { + name: "memory_query_raw", + label: "Memory Query Raw", + description: "returns plain object", + parameters: Type.Object({}), + execute: (async () => ({ + count: 2, + ids: ["m1", "m2"], + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call4"); + expect(result.details).toEqual({ + count: 2, + ids: ["m1", "m2"], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"count"'); + }); }); diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index f3963600c80..a6221586242 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -62,6 +62,56 @@ function describeToolExecutionError(err: unknown): { return { message: String(err) }; } +function stringifyToolPayload(payload: unknown): string { + if (typeof payload === "string") { + return payload; + } + try { + const encoded = JSON.stringify(payload, null, 2); + if (typeof encoded === "string") { + return encoded; + } + } catch { + // Fall through to String(payload) for non-serializable values. + } + return String(payload); +} + +function normalizeToolExecutionResult(params: { + toolName: string; + result: unknown; +}): AgentToolResult { + const { toolName, result } = params; + if (result && typeof result === "object") { + const record = result as Record; + if (Array.isArray(record.content)) { + return result as AgentToolResult; + } + logDebug(`tools: ${toolName} returned non-standard result (missing content[]); coercing`); + const details = "details" in record ? record.details : record; + const safeDetails = details ?? { status: "ok", tool: toolName }; + return { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; + } + const safeDetails = result ?? { status: "ok", tool: toolName }; + return { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; +} + function splitToolExecuteArgs(args: ToolExecuteArgsAny): { toolCallId: string; params: unknown; @@ -111,7 +161,11 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { } executeParams = hookOutcome.params; } - const result = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const result = normalizeToolExecutionResult({ + toolName: normalizedName, + result: rawResult, + }); const afterParams = beforeHookWrapped ? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams) : executeParams; From 8f8e2b13b47b48762e0d288e588f9805a37b3265 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:12:26 +0100 Subject: [PATCH 75/95] fix: disable tts tool for voice provider --- CHANGELOG.md | 1 + ...s-claude-style-aliases-schemas-without-dropping.test.ts | 5 +++++ src/agents/pi-tools.ts | 7 ++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f6b560f460..83c55a178fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. - Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042) - Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. +- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025) - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. - Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 22d68f15ff8..4f2b0be9f47 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -319,6 +319,11 @@ describe("createOpenClawCodingTools", () => { expect(names.has("telegram")).toBe(false); expect(names.has("whatsapp")).toBe(false); }); + it("does not expose tts tool for voice message provider", () => { + const tools = createOpenClawCodingTools({ messageProvider: "voice" }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(false); + }); it("filters session tools for sub-agent sessions by default", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index e2d29d375da..f4252f562bb 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -217,6 +217,8 @@ export function createOpenClawCodingTools(options?: { /** Whether the sender is an owner (required for owner-only tools). */ senderIsOwner?: boolean; }): AnyAgentTool[] { + const rawMessageProvider = options?.messageProvider?.trim().toLowerCase(); + const isVoiceMessageProvider = rawMessageProvider === "voice"; const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const { @@ -480,9 +482,12 @@ export function createOpenClawCodingTools(options?: { senderIsOwner: options?.senderIsOwner, }), ]; + const toolsForMessageProvider = isVoiceMessageProvider + ? tools.filter((tool) => tool.name !== "tts") + : tools; // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true; - const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner); + const toolsByAuthorization = applyOwnerOnlyToolPolicy(toolsForMessageProvider, senderIsOwner); const subagentFiltered = applyToolPolicyPipeline({ tools: toolsByAuthorization, toolMeta: (tool) => getPluginToolMeta(tool), From f789f880c934caa8be25b38832f27f90f37903db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:13:59 +0100 Subject: [PATCH 76/95] fix(security): harden approval-bound node exec cwd handling --- CHANGELOG.md | 1 + src/cli/nodes-cli.coverage.test.ts | 9 ++ src/cli/nodes-cli/register.invoke.ts | 1 + src/node-host/invoke-system-run.test.ts | 67 ++++++++++++++ src/node-host/invoke-system-run.ts | 113 ++++++++++++++++++++++++ 5 files changed, 191 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c55a178fa..c472e545356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index b8ddc75308c..cd3fe62857d 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -83,6 +83,11 @@ describe("nodes-cli coverage", () => { const getNodeInvokeCall = () => callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall; + const getApprovalRequestCall = () => + callGateway.mock.calls.find((call) => call[0]?.method === "exec.approval.request")?.[0] as { + params?: Record; + }; + const createNodesProgram = () => { const program = new Command(); program.exitOverride(); @@ -140,6 +145,8 @@ describe("nodes-cli coverage", () => { runId: expect.any(String), }); expect(invoke?.params?.timeoutMs).toBe(5000); + const approval = getApprovalRequestCall(); + expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]); }); it("invokes system.run with raw command", async () => { @@ -165,6 +172,8 @@ describe("nodes-cli coverage", () => { approvalDecision: "allow-once", runId: expect.any(String), }); + const approval = getApprovalRequestCall(); + expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]); }); it("invokes system.notify with provided fields", async () => { diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index a53cc783041..e644d754d12 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -252,6 +252,7 @@ export function registerNodesInvokeCommands(nodes: Command) { { id: approvalId, command: rawCommand ?? argv.join(" "), + commandArgv: argv, cwd: opts.cwd, nodeId, host: "node", diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index d1917199067..2682edd2423 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -49,6 +49,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; command?: string[]; + cwd?: string; security?: "full" | "allowlist"; ask?: "off" | "on-miss" | "always"; approved?: boolean; @@ -70,6 +71,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { client: {} as never, params: { command: params.command ?? ["echo", "ok"], + cwd: params.cwd, approved: params.approved ?? false, sessionKey: "agent:main:main", }, @@ -214,6 +216,71 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }), ); }); + + it.runIf(process.platform !== "win32")( + "denies approval-based execution when cwd is a symlink", + async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-link-")); + const safeDir = path.join(tmp, "safe"); + const linkDir = path.join(tmp, "cwd-link"); + const script = path.join(safeDir, "run.sh"); + fs.mkdirSync(safeDir, { recursive: true }); + fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(script, 0o755); + fs.symlinkSync(safeDir, linkDir, "dir"); + try { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: ["./run.sh"], + cwd: linkDir, + approved: true, + security: "full", + ask: "off", + }); + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("canonical cwd"), + }), + }), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it("uses canonical executable path for approval-based relative command execution", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-real-")); + const script = path.join(tmp, "run.sh"); + fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(script, 0o755); + try { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: ["./run.sh", "--flag"], + cwd: tmp, + approved: true, + security: "full", + ask: "off", + }); + expect(runCommand).toHaveBeenCalledWith( + [fs.realpathSync(script), "--flag"], + fs.realpathSync(tmp), + undefined, + undefined, + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + }), + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 39e6766f7d5..93edb85e0b7 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,4 +1,6 @@ import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import type { GatewayClient } from "../gateway/client.js"; @@ -18,6 +20,7 @@ import { } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; +import { sameFileIdentity } from "../infra/file-identity.js"; import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js"; import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; @@ -110,6 +113,100 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni } } +function isPathLikeExecutableToken(value: string): boolean { + if (!value) { + return false; + } + if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) { + return true; + } + if (value.includes("/") || value.includes("\\")) { + return true; + } + if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) { + return true; + } + return false; +} + +function hardenApprovedExecutionPaths(params: { + approvedByAsk: boolean; + argv: string[]; + shellCommand: string | null; + cwd: string | undefined; +}): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } { + if (!params.approvedByAsk) { + return { ok: true, argv: params.argv, cwd: params.cwd }; + } + + let hardenedCwd = params.cwd; + if (hardenedCwd) { + const requestedCwd = path.resolve(hardenedCwd); + let cwdLstat: fs.Stats; + let cwdStat: fs.Stats; + let cwdReal: string; + let cwdRealStat: fs.Stats; + try { + cwdLstat = fs.lstatSync(requestedCwd); + cwdStat = fs.statSync(requestedCwd); + cwdReal = fs.realpathSync(requestedCwd); + cwdRealStat = fs.statSync(cwdReal); + } catch { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd", + }; + } + if (!cwdStat.isDirectory()) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory", + }; + } + if (cwdLstat.isSymbolicLink()) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink cwd)", + }; + } + if ( + !sameFileIdentity(cwdStat, cwdLstat) || + !sameFileIdentity(cwdStat, cwdRealStat) || + !sameFileIdentity(cwdLstat, cwdRealStat) + ) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval cwd identity mismatch", + }; + } + hardenedCwd = cwdReal; + } + + if (params.shellCommand !== null || params.argv.length === 0) { + return { ok: true, argv: params.argv, cwd: hardenedCwd }; + } + + const argv = [...params.argv]; + const rawExecutable = argv[0] ?? ""; + if (!isPathLikeExecutableToken(rawExecutable)) { + return { ok: true, argv, cwd: hardenedCwd }; + } + + const base = hardenedCwd ?? process.cwd(); + const candidate = path.isAbsolute(rawExecutable) + ? rawExecutable + : path.resolve(base, rawExecutable); + try { + argv[0] = fs.realpathSync(candidate); + } catch { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval requires a stable executable path", + }; + } + return { ok: true, argv, cwd: hardenedCwd }; +} + export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams; @@ -422,6 +519,20 @@ async function evaluateSystemRunPolicyPhase( return null; } + const hardenedPaths = hardenApprovedExecutionPaths({ + approvedByAsk: policy.approvedByAsk, + argv: parsed.argv, + shellCommand: parsed.shellCommand, + cwd: parsed.cwd, + }); + if (!hardenedPaths.ok) { + await sendSystemRunDenied(opts, parsed.execution, { + reason: "approval-required", + message: hardenedPaths.message, + }); + return null; + } + const plannedAllowlistArgv = resolvePlannedAllowlistArgv({ security, shellCommand: parsed.shellCommand, @@ -437,6 +548,8 @@ async function evaluateSystemRunPolicyPhase( } return { ...parsed, + argv: hardenedPaths.argv, + cwd: hardenedPaths.cwd, approvals, security, policy, From e4d62c21be2f5454749d1a22b11f8f2dd6594d7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:15:04 +0100 Subject: [PATCH 77/95] test: expand voice provider tts regression coverage --- ...-style-aliases-schemas-without-dropping.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 4f2b0be9f47..e074b6f9189 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -319,10 +319,18 @@ describe("createOpenClawCodingTools", () => { expect(names.has("telegram")).toBe(false); expect(names.has("whatsapp")).toBe(false); }); - it("does not expose tts tool for voice message provider", () => { - const tools = createOpenClawCodingTools({ messageProvider: "voice" }); + it.each(["voice", "VOICE", " Voice "])( + "does not expose tts tool for normalized voice message provider: %s", + (messageProvider) => { + const tools = createOpenClawCodingTools({ messageProvider }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(false); + }, + ); + it("keeps tts tool for non-voice providers", () => { + const tools = createOpenClawCodingTools({ messageProvider: "discord" }); const names = new Set(tools.map((tool) => tool.name)); - expect(names.has("tts")).toBe(false); + expect(names.has("tts")).toBe(true); }); it("filters session tools for sub-agent sessions by default", () => { const tools = createOpenClawCodingTools({ From f55238e72a61a839d585364c689c6fa006136289 Mon Sep 17 00:00:00 2001 From: Hongwei Ma Date: Thu, 26 Feb 2026 00:35:11 +0800 Subject: [PATCH 78/95] chore: remove accidental PR_STATUS.md from repo This file appears to be a personal agent tracking document that was accidentally committed to the main repository. It contains internal PR submission plans and CI status tracking that doesn't belong in the upstream codebase. --- PR_STATUS.md | 78 ---------------------------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 PR_STATUS.md diff --git a/PR_STATUS.md b/PR_STATUS.md deleted file mode 100644 index 1887eca27d9..00000000000 --- a/PR_STATUS.md +++ /dev/null @@ -1,78 +0,0 @@ -# OpenClaw PR Submission Status - -> Auto-maintained by agent team. Last updated: 2026-02-22 - -## PR Plan Overview - -All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. -Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). - -## Duplicate Check - -Before submission, each PR was cross-referenced against: - -- 100+ open upstream PRs (as of 2026-02-22) -- 50 recently merged PRs -- 50+ open issues - -No overlap found with existing PRs. - -## PR Status Table - -| # | Branch | Title | Type | Status | PR URL | -| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | -| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | -| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | -| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | -| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | -| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | -| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | -| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | -| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | -| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | -| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | -| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | - -## Isolation Rules - -- Each agent works on a separate git worktree branch -- No two agents modify the same file -- File ownership: - - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` - - PR 2: `src/agents/session-slug.ts` - - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` - - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` - - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` - - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` - - PR 7: `src/telegram/bot-message-dispatch.ts` - - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` - - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` - - PR 10: `src/agents/skills-install-download.ts` - - PR 11: `src/browser/extension-relay.ts` - -## Verification Results - -### Batch 1 (PRs 1-4) — All CI Green - -- PR 1: 17 tests pass, check/build/tests all green -- PR 2: 3 tests pass, check/build/tests all green -- PR 3: 45 tests pass (3 new), check/build/tests all green -- PR 4: 12 tests pass, check/build/tests all green - -### Batch 2 (PRs 5-7) — CI Running - -- PR 5: 3 signal tests pass, check pass, awaiting full test suite -- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite -- PR 7: 47 tests pass (3 new), check pass, awaiting full suite - -### Batch 3 (PRs 8-9) — All CI Green - -- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. -- PR 8: 17/17 pass, check/build/tests/windows all green -- PR 9: 18/18 pass, check/build/tests/windows all green - -### Batch 4 (PRs 10-11) — All CI Green - -- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). -- PR 10: 19/19 pass, check/build/tests/windows all green -- PR 11: 20/20 pass, check/build/tests/windows all green From 243e28df4fb087869bc80946a2ad23b5db4b5075 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Wed, 25 Feb 2026 21:06:44 +0800 Subject: [PATCH 79/95] fix(line): keep startAccount pending until abort signal to prevent restart loop monitorLineProvider() registers the webhook HTTP route and returns immediately. Because startAccount() directly returned that resolved promise, the channel supervisor interpreted it as "provider exited" and triggered auto-restart up to 10 times. Await a promise gated on ctx.abortSignal so startAccount stays alive for the full provider lifecycle, matching the contract expected by the channel supervisor. Closes #26478 Co-authored-by: Cursor --- extensions/line/src/channel.startup.test.ts | 59 ++++++++++++++++++++- extensions/line/src/channel.ts | 16 +++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index e5b0ce333f5..11ba80bda12 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -37,6 +37,7 @@ function createStartAccountCtx(params: { token: string; secret: string; runtime: ReturnType; + abortSignal?: AbortSignal; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", @@ -56,7 +57,7 @@ function createStartAccountCtx(params: { }, cfg: {} as OpenClawConfig, runtime: params.runtime, - abortSignal: new AbortController().signal, + abortSignal: params.abortSignal ?? new AbortController().signal, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, getStatus: () => snapshot, setStatus: vi.fn(), @@ -104,14 +105,19 @@ describe("linePlugin gateway.startAccount", () => { const { runtime, monitorLineProvider } = createRuntime(); setLineRuntime(runtime); - await linePlugin.gateway!.startAccount!( + const abort = new AbortController(); + const task = linePlugin.gateway!.startAccount!( createStartAccountCtx({ token: "token", secret: "secret", runtime: createRuntimeEnv(), + abortSignal: abort.signal, }), ); + // Allow async internals (probeLineBot await) to flush + await new Promise((r) => setTimeout(r, 20)); + expect(monitorLineProvider).toHaveBeenCalledWith( expect.objectContaining({ channelAccessToken: "token", @@ -119,5 +125,54 @@ describe("linePlugin gateway.startAccount", () => { accountId: "default", }), ); + + abort.abort(); + await task; + }); + + it("stays pending until abort signal fires (no premature exit)", async () => { + const { runtime, monitorLineProvider } = createRuntime(); + setLineRuntime(runtime); + + const abort = new AbortController(); + let resolved = false; + + const task = linePlugin.gateway!.startAccount!( + createStartAccountCtx({ + token: "token", + secret: "secret", + runtime: createRuntimeEnv(), + abortSignal: abort.signal, + }), + ).then(() => { + resolved = true; + }); + + // Allow async internals to flush + await new Promise((r) => setTimeout(r, 50)); + + expect(monitorLineProvider).toHaveBeenCalled(); + expect(resolved).toBe(false); + + abort.abort(); + await task; + expect(resolved).toBe(true); + }); + + it("resolves immediately when abortSignal is already aborted", async () => { + const { runtime } = createRuntime(); + setLineRuntime(runtime); + + const abort = new AbortController(); + abort.abort(); + + await linePlugin.gateway!.startAccount!( + createStartAccountCtx({ + token: "token", + secret: "secret", + runtime: createRuntimeEnv(), + abortSignal: abort.signal, + }), + ); }); }); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index a260d96c961..f37a86aa0c4 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -651,7 +651,7 @@ export const linePlugin: ChannelPlugin = { ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`); - return getLineRuntime().channel.line.monitorLineProvider({ + const monitor = await getLineRuntime().channel.line.monitorLineProvider({ channelAccessToken: token, channelSecret: secret, accountId: account.accountId, @@ -660,6 +660,20 @@ export const linePlugin: ChannelPlugin = { abortSignal: ctx.abortSignal, webhookPath: account.config.webhookPath, }); + + // Keep the provider alive until the abort signal fires. Without this, + // the startAccount promise resolves immediately after webhook registration + // and the channel supervisor treats the provider as "exited", triggering an + // auto-restart loop (up to 10 attempts). + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) { + resolve(); + return; + } + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + + return monitor; }, logoutAccount: async ({ accountId, cfg }) => { const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? ""; From 9b81a530161cfb54d75fbf15e21a85808b87a48e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:16:10 +0100 Subject: [PATCH 80/95] fix: add changelog note for LINE lifecycle fix (#26528) (thanks @Sid-Qin) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c472e545356..3fe1e695a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. +- LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin. - Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. From 7af6849c2f85e212dfcdeeabc7b1b4b82939db0e Mon Sep 17 00:00:00 2001 From: Theo Tarr Date: Sun, 22 Feb 2026 13:43:06 -0500 Subject: [PATCH 81/95] Discord: handle early gateway startup errors --- CHANGELOG.md | 1 + .../monitor/provider.lifecycle.test.ts | 105 ++++++++++++++++-- src/discord/monitor/provider.lifecycle.ts | 62 +++++++---- src/discord/monitor/provider.test.ts | 39 ++++++- src/discord/monitor/provider.ts | 34 ++++++ 5 files changed, 205 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe1e695a7e..5f4e3fb749b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin. +- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr. - Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. diff --git a/src/discord/monitor/provider.lifecycle.test.ts b/src/discord/monitor/provider.lifecycle.test.ts index 9b74a0badfb..e503d88ccde 100644 --- a/src/discord/monitor/provider.lifecycle.test.ts +++ b/src/discord/monitor/provider.lifecycle.test.ts @@ -49,23 +49,33 @@ describe("runDiscordGatewayLifecycle", () => { accountId?: string; start?: () => Promise; stop?: () => Promise; + isDisallowedIntentsError?: (err: unknown) => boolean; + pendingGatewayErrors?: unknown[]; }) => { const start = vi.fn(params?.start ?? (async () => undefined)); const stop = vi.fn(params?.stop ?? (async () => undefined)); const threadStop = vi.fn(); + const runtimeError = vi.fn(); + const releaseEarlyGatewayErrorGuard = vi.fn(); return { start, stop, threadStop, + runtimeError, + releaseEarlyGatewayErrorGuard, lifecycleParams: { accountId: params?.accountId ?? "default", client: { getPlugin: vi.fn(() => undefined) } as unknown as Client, - runtime: {} as RuntimeEnv, - isDisallowedIntentsError: () => false, + runtime: { + error: runtimeError, + } as RuntimeEnv, + isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false), voiceManager: null, voiceManagerRef: { current: null }, execApprovalsHandler: { start, stop }, threadBindings: { stop: threadStop }, + pendingGatewayErrors: params?.pendingGatewayErrors, + releaseEarlyGatewayErrorGuard, }, }; }; @@ -75,6 +85,7 @@ describe("runDiscordGatewayLifecycle", () => { stop: ReturnType; threadStop: ReturnType; waitCalls: number; + releaseEarlyGatewayErrorGuard: ReturnType; }) { expect(params.start).toHaveBeenCalledTimes(1); expect(params.stop).toHaveBeenCalledTimes(1); @@ -82,39 +93,109 @@ describe("runDiscordGatewayLifecycle", () => { expect(unregisterGatewayMock).toHaveBeenCalledWith("default"); expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1); expect(params.threadStop).toHaveBeenCalledTimes(1); + expect(params.releaseEarlyGatewayErrorGuard).toHaveBeenCalledTimes(1); } it("cleans up thread bindings when exec approvals startup fails", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); - const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness({ - start: async () => { - throw new Error("startup failed"); - }, - }); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness({ + start: async () => { + throw new Error("startup failed"); + }, + }); await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed"); - expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 0 }); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); }); it("cleans up when gateway wait fails after startup", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed")); - const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness(); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness(); await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow( "gateway wait failed", ); - expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 }); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 1, + releaseEarlyGatewayErrorGuard, + }); }); it("cleans up after successful gateway wait", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); - const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness(); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness(); await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); - expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 }); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 1, + releaseEarlyGatewayErrorGuard, + }); + }); + + it("handles queued disallowed intents errors without waiting for gateway events", async () => { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { + lifecycleParams, + start, + stop, + threadStop, + runtimeError, + releaseEarlyGatewayErrorGuard, + } = createLifecycleHarness({ + pendingGatewayErrors: [new Error("Fatal Gateway error: 4014")], + isDisallowedIntentsError: (err) => String(err).includes("4014"), + }); + + await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); + + expect(runtimeError).toHaveBeenCalledWith( + expect.stringContaining("discord: gateway closed with code 4014"), + ); + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); + }); + + it("throws queued non-disallowed fatal gateway errors", async () => { + const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); + const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = + createLifecycleHarness({ + pendingGatewayErrors: [new Error("Fatal Gateway error: 4000")], + }); + + await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow( + "Fatal Gateway error: 4000", + ); + + expectLifecycleCleanup({ + start, + stop, + threadStop, + waitCalls: 0, + releaseEarlyGatewayErrorGuard, + }); }); }); diff --git a/src/discord/monitor/provider.lifecycle.ts b/src/discord/monitor/provider.lifecycle.ts index 8e5177bb945..489657d08bd 100644 --- a/src/discord/monitor/provider.lifecycle.ts +++ b/src/discord/monitor/provider.lifecycle.ts @@ -22,6 +22,8 @@ export async function runDiscordGatewayLifecycle(params: { voiceManagerRef: { current: DiscordVoiceManager | null }; execApprovalsHandler: ExecApprovalsHandler | null; threadBindings: { stop: () => void }; + pendingGatewayErrors?: unknown[]; + releaseEarlyGatewayErrorGuard?: () => void; }) { const gateway = params.client.getPlugin("gateway"); if (gateway) { @@ -74,11 +76,48 @@ export async function runDiscordGatewayLifecycle(params: { gatewayEmitter?.on("debug", onGatewayDebug); let sawDisallowedIntents = false; + const logGatewayError = (err: unknown) => { + if (params.isDisallowedIntentsError(err)) { + sawDisallowedIntents = true; + params.runtime.error?.( + danger( + "discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.", + ), + ); + return; + } + params.runtime.error?.(danger(`discord gateway error: ${String(err)}`)); + }; + const shouldStopOnGatewayError = (err: unknown) => { + const message = String(err); + return ( + message.includes("Max reconnect attempts") || + message.includes("Fatal Gateway error") || + params.isDisallowedIntentsError(err) + ); + }; try { if (params.execApprovalsHandler) { await params.execApprovalsHandler.start(); } + // Drain gateway errors emitted before lifecycle listeners were attached. + const pendingGatewayErrors = params.pendingGatewayErrors ?? []; + if (pendingGatewayErrors.length > 0) { + const queuedErrors = [...pendingGatewayErrors]; + pendingGatewayErrors.length = 0; + for (const err of queuedErrors) { + logGatewayError(err); + if (!shouldStopOnGatewayError(err)) { + continue; + } + if (params.isDisallowedIntentsError(err)) { + return; + } + throw err; + } + } + await waitForDiscordGatewayStop({ gateway: gateway ? { @@ -87,32 +126,15 @@ export async function runDiscordGatewayLifecycle(params: { } : undefined, abortSignal: params.abortSignal, - onGatewayError: (err) => { - if (params.isDisallowedIntentsError(err)) { - sawDisallowedIntents = true; - params.runtime.error?.( - danger( - "discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.", - ), - ); - return; - } - params.runtime.error?.(danger(`discord gateway error: ${String(err)}`)); - }, - shouldStopOnError: (err) => { - const message = String(err); - return ( - message.includes("Max reconnect attempts") || - message.includes("Fatal Gateway error") || - params.isDisallowedIntentsError(err) - ); - }, + onGatewayError: logGatewayError, + shouldStopOnError: shouldStopOnGatewayError, }); } catch (err) { if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) { throw err; } } finally { + params.releaseEarlyGatewayErrorGuard?.(); unregisterGateway(params.accountId); stopGatewayLogging(); if (helloTimeoutId) { diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 14b137fd1bd..db998ac6720 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -1,8 +1,11 @@ +import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; const { + clientFetchUserMock, + clientGetPluginMock, createDiscordNativeCommandMock, createNoopThreadBindingManagerMock, createThreadBindingManagerMock, @@ -17,6 +20,8 @@ const { } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; return { + clientFetchUserMock: vi.fn(async () => ({ id: "bot-1" })), + clientGetPluginMock: vi.fn(() => undefined), createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })), createNoopThreadBindingManagerMock: vi.fn(() => { const manager = { stop: vi.fn() }; @@ -65,11 +70,11 @@ vi.mock("@buape/carbon", () => { async handleDeployRequest() { return undefined; } - async fetchUser(_target: string) { - return { id: "bot-1" }; + async fetchUser(target: string) { + return await clientFetchUserMock(target); } - getPlugin(_name: string) { - return undefined; + getPlugin(name: string) { + return clientGetPluginMock(name); } } return { Client, ReadyListener }; @@ -242,6 +247,8 @@ describe("monitorDiscordProvider", () => { }) as OpenClawConfig; beforeEach(() => { + clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); + clientGetPluginMock.mockClear().mockReturnValue(undefined); createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" }); createNoopThreadBindingManagerMock.mockClear(); createThreadBindingManagerMock.mockClear(); @@ -290,4 +297,28 @@ describe("monitorDiscordProvider", () => { expect(createdBindingManagers).toHaveLength(1); expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1); }); + + it("captures gateway errors emitted before lifecycle wait starts", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const emitter = new EventEmitter(); + clientGetPluginMock.mockImplementation((name: string) => + name === "gateway" ? { emitter, disconnect: vi.fn() } : undefined, + ); + clientFetchUserMock.mockImplementationOnce(async () => { + emitter.emit("error", new Error("Fatal Gateway error: 4014")); + return { id: "bot-1" }; + }); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime: baseRuntime(), + }); + + expect(monitorLifecycleMock).toHaveBeenCalledTimes(1); + const lifecycleArgs = monitorLifecycleMock.mock.calls[0]?.[0] as { + pendingGatewayErrors?: unknown[]; + }; + expect(lifecycleArgs.pendingGatewayErrors).toHaveLength(1); + expect(String(lifecycleArgs.pendingGatewayErrors?.[0])).toContain("4014"); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 629f8a3e7aa..2239503a5db 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -34,6 +34,7 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; import { resolveDiscordAccount } from "../accounts.js"; +import { getDiscordGatewayEmitter } from "../monitor.gateway.js"; import { fetchDiscordApplicationId } from "../probe.js"; import { normalizeDiscordToken } from "../token.js"; import { createDiscordVoiceCommand } from "../voice/command.js"; @@ -229,6 +230,33 @@ function isDiscordDisallowedIntentsError(err: unknown): boolean { return message.includes(String(DISCORD_DISALLOWED_INTENTS_CODE)); } +type EarlyGatewayErrorGuard = { + pendingErrors: unknown[]; + release: () => void; +}; + +function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard { + const pendingErrors: unknown[] = []; + const gateway = client.getPlugin("gateway"); + const emitter = getDiscordGatewayEmitter(gateway); + if (!emitter) { + return { + pendingErrors, + release: () => {}, + }; + } + const onGatewayError = (err: unknown) => { + pendingErrors.push(err); + }; + emitter.on("error", onGatewayError); + return { + pendingErrors, + release: () => { + emitter.removeListener("error", onGatewayError); + }, + }; +} + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ @@ -365,6 +393,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }) : createNoopThreadBindingManager(account.accountId); let lifecycleStarted = false; + let releaseEarlyGatewayErrorGuard = () => {}; try { const commands: BaseCommand[] = commandSpecs.map((spec) => createDiscordNativeCommand({ @@ -496,6 +525,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, clientPlugins, ); + const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client); + releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release; await deployDiscordCommands({ client, runtime, enabled: nativeEnabled }); @@ -612,8 +643,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { voiceManagerRef, execApprovalsHandler, threadBindings, + pendingGatewayErrors: earlyGatewayErrorGuard.pendingErrors, + releaseEarlyGatewayErrorGuard, }); } finally { + releaseEarlyGatewayErrorGuard(); if (!lifecycleStarted) { threadBindings.stop(); } From e35fe7888b98e31715b95c3b425510189618a07f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:22:44 +0100 Subject: [PATCH 82/95] refactor: centralize message-provider tool filtering --- ...e-aliases-schemas-without-dropping.test.ts | 13 -------- .../pi-tools.message-provider-policy.test.ts | 19 ++++++++++++ src/agents/pi-tools.ts | 31 ++++++++++++++++--- 3 files changed, 45 insertions(+), 18 deletions(-) create mode 100644 src/agents/pi-tools.message-provider-policy.test.ts diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index e074b6f9189..22d68f15ff8 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -319,19 +319,6 @@ describe("createOpenClawCodingTools", () => { expect(names.has("telegram")).toBe(false); expect(names.has("whatsapp")).toBe(false); }); - it.each(["voice", "VOICE", " Voice "])( - "does not expose tts tool for normalized voice message provider: %s", - (messageProvider) => { - const tools = createOpenClawCodingTools({ messageProvider }); - const names = new Set(tools.map((tool) => tool.name)); - expect(names.has("tts")).toBe(false); - }, - ); - it("keeps tts tool for non-voice providers", () => { - const tools = createOpenClawCodingTools({ messageProvider: "discord" }); - const names = new Set(tools.map((tool) => tool.name)); - expect(names.has("tts")).toBe(true); - }); it("filters session tools for sub-agent sessions by default", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", diff --git a/src/agents/pi-tools.message-provider-policy.test.ts b/src/agents/pi-tools.message-provider-policy.test.ts new file mode 100644 index 00000000000..0bcdd5144f0 --- /dev/null +++ b/src/agents/pi-tools.message-provider-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { createOpenClawCodingTools } from "./pi-tools.js"; + +describe("createOpenClawCodingTools message provider policy", () => { + it.each(["voice", "VOICE", " Voice "])( + "does not expose tts tool for normalized voice provider: %s", + (messageProvider) => { + const tools = createOpenClawCodingTools({ messageProvider }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(false); + }, + ); + + it("keeps tts tool for non-voice providers", () => { + const tools = createOpenClawCodingTools({ messageProvider: "discord" }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(true); + }); +}); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f4252f562bb..15be5766c89 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -67,6 +67,31 @@ function isOpenAIProvider(provider?: string) { return normalized === "openai" || normalized === "openai-codex"; } +const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> = { + voice: ["tts"], +}; + +function normalizeMessageProvider(messageProvider?: string): string | undefined { + const normalized = messageProvider?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function applyMessageProviderToolPolicy( + tools: AnyAgentTool[], + messageProvider?: string, +): AnyAgentTool[] { + const normalizedProvider = normalizeMessageProvider(messageProvider); + if (!normalizedProvider) { + return tools; + } + const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider]; + if (!deniedTools || deniedTools.length === 0) { + return tools; + } + const deniedSet = new Set(deniedTools); + return tools.filter((tool) => !deniedSet.has(tool.name)); +} + function isApplyPatchAllowedForModel(params: { modelProvider?: string; modelId?: string; @@ -217,8 +242,6 @@ export function createOpenClawCodingTools(options?: { /** Whether the sender is an owner (required for owner-only tools). */ senderIsOwner?: boolean; }): AnyAgentTool[] { - const rawMessageProvider = options?.messageProvider?.trim().toLowerCase(); - const isVoiceMessageProvider = rawMessageProvider === "voice"; const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const { @@ -482,9 +505,7 @@ export function createOpenClawCodingTools(options?: { senderIsOwner: options?.senderIsOwner, }), ]; - const toolsForMessageProvider = isVoiceMessageProvider - ? tools.filter((tool) => tool.name !== "tts") - : tools; + const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider); // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true; const toolsByAuthorization = applyOwnerOnlyToolPolicy(toolsForMessageProvider, senderIsOwner); From 02c731826a036b7be49fe516436173554a1488f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:35:49 +0100 Subject: [PATCH 83/95] test(discord): fix monitor test typings --- src/discord/monitor/provider.lifecycle.test.ts | 11 ++++++++--- src/discord/monitor/provider.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/discord/monitor/provider.lifecycle.test.ts b/src/discord/monitor/provider.lifecycle.test.ts index e503d88ccde..f29bd8e8cc1 100644 --- a/src/discord/monitor/provider.lifecycle.test.ts +++ b/src/discord/monitor/provider.lifecycle.test.ts @@ -55,8 +55,15 @@ describe("runDiscordGatewayLifecycle", () => { const start = vi.fn(params?.start ?? (async () => undefined)); const stop = vi.fn(params?.stop ?? (async () => undefined)); const threadStop = vi.fn(); + const runtimeLog = vi.fn(); const runtimeError = vi.fn(); + const runtimeExit = vi.fn(); const releaseEarlyGatewayErrorGuard = vi.fn(); + const runtime: RuntimeEnv = { + log: runtimeLog, + error: runtimeError, + exit: runtimeExit, + }; return { start, stop, @@ -66,9 +73,7 @@ describe("runDiscordGatewayLifecycle", () => { lifecycleParams: { accountId: params?.accountId ?? "default", client: { getPlugin: vi.fn(() => undefined) } as unknown as Client, - runtime: { - error: runtimeError, - } as RuntimeEnv, + runtime, isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false), voiceManager: null, voiceManagerRef: { current: null }, diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index db998ac6720..75552749fda 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -20,8 +20,8 @@ const { } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; return { - clientFetchUserMock: vi.fn(async () => ({ id: "bot-1" })), - clientGetPluginMock: vi.fn(() => undefined), + clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), + clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined), createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })), createNoopThreadBindingManagerMock: vi.fn(() => { const manager = { stop: vi.fn() }; From e915b4c64a774365fafbb8558b94373c8c0cad2d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:36:00 +0100 Subject: [PATCH 84/95] refactor: unify monitor abort lifecycle handling --- changelog/fragments/README.md | 13 +++ extensions/line/src/channel.startup.test.ts | 46 ---------- extensions/line/src/channel.ts | 12 --- scripts/pr | 80 +++++++++++++++- .../monitor/gateway-error-guard.test.ts | 33 +++++++ src/discord/monitor/gateway-error-guard.ts | 36 ++++++++ src/discord/monitor/provider.ts | 29 +----- src/infra/abort-signal.test.ts | 29 ++++++ src/infra/abort-signal.ts | 12 +++ src/line/monitor.lifecycle.test.ts | 92 +++++++++++++++++++ src/line/monitor.ts | 13 ++- src/telegram/monitor.test.ts | 15 ++- src/telegram/monitor.ts | 12 +-- 13 files changed, 319 insertions(+), 103 deletions(-) create mode 100644 changelog/fragments/README.md create mode 100644 src/discord/monitor/gateway-error-guard.test.ts create mode 100644 src/discord/monitor/gateway-error-guard.ts create mode 100644 src/infra/abort-signal.test.ts create mode 100644 src/infra/abort-signal.ts create mode 100644 src/line/monitor.lifecycle.test.ts diff --git a/changelog/fragments/README.md b/changelog/fragments/README.md new file mode 100644 index 00000000000..93bb5b65d70 --- /dev/null +++ b/changelog/fragments/README.md @@ -0,0 +1,13 @@ +# Changelog Fragments + +Use this directory when a PR should not edit `CHANGELOG.md` directly. + +- One fragment file per PR. +- File name recommendation: `pr-.md`. +- Include at least one line with both `#` and `thanks @`. + +Example: + +```md +- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice) +``` diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index 11ba80bda12..812636113cb 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -129,50 +129,4 @@ describe("linePlugin gateway.startAccount", () => { abort.abort(); await task; }); - - it("stays pending until abort signal fires (no premature exit)", async () => { - const { runtime, monitorLineProvider } = createRuntime(); - setLineRuntime(runtime); - - const abort = new AbortController(); - let resolved = false; - - const task = linePlugin.gateway!.startAccount!( - createStartAccountCtx({ - token: "token", - secret: "secret", - runtime: createRuntimeEnv(), - abortSignal: abort.signal, - }), - ).then(() => { - resolved = true; - }); - - // Allow async internals to flush - await new Promise((r) => setTimeout(r, 50)); - - expect(monitorLineProvider).toHaveBeenCalled(); - expect(resolved).toBe(false); - - abort.abort(); - await task; - expect(resolved).toBe(true); - }); - - it("resolves immediately when abortSignal is already aborted", async () => { - const { runtime } = createRuntime(); - setLineRuntime(runtime); - - const abort = new AbortController(); - abort.abort(); - - await linePlugin.gateway!.startAccount!( - createStartAccountCtx({ - token: "token", - secret: "secret", - runtime: createRuntimeEnv(), - abortSignal: abort.signal, - }), - ); - }); }); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index f37a86aa0c4..1c87ad8e2f3 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -661,18 +661,6 @@ export const linePlugin: ChannelPlugin = { webhookPath: account.config.webhookPath, }); - // Keep the provider alive until the abort signal fires. Without this, - // the startAccount promise resolves immediately after webhook registration - // and the channel supervisor treats the provider as "exited", triggering an - // auto-restart loop (up to 10 attempts). - await new Promise((resolve) => { - if (ctx.abortSignal.aborted) { - resolve(); - return; - } - ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); - }); - return monitor; }, logoutAccount: async ({ accountId, cfg }) => { diff --git a/scripts/pr b/scripts/pr index 90cfe029db0..36ab74972c4 100755 --- a/scripts/pr +++ b/scripts/pr @@ -664,6 +664,61 @@ validate_changelog_entry_for_pr() { echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" } +changed_changelog_fragment_files() { + git diff --name-only origin/main...HEAD -- changelog/fragments | rg '^changelog/fragments/.*\.md$' || true +} + +validate_changelog_fragments_for_pr() { + local pr="$1" + local contrib="$2" + shift 2 + + if [ "$#" -lt 1 ]; then + echo "No changelog fragments provided for validation." + exit 1 + fi + + local pr_pattern + pr_pattern="(#$pr|openclaw#$pr)" + + local added_lines + local file + local all_added_lines="" + for file in "$@"; do + added_lines=$(git diff --unified=0 origin/main...HEAD -- "$file" | awk ' + /^\+\+\+/ { next } + /^\+/ { print substr($0, 2) } + ') + + if [ -z "$added_lines" ]; then + echo "$file is in diff but no added lines were detected." + exit 1 + fi + + all_added_lines=$(printf '%s\n%s\n' "$all_added_lines" "$added_lines") + done + + local with_pr + with_pr=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" || true) + if [ -z "$with_pr" ]; then + echo "Changelog fragment update must reference PR #$pr (for example, (#$pr))." + exit 1 + fi + + if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then + local with_pr_and_thanks + with_pr_and_thanks=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true) + if [ -z "$with_pr_and_thanks" ]; then + echo "Changelog fragment update must include both PR #$pr and thanks @$contrib on the entry line." + exit 1 + fi + echo "changelog fragments validated: found PR #$pr + thanks @$contrib" + return 0 + fi + + echo "changelog fragments validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" +} + prepare_gates() { local pr="$1" enter_worktree "$pr" false @@ -684,13 +739,30 @@ prepare_gates() { docs_only=true fi - # Enforce workflow policy: every prepared PR must include a changelog update. - if ! printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then - echo "Missing CHANGELOG.md update in PR diff. This workflow requires a changelog entry." + local has_changelog_update=false + if printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then + has_changelog_update=true + fi + local fragment_files + fragment_files=$(changed_changelog_fragment_files) + local has_fragment_update=false + if [ -n "$fragment_files" ]; then + has_fragment_update=true + fi + # Enforce workflow policy: every prepared PR must include either CHANGELOG.md + # or one or more changelog fragments. + if [ "$has_changelog_update" = "false" ] && [ "$has_fragment_update" = "false" ]; then + echo "Missing changelog update. Add CHANGELOG.md changes or changelog/fragments/*.md entry." exit 1 fi local contrib="${PR_AUTHOR:-}" - validate_changelog_entry_for_pr "$pr" "$contrib" + if [ "$has_changelog_update" = "true" ]; then + validate_changelog_entry_for_pr "$pr" "$contrib" + fi + if [ "$has_fragment_update" = "true" ]; then + mapfile -t fragment_file_list <<<"$fragment_files" + validate_changelog_fragments_for_pr "$pr" "$contrib" "${fragment_file_list[@]}" + fi run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check diff --git a/src/discord/monitor/gateway-error-guard.test.ts b/src/discord/monitor/gateway-error-guard.test.ts new file mode 100644 index 00000000000..783fcc6a712 --- /dev/null +++ b/src/discord/monitor/gateway-error-guard.test.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js"; + +describe("attachEarlyGatewayErrorGuard", () => { + it("captures gateway errors until released", () => { + const emitter = new EventEmitter(); + const fallbackErrorListener = vi.fn(); + emitter.on("error", fallbackErrorListener); + const client = { + getPlugin: vi.fn(() => ({ emitter })), + }; + + const guard = attachEarlyGatewayErrorGuard(client as never); + emitter.emit("error", new Error("Fatal Gateway error: 4014")); + expect(guard.pendingErrors).toHaveLength(1); + + guard.release(); + emitter.emit("error", new Error("Fatal Gateway error: 4000")); + expect(guard.pendingErrors).toHaveLength(1); + expect(fallbackErrorListener).toHaveBeenCalledTimes(2); + }); + + it("returns noop guard when gateway emitter is unavailable", () => { + const client = { + getPlugin: vi.fn(() => undefined), + }; + + const guard = attachEarlyGatewayErrorGuard(client as never); + expect(guard.pendingErrors).toEqual([]); + expect(() => guard.release()).not.toThrow(); + }); +}); diff --git a/src/discord/monitor/gateway-error-guard.ts b/src/discord/monitor/gateway-error-guard.ts new file mode 100644 index 00000000000..5cb79753325 --- /dev/null +++ b/src/discord/monitor/gateway-error-guard.ts @@ -0,0 +1,36 @@ +import type { Client } from "@buape/carbon"; +import { getDiscordGatewayEmitter } from "../monitor.gateway.js"; + +export type EarlyGatewayErrorGuard = { + pendingErrors: unknown[]; + release: () => void; +}; + +export function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard { + const pendingErrors: unknown[] = []; + const gateway = client.getPlugin("gateway"); + const emitter = getDiscordGatewayEmitter(gateway); + if (!emitter) { + return { + pendingErrors, + release: () => {}, + }; + } + + let released = false; + const onGatewayError = (err: unknown) => { + pendingErrors.push(err); + }; + emitter.on("error", onGatewayError); + + return { + pendingErrors, + release: () => { + if (released) { + return; + } + released = true; + emitter.removeListener("error", onGatewayError); + }, + }; +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 2239503a5db..8243da5a246 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -34,7 +34,6 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; import { resolveDiscordAccount } from "../accounts.js"; -import { getDiscordGatewayEmitter } from "../monitor.gateway.js"; import { fetchDiscordApplicationId } from "../probe.js"; import { normalizeDiscordToken } from "../token.js"; import { createDiscordVoiceCommand } from "../voice/command.js"; @@ -52,6 +51,7 @@ import { } from "./agent-components.js"; import { resolveDiscordSlashCommandConfig } from "./commands.js"; import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js"; +import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js"; import { createDiscordGatewayPlugin } from "./gateway-plugin.js"; import { DiscordMessageListener, @@ -230,33 +230,6 @@ function isDiscordDisallowedIntentsError(err: unknown): boolean { return message.includes(String(DISCORD_DISALLOWED_INTENTS_CODE)); } -type EarlyGatewayErrorGuard = { - pendingErrors: unknown[]; - release: () => void; -}; - -function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard { - const pendingErrors: unknown[] = []; - const gateway = client.getPlugin("gateway"); - const emitter = getDiscordGatewayEmitter(gateway); - if (!emitter) { - return { - pendingErrors, - release: () => {}, - }; - } - const onGatewayError = (err: unknown) => { - pendingErrors.push(err); - }; - emitter.on("error", onGatewayError); - return { - pendingErrors, - release: () => { - emitter.removeListener("error", onGatewayError); - }, - }; -} - export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ diff --git a/src/infra/abort-signal.test.ts b/src/infra/abort-signal.test.ts new file mode 100644 index 00000000000..be32e0d881a --- /dev/null +++ b/src/infra/abort-signal.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { waitForAbortSignal } from "./abort-signal.js"; + +describe("waitForAbortSignal", () => { + it("resolves immediately when signal is missing", async () => { + await expect(waitForAbortSignal(undefined)).resolves.toBeUndefined(); + }); + + it("resolves immediately when signal is already aborted", async () => { + const abort = new AbortController(); + abort.abort(); + await expect(waitForAbortSignal(abort.signal)).resolves.toBeUndefined(); + }); + + it("waits until abort fires", async () => { + const abort = new AbortController(); + let resolved = false; + + const task = waitForAbortSignal(abort.signal).then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + + abort.abort(); + await task; + expect(resolved).toBe(true); + }); +}); diff --git a/src/infra/abort-signal.ts b/src/infra/abort-signal.ts new file mode 100644 index 00000000000..77922784eda --- /dev/null +++ b/src/infra/abort-signal.ts @@ -0,0 +1,12 @@ +export async function waitForAbortSignal(signal?: AbortSignal): Promise { + if (!signal || signal.aborted) { + return; + } + await new Promise((resolve) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/line/monitor.lifecycle.test.ts b/src/line/monitor.lifecycle.test.ts new file mode 100644 index 00000000000..635d921e7ad --- /dev/null +++ b/src/line/monitor.lifecycle.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; + +const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = vi.hoisted(() => ({ + createLineBotMock: vi.fn(() => ({ + account: { accountId: "default" }, + handleWebhook: vi.fn(), + })), + registerPluginHttpRouteMock: vi.fn(), + unregisterHttpMock: vi.fn(), +})); + +vi.mock("./bot.js", () => ({ + createLineBot: createLineBotMock, +})); + +vi.mock("../plugins/http-path.js", () => ({ + normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback, +})); + +vi.mock("../plugins/http-registry.js", () => ({ + registerPluginHttpRoute: registerPluginHttpRouteMock, +})); + +vi.mock("./webhook-node.js", () => ({ + createLineNodeWebhookHandler: vi.fn(() => vi.fn()), +})); + +describe("monitorLineProvider lifecycle", () => { + beforeEach(() => { + createLineBotMock.mockClear(); + unregisterHttpMock.mockClear(); + registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock); + }); + + it("waits for abort before resolving", async () => { + const { monitorLineProvider } = await import("./monitor.js"); + const abort = new AbortController(); + let resolved = false; + + const task = monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", + config: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + abortSignal: abort.signal, + }).then((monitor) => { + resolved = true; + return monitor; + }); + + await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1)); + expect(resolved).toBe(false); + + abort.abort(); + await task; + expect(unregisterHttpMock).toHaveBeenCalledTimes(1); + }); + + it("stops immediately when signal is already aborted", async () => { + const { monitorLineProvider } = await import("./monitor.js"); + const abort = new AbortController(); + abort.abort(); + + await monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", + config: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + abortSignal: abort.signal, + }); + + expect(unregisterHttpMock).toHaveBeenCalledTimes(1); + }); + + it("returns immediately without abort signal and stop is idempotent", async () => { + const { monitorLineProvider } = await import("./monitor.js"); + + const monitor = await monitorLineProvider({ + channelAccessToken: "token", + channelSecret: "secret", + config: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + }); + + expect(unregisterHttpMock).not.toHaveBeenCalled(); + monitor.stop(); + monitor.stop(); + expect(unregisterHttpMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/line/monitor.ts b/src/line/monitor.ts index 07a995c4eed..49fcc518a3f 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -4,6 +4,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import type { OpenClawConfig } from "../config/config.js"; import { danger, logVerbose } from "../globals.js"; +import { waitForAbortSignal } from "../infra/abort-signal.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -296,7 +297,12 @@ export async function monitorLineProvider( logVerbose(`line: registered webhook handler at ${normalizedPath}`); // Handle abort signal + let stopped = false; const stopHandler = () => { + if (stopped) { + return; + } + stopped = true; logVerbose(`line: stopping provider for account ${resolvedAccountId}`); unregisterHttp(); recordChannelRuntimeState({ @@ -309,7 +315,12 @@ export async function monitorLineProvider( }); }; - abortSignal?.addEventListener("abort", stopHandler); + if (abortSignal?.aborted) { + stopHandler(); + } else if (abortSignal) { + abortSignal.addEventListener("abort", stopHandler, { once: true }); + await waitForAbortSignal(abortSignal); + } return { account: bot.account, diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 4e59f6c0c6a..5c0df3de6ef 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { monitorTelegramProvider } from "./monitor.js"; type MockCtx = { @@ -160,19 +160,30 @@ vi.mock("../auto-reply/reply.js", () => ({ })); describe("monitorTelegramProvider (grammY)", () => { + let consoleErrorSpy: { mockRestore: () => void } | undefined; + beforeEach(() => { loadConfig.mockReturnValue({ agents: { defaults: { maxConcurrent: 2 } }, channels: { telegram: {} }, }); initSpy.mockClear(); - runSpy.mockClear(); + runSpy.mockReset().mockImplementation(() => + makeRunnerStub({ + task: () => Promise.reject(new Error("runSpy called without explicit test stub")), + }), + ); computeBackoff.mockClear(); sleepWithAbort.mockClear(); startTelegramWebhookSpy.mockClear(); registerUnhandledRejectionHandlerMock.mockClear(); resetUnhandledRejection(); createTelegramBotErrors.length = 0; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); }); it("processes a DM and sends reply", async () => { diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 579db8ad3a1..06410b74ed1 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -2,6 +2,7 @@ import { type RunOptions, run } from "@grammyjs/runner"; import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { waitForAbortSignal } from "../infra/abort-signal.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; @@ -172,16 +173,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { abortSignal: opts.abortSignal, publicUrl: opts.webhookUrl, }); - const abortSignal = opts.abortSignal; - if (abortSignal && !abortSignal.aborted) { - await new Promise((resolve) => { - const onAbort = () => { - abortSignal.removeEventListener("abort", onAbort); - resolve(); - }; - abortSignal.addEventListener("abort", onAbort, { once: true }); - }); - } + await waitForAbortSignal(opts.abortSignal); return; } From fdea7415ccd4a6de5deeea70f7cc97721b469fdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:38:56 +0100 Subject: [PATCH 85/95] docs: reorder unreleased changelog by user impact --- CHANGELOG.md | 76 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4e3fb749b..4717658d4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ Docs: https://docs.openclaw.ai ### Changes -- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. -- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus. - Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus. - UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz. +- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. +- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. -- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. -- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. +- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. ### Breaking @@ -21,56 +21,56 @@ Docs: https://docs.openclaw.ai ### Fixes -- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. +- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) +- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. +- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. +- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. +- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018) - LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin. - Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr. -- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. -- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. +- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. +- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. +- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042) +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. +- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. +- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. +- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. +- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. +- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025) +- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) +- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. +- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. +- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. - Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed. -- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. +- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. +- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. +- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. +- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. +- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. - Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. -- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting. -- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. -- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. -- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018) -- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. -- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. -- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. -- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. - Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. +- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. - Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. - Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. -- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. -- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling. -- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. -- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) -- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel. -- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. -- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. -- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007) -- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. -- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. -- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. -- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. -- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. -- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW. -- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042) -- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728. -- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025) +- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting. - Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. -- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. ## 2026.2.24 From 550000049298c1f00c41cbdbc8c6de491aecb235 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 04:43:27 +0100 Subject: [PATCH 86/95] chore(protocol): regenerate Swift gateway models --- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ .../OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 95565a68c4f..60b44d4545c 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2809,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String + public let commandargv: [String]? public let cwd: AnyCodable? public let nodeid: AnyCodable? public let host: AnyCodable? @@ -2823,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public init( id: String?, command: String, + commandargv: [String]?, cwd: AnyCodable?, nodeid: AnyCodable?, host: AnyCodable?, @@ -2836,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { { self.id = id self.command = command + self.commandargv = commandargv self.cwd = cwd self.nodeid = nodeid self.host = host @@ -2851,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case id case command + case commandargv = "commandArgv" case cwd case nodeid = "nodeId" case host diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 95565a68c4f..60b44d4545c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2809,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String + public let commandargv: [String]? public let cwd: AnyCodable? public let nodeid: AnyCodable? public let host: AnyCodable? @@ -2823,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public init( id: String?, command: String, + commandargv: [String]?, cwd: AnyCodable?, nodeid: AnyCodable?, host: AnyCodable?, @@ -2836,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { { self.id = id self.command = command + self.commandargv = commandargv self.cwd = cwd self.nodeid = nodeid self.host = host @@ -2851,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case id case command + case commandargv = "commandArgv" case cwd case nodeid = "nodeId" case host From c7352f6b3f0ddd7b9c56cbeb2ef5355dc9b5ff39 Mon Sep 17 00:00:00 2001 From: bmendonca3 <208517100+bmendonca3@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:07:20 -0700 Subject: [PATCH 87/95] security(telegram): fail closed group allowlist against DM pairing store --- src/telegram/bot-message-context.ts | 14 +++++++----- src/telegram/bot.create-telegram-bot.test.ts | 24 ++++++++++++++++++++ src/telegram/bot/helpers.ts | 14 ++++-------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 3ea805c944d..c3a2cfcdcb1 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -36,7 +36,12 @@ import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; +import { + firstDefined, + isSenderAllowed, + normalizeAllowFrom, + normalizeAllowFromWithStore, +} from "./bot-access.js"; import { buildGroupLabel, buildSenderLabel, @@ -189,11 +194,8 @@ export const buildTelegramMessageContext = async ({ const mentionRegexes = buildMentionRegexes(cfg, route.agentId); const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy }); const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowOverride ?? groupAllowFrom, - storeAllowFrom, - dmPolicy, - }); + // Group sender checks are explicit and must not inherit DM pairing-store entries. + const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom); const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 942a1c6c2b3..4be6b0dcbf3 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -1416,6 +1416,30 @@ describe("createTelegramBot", () => { expect(replySpy.mock.calls.length, testCase.name).toBe(0); } }); + it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => { + resetHarnessSpies(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: ["222222222"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]); + + await dispatchMessage({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 493ad010082..0e41a7d0b28 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -3,11 +3,7 @@ import { formatLocationText, type NormalizedLocation } from "../../channels/loca import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js"; import type { TelegramGroupConfig, TelegramTopicConfig } from "../../config/types.js"; import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; -import { - firstDefined, - normalizeAllowFromWithStore, - type NormalizedAllowFrom, -} from "../bot-access.js"; +import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; import type { TelegramStreamMode } from "./types.js"; const TELEGRAM_GENERAL_TOPIC_ID = 1; @@ -51,11 +47,9 @@ export async function resolveTelegramGroupAllowFromContext(params: { resolvedThreadId, ); const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowOverride ?? params.groupAllowFrom, - storeAllowFrom, - dmPolicy: params.dmPolicy, - }); + // Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only). + // DM pairing store entries are not a group authorization source. + const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? params.groupAllowFrom); const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; return { resolvedThreadId, From 470c606dac1c9a2fd7333e1a5800c25680012af5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 09:02:46 +0530 Subject: [PATCH 88/95] refactor(telegram): remove dmPolicy from group allow context helper --- src/telegram/bot-handlers.ts | 1 - src/telegram/bot-native-commands.ts | 1 - src/telegram/bot/helpers.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index a3b4d46a677..ad28c32883d 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -554,7 +554,6 @@ export const registerTelegramHandlers = ({ (await resolveTelegramGroupAllowFromContext({ chatId: params.chatId, accountId, - dmPolicy, isForum: params.isForum, messageThreadId: params.messageThreadId, groupAllowFrom, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 88316cbeb82..f963aa269cc 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -170,7 +170,6 @@ async function resolveTelegramCommandAuth(params: { const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId, accountId, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", isForum, messageThreadId, groupAllowFrom, diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 0e41a7d0b28..ebfe36fbac0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -16,7 +16,6 @@ export type TelegramThreadSpec = { export async function resolveTelegramGroupAllowFromContext(params: { chatId: string | number; accountId?: string; - dmPolicy?: string; isForum?: boolean; messageThreadId?: number | null; groupAllowFrom?: Array; From 3b0298562b7105c276380f26d966a10216c3f4de Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 09:21:31 +0530 Subject: [PATCH 89/95] fix: document telegram group allowlist hardening (#25988) (thanks @bmendonca3) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717658d4ed..b112aacf83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. +- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3. - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. From bf70614943fe932b49e6071790ef7d464711db60 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 09:27:41 +0530 Subject: [PATCH 90/95] fix(ci): publish latest tag for stable docker release --- .github/workflows/docker-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index fc0d97d4091..6ad41bf4b77 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -172,6 +172,9 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") + if [[ "$version" != *-* ]]; then + tags+=("${IMAGE}:latest") + fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No manifest tags resolved for ref ${GITHUB_REF}" From 41314c691dcc905d39d1bf8ce215ad3c63180471 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 09:30:10 +0530 Subject: [PATCH 91/95] fix(ci): gate docker latest tag to stable release format --- .github/workflows/docker-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 6ad41bf4b77..2eb415027af 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -172,7 +172,7 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") - if [[ "$version" != *-* ]]; then + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then tags+=("${IMAGE}:latest") fi fi From 7493f11b406588cbf0a861154efc123d278d30ee Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 09:31:17 +0530 Subject: [PATCH 92/95] fix(ci): allow legacy patch tags to publish docker latest --- .github/workflows/docker-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 2eb415027af..eff0993b466 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -172,7 +172,7 @@ jobs: if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then tags+=("${IMAGE}:latest") fi fi From 04870a552882c3946aeb3dc790aea55aa9290f7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 05:12:44 +0100 Subject: [PATCH 93/95] test(session): make fork parent path assertion cross-platform --- src/auto-reply/reply/session.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index cdd8b5310c0..12433057b14 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -326,7 +326,12 @@ describe("initSessionState thread forking", () => { expect(result.sessionEntry.forkedFromParent).toBe(true); expect(result.sessionEntry.sessionFile).toBeTruthy(); const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8"); - expect(forkedContent).toContain(parentSessionFile); + const [sessionHeaderLine] = forkedContent.split("\n"); + const sessionHeader = JSON.parse(sessionHeaderLine ?? "{}") as { parentSession?: string }; + expect(sessionHeader.parentSession).toBeTruthy(); + const resolvedParentSession = await fs.realpath(parentSessionFile); + const resolvedForkParentSession = await fs.realpath(sessionHeader.parentSession ?? ""); + expect(resolvedForkParentSession).toBe(resolvedParentSession); }); it("records topic-specific session files when MessageThreadId is present", async () => { From 4b5d4a4c660d05e4bd73f0e11123e68fd9664432 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 05:15:21 +0100 Subject: [PATCH 94/95] docs: finalize 2026.2.25 release notes and appcast --- CHANGELOG.md | 2 +- appcast.xml | 152 ++++++++++++++++++++++----------------------------- 2 files changed, 66 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b112aacf83e..cfb74303243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Docs: https://docs.openclaw.ai -## 2026.2.25 (Unreleased) +## 2026.2.25 ### Changes diff --git a/appcast.xml b/appcast.xml index 902d60972fd..f5eb1699934 100644 --- a/appcast.xml +++ b/appcast.xml @@ -209,106 +209,84 @@ - 2026.2.24 - Wed, 25 Feb 2026 02:59:30 +0000 + 2026.2.25 + Thu, 26 Feb 2026 05:14:17 +0100 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 14728 - 2026.2.24 + 14883 + 2026.2.25 15.0 - OpenClaw 2026.2.24 + OpenClaw 2026.2.25

Changes

    -
  • Auto-reply/Abort shortcuts: expand standalone stop phrases (stop openclaw, stop action, stop run, stop agent, please stop, and related variants), accept trailing punctuation (for example STOP OPENCLAW!!!), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact do not do that as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.
  • -
  • Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.
  • -
  • Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.
  • -
  • Security/Audit: add security.trust_model.multi_user_heuristic to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (sandbox.mode="all", workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
  • -
  • Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping @buape/carbon pinned.
  • +
  • Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
  • +
  • Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
  • +
  • UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
  • +
  • Heartbeat/Config: replace heartbeat DM toggle with agents.defaults.heartbeat.directPolicy (allow | block; also supported per-agent via agents.list[].heartbeat.directPolicy) for clearer delivery semantics.
  • +
  • Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
  • +
  • Branding/Docs + Apple surfaces: replace remaining bot.molt launchd label, bundle-id, logging subsystem, and command examples with ai.openclaw across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
  • +
  • Agents/Config: remind agents to call config.schema before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
  • +
  • Dependencies: update workspace dependency pins and lockfile (Bedrock SDK 3.998.0, @mariozechner/pi-* 0.55.1, TypeScript native preview 7.0.0-dev.20260225.1) while keeping @buape/carbon pinned.

Breaking

    -
  • BREAKING: Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example user:, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
  • -
  • BREAKING: Security/Sandbox: block Docker network: "container:" namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true (break-glass). Thanks @tdjackey for reporting.
  • +
  • BREAKING: Heartbeat direct/DM delivery default is now allow again. To keep DM-blocked behavior from 2026.2.24, set agents.defaults.heartbeat.directPolicy: "block" (or per-agent override).

Fixes

    -
  • Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (channel/to/thread) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
  • -
  • Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.
  • -
  • Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)
  • -
  • Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from last to none (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
  • -
  • Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
  • -
  • Cron/Heartbeat delivery: stop inheriting cached session lastThreadId for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
  • -
  • Messaging tool dedupe: treat originating channel metadata as authoritative for same-target message.send suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so delivery-mirror transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
  • -
  • Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.
  • -
  • Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.
  • -
  • Gateway/Models: honor explicit agents.defaults.models allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in models.list, and allow sessions.patch//model selection for those refs without false model not allowed errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.
  • -
  • Control UI/Agents: inherit agents.defaults.model.fallbacks in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.
  • -
  • Automation/Subagent/Cron reliability: honor ANNOUNCE_SKIP in sessions_spawn completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include cron in the coding tool profile so /tools/invoke can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
  • -
  • Discord/Voice reliability: restore runtime DAVE dependency (@snazzah/davey), add configurable DAVE join options (channels.discord.voice.daveEncryption and channels.discord.voice.decryptionFailureTolerance), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)
  • -
  • Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all block payloads), fixing missing Discord replies in channels.discord.streaming=block mode. (#25839, #25836, #25792) Thanks @pewallin.
  • -
  • Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire messages.statusReactions.{emojis,timing} into Discord reaction lifecycle control, and compact model-picker custom_id keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
  • -
  • WhatsApp/Web reconnect: treat close status 440 as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
  • -
  • WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with Reasoning: before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
  • -
  • Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
  • -
  • Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
  • -
  • Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
  • -
  • Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram autoSelectFamily decisions so outbound fetch calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
  • -
  • Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
  • -
  • Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.
  • -
  • Slack/DM routing: treat D* channel IDs as direct messages even when Slack sends an incorrect channel_type, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
  • -
  • Zalo/Group policy: enforce sender authorization for group messages with groupPolicy + groupAllowFrom (fallback to allowFrom), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.
  • -
  • macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
  • -
  • macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.
  • -
  • macOS/Voice wake routing: default forwarded voice-wake transcripts to the webchat channel (instead of ambiguous last routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.
  • -
  • macOS/Gateway launch: prefer an available openclaw binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.
  • -
  • macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
  • -
  • macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.
  • -
  • Windows/Exec shell selection: prefer PowerShell 7 (pwsh) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing && command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.
  • -
  • Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 dev=0 stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false Local media path is not safe to read drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
  • -
  • iMessage/Reasoning safety: harden iMessage echo suppression with outbound messageId matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.
  • -
  • Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.
  • -
  • Providers/Google reasoning: sanitize invalid negative thinkingBudget payloads for Gemini 3.1 requests by dropping -1 budgets and mapping configured reasoning effort to thinkingLevel, preventing malformed reasoning payloads on google-generative-ai. (#25900)
  • -
  • Providers/SiliconFlow: normalize thinking="off" to thinking: null for Pro/* model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
  • -
  • Models/Bedrock auth: normalize additional Bedrock provider aliases (bedrock, aws-bedrock, aws_bedrock, amazon bedrock) to canonical amazon-bedrock, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
  • -
  • Models/Providers: preserve explicit user reasoning overrides when merging provider model config with built-in catalog metadata, so reasoning: false is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
  • -
  • Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false pairing required failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
  • -
  • CLI/Memory search: accept --query for openclaw memory search (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
  • -
  • CLI/Doctor: correct stale recovery hints to use valid commands (openclaw gateway status --deep and openclaw configure --section model). (#24485) Thanks @chilu18.
  • -
  • Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
  • -
  • Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid plugins.entries. writes when ids differ. (#25275) Thanks @zerone0x.
  • -
  • Config/Plugins: treat stale removed google-antigravity-auth plugin references as compatibility warnings (not hard validation errors) across plugins.entries, plugins.allow, plugins.deny, and plugins.slots.memory, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
  • -
  • Config/Meta: accept numeric meta.lastTouchedAt timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write Date.now() values. (#25491) Thanks @mcaxtr.
  • -
  • Usage accounting: parse Moonshot/Kimi cached_tokens fields (including prompt_tokens_details.cached_tokens) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
  • -
  • Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
  • -
  • Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit status/code/http 402 detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
  • -
  • Sessions/Tool-result guard: avoid generating synthetic toolResult entries for assistant turns that ended with stopReason: "aborted" or "error", preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
  • -
  • Auto-reply/Reset hooks: guarantee native /new and /reset flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
  • -
  • Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
  • -
  • Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.
  • -
  • Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not ; joins) to avoid POSIX sh do; syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
  • -
  • Sandbox/Config: preserve dangerouslyAllowReservedContainerTargets and dangerouslyAllowExternalBindSources during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.
  • -
  • Gateway/Security: enforce gateway auth for the exact /api/channels plugin root path (plus /api/channels/ descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
  • -
  • Exec approvals: treat bare allowlist * as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
  • -
  • iOS/Signing: improve scripts/ios-team-id.sh for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode xcodebuild output directories (apps/ios/build, apps/shared/OpenClawKit/build, Swabble/build). (#22773) Thanks @brianleach.
  • -
  • Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
  • -
  • Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (LD_*, DYLD_*, SSLKEYLOGFILE, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
  • -
  • Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width HOOK:...) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
  • -
  • Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.
  • -
  • Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host os.tmpdir() trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
  • -
  • Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
  • -
  • Security/Message actions: enforce local media root checks for sendAttachment and setGroupIcon when sandboxRoot is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.
  • -
  • Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.
  • -
  • Security/Workspace FS: normalize @-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.
  • -
  • Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so dmPolicy: "allowlist" with empty allowedUserIds rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.
  • -
  • Security/Native images: enforce tools.fs.workspaceOnly for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: bind system.run command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only rawCommand mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.
  • -
  • Security/Exec companion host: forward canonical system.run display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested /usr/bin/env chains cannot bypass shell-wrapper approval gating in allowlist + ask=on-miss mode. Thanks @tdjackey for reporting.
  • -
  • Security/Exec: limit default safe-bin trusted directories to immutable system paths (/bin, /usr/bin) and require explicit opt-in (tools.exec.safeBinTrustedDirs) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured safeBins resolve outside trusted dirs. Thanks @tdjackey for reporting.
  • -
  • Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
  • +
  • Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without message_id as delivery failures (instead of false-success "unknown" IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
  • +
  • Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
  • +
  • Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable session.parentForkMaxTokens (default 100000, 0 disables). (#26912) Thanks @markshields-tl.
  • +
  • Cron/Message multi-account routing: honor explicit delivery.accountId for isolated cron delivery resolution, and when message.send omits accountId, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
  • +
  • Gateway/Message media roots: thread agentId through gateway send RPC and prefer explicit agentId over session/default resolution so non-default agent workspace media sends no longer fail with LocalMediaAccessError; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
  • +
  • Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.
  • +
  • Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed delivered, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
  • +
  • LINE/Lifecycle: keep LINE startAccount pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
  • +
  • Discord/Gateway: capture and drain startup-time gateway error events before lifecycle listeners attach so early Fatal Gateway error: 4014 closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
  • +
  • Discord/Inbound text: preserve embed title + description fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
  • +
  • Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to file so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
  • +
  • Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
  • +
  • Telegram/Markdown spoilers: keep valid ||spoiler|| pairs while leaving unmatched trailing || delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
  • +
  • Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example c0abc12345) correctly match Slack runtime IDs (C0ABC12345) under groupPolicy: "allowlist", preventing silent channel-event drops. (#26878) Thanks @lbo728.
  • +
  • Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
  • +
  • Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
  • +
  • Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including NO_REPLY, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
  • +
  • Voice-call/TTS tools: hide the tts tool when the message provider is voice, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
  • +
  • Agents/Tools: normalize non-standard plugin tool results that omit content so embedded runs no longer crash with Cannot read properties of undefined (reading 'filter') after tool completion (including tesseramemo_query). (#27007)
  • +
  • Cron/Model overrides: when isolated payload.model is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
  • +
  • Agents/Model fallback: keep explicit text + image fallback chains reachable even when agents.defaults.models allowlists are present, prefer explicit run agentId over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify model_cooldown / cooling down errors as rate_limit so failover continues. (#11972, #24137, #17231)
  • +
  • Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of disabledReason only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for rate_limit. (#23816) thanks @ramezgaberiel.
  • +
  • Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
  • +
  • Models/Auth probes: map permanent auth failover reasons (auth_permanent, for example revoked keys) into probe auth status instead of unknown, so openclaw models status --probe reports actionable auth failures. (#25754) thanks @rrenamed.
  • +
  • Hooks/Inbound metadata: include guildId and channelName in message_received metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
  • +
  • Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get CommandAuthorized: true on modal/button events. (#26119) Thanks @bmendonca3.
  • +
  • Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
  • +
  • Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (2026.2.25). Thanks @luz-oasis for reporting.
  • +
  • Security/Gateway trusted proxy: require operator role for the Control UI trusted-proxy pairing bypass so unpaired node sessions can no longer connect via client.id=control-ui and invoke node event methods. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy oauth.json onboarding path that exposed the PKCE verifier via OAuth state; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (2026.2.25). Thanks @zdi-disclosures for reporting.
  • +
  • Security/Microsoft Teams file consent: bind fileConsent/invoke upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked uploadId values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Gateway: harden agents.files path handling to block out-of-workspace symlink targets for agents.files.get/agents.files.set, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
  • +
  • Security/Workspace FS: reject hardlinked workspace file aliases in tools.fs.workspaceOnly and tools.exec.applyPatch.workspaceOnly boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before setFiles, with regression coverage for strict missing-path handling.
  • +
  • Security/Exec approvals: bind system.run approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: harden approval-bound system.run execution on node hosts by rejecting symlink cwd paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under dmPolicy/groupPolicy; reaction notifications now require channel access checks first. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild groupPolicy channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Slack reactions + pins: gate reaction_* and pin_* system-event enqueue through shared sender authorization so DM dmPolicy/allowFrom and channel users allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Telegram reactions: enforce dmPolicy/allowFrom and group allowlist authorization on message_reaction events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Slack interactions: enforce channel/DM authorization and modal actor binding (private_metadata.userId) before enqueueing block_action/view_submission/view_closed system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
  • +
  • Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
  • +
  • Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
  • +
  • Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
  • +
  • Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
  • +
  • Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
  • +
  • Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
  • +
  • Security/SSRF guard: classify IPv6 multicast literals (ff00::/8) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (2026.2.25). Thanks @zpbrent for reporting.
  • +
  • Tests/Low-memory stability: disable Vitest vmForks by default on low-memory local hosts (<64 GiB), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with setSessionRuntimeModel usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.

View full changelog

]]>
- +
\ No newline at end of file From cb3e5c35b05e921a67978035958a4d8bfb4b8452 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 05:23:30 +0100 Subject: [PATCH 95/95] docs: fix onboarding markdown list spacing --- docs/start/onboarding.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 679ab059f45..dfa058af545 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -34,6 +34,7 @@ Security trust model: - By default, OpenClaw is a personal agent: one trusted operator boundary. - Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). +
@@ -50,9 +51,11 @@ Where does the **Gateway** run? **Gateway auth tip:** + - The wizard now generates a **token** even for loopback, so local WS clients must authenticate. - If you disable auth, any local process can connect; use that only on fully trusted machines. - Use a **token** for multi‑machine access or non‑loopback binds. +