Compare commits

..

31 Commits

Author SHA1 Message Date
Peter Steinberger
19b8a63e78 fix(agent): harden compaction alias routing 2026-06-19 14:08:01 +01:00
openclaw-clownfish[bot]
d16670043d fix(agent): resolve compaction model aliases before dispatch 2026-06-19 14:08:00 +01:00
Huangting-xy
c5a5d821d1 fix(agent): resolve compaction model alias via shared resolver 2026-06-19 14:08:00 +01:00
Andrew Stroup
378c4134f1 fix(slack): default member-info userId to inbound sender (#89236)
Merged via squash.

Prepared head SHA: c7a39e54f7
Co-authored-by: stroupaloop <2424551+stroupaloop@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:03:29 +01:00
Vincent Koc
cd2d837a1f fix(slack): preserve buffered thread stream replies (#78536)
Merged via squash.

Prepared head SHA: 0d8d75918d
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 14:02:50 +01:00
Vincent Koc
29e44f5eba refactor(tasks): drop duplicate maintenance stop alias 2026-06-19 20:56:56 +08:00
Vincent Koc
ce7f899165 fix(discord): bound voice upload error bodies 2026-06-19 14:50:29 +02:00
Vincent Koc
4c3b15bae6 fix(discord): bound webhook error bodies 2026-06-19 14:43:17 +02:00
Kendrick Ha
4723602e7e feat(channels): add Zalo ClawBot external channel entry and documenta… (#89586)
Merged via squash.

Prepared head SHA: 5ef4fe999a
Co-authored-by: ken-kuro <47441476+ken-kuro@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:38 +01:00
Peter Lee
430682e97a fix(xai): reject unsupported multi-agent model refs before runtime fallback (#93969)
Merged via squash.

Prepared head SHA: b58d798381
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:42:00 +01:00
Peter Lee
5c8761976c fix(whatsapp): restart listener on selfChatMode config change (#93873)
Merged via squash.

Prepared head SHA: d85f604f01
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:41:26 +01:00
Vincent Koc
7fafad8c49 refactor(plugins): drop duplicate memory reset alias 2026-06-19 20:38:03 +08:00
NIO
47545e04c4 fix(channels): stop duplicating inbound previews in system events (#94589)
Merged via squash.

Prepared head SHA: 981003591c
Co-authored-by: hugenshen <16300669+hugenshen@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 13:37:28 +01:00
Vincent Koc
aea208f0ac fix(discord): bound pluralkit error bodies 2026-06-19 14:29:11 +02:00
Vincent Koc
4d37f42df7 fix(github-copilot): bound embedding error bodies 2026-06-19 14:21:28 +02:00
Vincent Koc
56c5630107 refactor(agents): drop duplicate gateway allowlist test export 2026-06-19 20:19:11 +08:00
joshavant
99e69e16b7 remove ios identity migration 2026-06-19 14:16:48 +02:00
joshavant
f13dc76ba1 fix ios share extension device identity 2026-06-19 14:16:48 +02:00
Vincent Koc
0de3d47195 fix(google-meet): bound google api error bodies 2026-06-19 14:06:36 +02:00
Vincent Koc
f7c3775140 fix(test): prefer local bundled plugins in linked Vitest worktrees 2026-06-19 14:00:32 +02:00
Peter Steinberger
e2b52f29e4 test(plugins): separate activation-scoped web search ids
Exclude startup-lazy Codex and QA Lab entries from the loader-scoped baseline before asserting them as explicit activation-scoped contracts.
2026-06-19 07:58:44 -04:00
Vincent Koc
482d6d59ac refactor(plugin-state): drop duplicate close alias 2026-06-19 19:57:26 +08:00
Vincent Koc
ff35b29a06 fix(mattermost): stream guarded api responses 2026-06-19 13:51:37 +02:00
Peter Steinberger
5a00720de0 fix(ci): repair signing lint and test types
Use the canonical undefined comparison and preserve the gateway predicate mock signature so full release-gate lint and test-type checks pass.
2026-06-19 07:42:51 -04:00
Vincent Koc
817dd593bb test(commands): type gateway transport mock input 2026-06-19 13:34:04 +02:00
Vincent Koc
c218255815 test(plugins): pin activation-scoped web search contracts 2026-06-19 13:34:04 +02:00
Vincent Koc
3bc936b675 test(sdk): keep package e2e pnpm noninteractive 2026-06-19 13:34:04 +02:00
Vincent Koc
4799fe7df6 fix(msteams): stream graph success responses 2026-06-19 13:25:18 +02:00
Vincent Koc
f29af26326 fix(sms): bound twilio api response bodies 2026-06-19 13:24:16 +02:00
Vincent Koc
b0c1010fbf refactor(cron): drop duplicate isolated-agent test aliases 2026-06-19 19:19:08 +08:00
Vincent Koc
f14a2cb9c5 fix(clickclack): bound api error response bodies 2026-06-19 13:16:08 +02:00
118 changed files with 2438 additions and 571 deletions

4
.github/labeler.yml vendored
View File

@@ -171,6 +171,10 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:

View File

@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
includeDeviceIdentity: false)
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
}
do {

View File

@@ -62,6 +62,7 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -18,6 +18,7 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -21,10 +21,12 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -33,10 +35,11 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = []) -> DeviceAuthEntry
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore()
var next = self.readStore(profile: profile)
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -50,17 +53,25 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store)
self.writeStore(store, profile: profile)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
}
private static func normalizeRole(_ role: String) -> String {
@@ -74,14 +85,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.authFileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -90,8 +101,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,6 +1,29 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -19,6 +42,32 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -27,34 +76,49 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL())
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case .identity(let decoded):
case let .identity(decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -143,7 +207,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32 && privateKeyData.count == 32,
guard publicKeyData.count == 32, privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -211,11 +275,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL() -> URL {
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(self.fileName, isDirectory: false)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
}
}

View File

@@ -107,6 +107,7 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -122,6 +123,7 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -133,6 +135,7 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -436,13 +439,15 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -532,7 +537,11 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -550,7 +559,10 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
}
throw error
}
@@ -559,6 +571,7 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -568,7 +581,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -756,7 +769,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -765,7 +779,8 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes)
scopes: filteredScopes,
profile: deviceIdentityProfile)
}
private func persistIssuedDeviceToken(
@@ -773,7 +788,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String])
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -783,20 +799,23 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
scopes: scopes,
profile: deviceIdentityProfile)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String) async throws
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
{
if res.ok == false {
let error = res.error
@@ -855,7 +874,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -873,7 +893,8 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
}
}
}

View File

@@ -162,6 +162,7 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -179,6 +180,7 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -548,6 +548,7 @@ public struct MessageActionParams: Codable, Sendable {
public let action: String
public let params: [String: AnyCodable]
public let accountid: String?
public let requesteraccountid: String?
public let requestersenderid: String?
public let senderisowner: Bool?
public let sessionkey: String?
@@ -562,6 +563,7 @@ public struct MessageActionParams: Codable, Sendable {
action: String,
params: [String: AnyCodable],
accountid: String?,
requesteraccountid: String? = nil,
requestersenderid: String?,
senderisowner: Bool?,
sessionkey: String?,
@@ -575,6 +577,7 @@ public struct MessageActionParams: Codable, Sendable {
self.action = action
self.params = params
self.accountid = accountid
self.requesteraccountid = requesteraccountid
self.requestersenderid = requestersenderid
self.senderisowner = senderisowner
self.sessionkey = sessionkey
@@ -590,6 +593,7 @@ public struct MessageActionParams: Codable, Sendable {
case action
case params
case accountid = "accountId"
case requesteraccountid = "requesterAccountId"
case requestersenderid = "requesterSenderId"
case senderisowner = "senderIsOwner"
case sessionkey = "sessionKey"

View File

@@ -5,8 +5,99 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `share extension profile uses separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(
DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
}
@Test
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -40,8 +131,8 @@ struct DeviceIdentityStoreTests {
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
}
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
@Test
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -52,14 +143,14 @@ struct DeviceIdentityStoreTests {
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)

View File

@@ -0,0 +1,22 @@
import OpenClawProtocol
import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
params: [:],
accountid: "default",
requestersenderid: "U123",
senderisowner: true,
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
#expect(params.requesteraccountid == nil)
}
}

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawKit
import OpenClawProtocol
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
@@ -18,7 +18,9 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
self.callbacks = callbacks
}
var state: URLSessionTask.State { .running }
var state: URLSessionTask.State {
.running
}
func resume() {}
@@ -53,6 +55,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@@ -73,7 +76,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
@@ -92,10 +98,13 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
self.connectDevice = device
}
}
}
@@ -104,6 +113,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectAuth }
}
func latestConnectDevice() -> [String: Any]? {
self.lock.withLock { self.connectDevice }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -134,7 +147,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
func emitReceiveFailure() {
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
self._state = .canceling
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
@@ -175,7 +191,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30_000,
"tickIntervalMs": 30000,
],
"auth": [:],
]
@@ -223,20 +239,25 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
private actor SeqGapProbe {
private var saw = false
func mark() { self.saw = true }
func value() -> Bool { self.saw }
func mark() {
self.saw = true
}
func value() -> Bool {
self.saw
}
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
func `websocket ping ignores duplicate success callbacks`() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
func `websocket ping ignores duplicate callbacks after first error`() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
@@ -249,7 +270,7 @@ struct GatewayNodeSessionTests {
}
@Test
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -284,7 +305,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -305,7 +326,74 @@ struct GatewayNodeSessionTests {
}
@Test
func passwordTakesPrecedenceOverBootstrapToken() async throws {
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-node-token")
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "share-node-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
let shareDeviceId = try #require(shareDevice["id"] as? String)
#expect(shareDeviceId != primaryIdentity.deviceId)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
.token == "primary-node-token")
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
#expect(
DeviceAuthStore
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
"share-node-token")
await gateway.disconnect()
}
@Test
func `password takes precedence over bootstrap token`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -320,7 +408,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
@@ -341,7 +429,7 @@ struct GatewayNodeSessionTests {
}
@Test
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
func `changed session box rebuilds existing gateway channel`() async throws {
let firstSession = FakeGatewayWebSocketSession()
let secondSession = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
@@ -357,7 +445,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -370,7 +458,7 @@ struct GatewayNodeSessionTests {
})
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -389,7 +477,7 @@ struct GatewayNodeSessionTests {
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
func `bootstrap hello stores additional device tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -440,7 +528,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -468,7 +556,7 @@ struct GatewayNodeSessionTests {
}
@Test
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -509,7 +597,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
url: #require(URL(string: "wss://example.invalid")),
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -530,7 +618,7 @@ struct GatewayNodeSessionTests {
}
@Test
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -574,7 +662,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -593,25 +681,25 @@ struct GatewayNodeSessionTests {
}
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com")!)
activeURL: #require(URL(string: "wss://gateway.example.com")))
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
let normalized = try canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com:7443")!)
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
func `invoke with timeout returns underlying response before timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -619,8 +707,7 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
@@ -628,7 +715,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
func `invoke with timeout returns timeout error`() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -636,8 +723,7 @@ struct GatewayNodeSessionTests {
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
})
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
@@ -645,7 +731,7 @@ struct GatewayNodeSessionTests {
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
func `invoke with timeout zero disables timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -653,15 +739,14 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
})
#expect(response.ok == true)
#expect(response.error == nil)
}
@Test
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
func `emits synthetic seq gap after reconnect snapshot`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -687,7 +772,7 @@ struct GatewayNodeSessionTests {
}
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,

View File

@@ -1194,5 +1194,9 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]

View File

@@ -52,6 +52,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
## Notes

View File

@@ -0,0 +1,95 @@
---
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
read_when:
- You want a personal Zalo assistant bot with QR-code login
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
title: "Zalo ClawBot"
---
OpenClaw connects to Zalo ClawBot through the catalog-listed external
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
code.
## Compatibility
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
| -------------- | ---------------- | ------------ | ------------- |
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
## Prerequisites
- Node.js **>= 22**
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
- A Zalo account on a mobile device to scan the login QR code.
## Install with onboard (recommended)
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
```bash
openclaw onboard
```
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
## Manual Installation
To add the channel to an already-onboarded gateway, follow these steps:
### 1. Install the plugin
```bash
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
```
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
### 2. Enable the plugin in config
```bash
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
```
### 3. Generate QR code and log in
```bash
openclaw channels login --channel openclaw-zaloclawbot
```
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
---
## How It Works
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
browser or web-session automation.
## Under the Hood
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
- Long-poll connections utilize the `getUpdates` endpoint.
- Webhooks are disabled by default for local desktop/terminal gateway runs.
- Messages are processed client-side and mapped directly to your local agent runtime.
The external plugin manages bot credentials under the OpenClaw state directory.
Treat that directory as sensitive and include it in the same access-control and
backup policy as the rest of your OpenClaw state.
---
## Troubleshooting
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.

View File

@@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`.
### Using a different model
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string:
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`:
```json
{
@@ -76,6 +76,8 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp
}
```
Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider.
This works with local models too, for example a second Ollama model dedicated to summarization:
```json

View File

@@ -316,6 +316,10 @@
"source": "/providers/zalo",
"destination": "/channels/zalo"
},
{
"source": "/channels/openclaw-zaloclawbot",
"destination": "/channels/zaloclawbot"
},
{
"source": "/providers/whatsapp",
"destination": "/channels/whatsapp"
@@ -1132,6 +1136,7 @@
"channels/feishu",
"channels/yuanbao",
"channels/zalo",
"channels/zaloclawbot",
"channels/zalouser"
]
},

View File

@@ -668,7 +668,7 @@ Periodic heartbeat runs.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.

View File

@@ -504,9 +504,10 @@ Legacy aliases still normalize to the canonical bundled ids:
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("raw response.text() should not be used");
});
const response = {
ok: false,
status: 502,
text,
body: {
getReader: () => ({
read: async () => {
if (readCount > 0) {
return { done: true, value: undefined };
}
readCount += 1;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
} as unknown as Response;
return {
response,
cancel,
releaseLock,
text,
expectedDetail: body.slice(0, limit),
};
}
describe("ClickClack HTTP client", () => {
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);
const client = createClickClackClient({
baseUrl: "https://clickclack.example",
token: "test-token",
fetch: fetchMock,
});
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
expect(streamed.text).not.toHaveBeenCalled();
expect(streamed.cancel).toHaveBeenCalledTimes(1);
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,6 +2,7 @@
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
* delivery code.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { WebSocket } from "ws";
import type {
ClickClackChannel,
@@ -17,6 +18,8 @@ type ClientOptions = {
fetch?: typeof fetch;
};
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
/**
* Creates a typed client for the ClickClack API using bearer-token auth.
*/
@@ -38,7 +41,8 @@ export function createClickClackClient(options: ClientOptions) {
}
const response = await fetcher(`${baseUrl}${path}`, { ...init, headers: requestHeaders });
if (!response.ok) {
throw new Error(`ClickClack ${response.status}: ${await response.text()}`);
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
throw new Error(`ClickClack ${response.status}: ${detail}`);
}
return (await response.json()) as T;
}

View File

@@ -20,6 +20,28 @@ const buildResponse = (params: { status: number; body?: unknown }): MockResponse
};
};
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("fetchPluralKitMessageInfo", () => {
it("returns null when disabled", async () => {
const fetcher = vi.fn();
@@ -65,4 +87,30 @@ describe("fetchPluralKitMessageInfo", () => {
expect(result?.member?.id).toBe("mem_1");
expect(receivedHeaders?.Authorization).toBe("pk_test");
});
it("bounds PluralKit API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"plural failure ".repeat(1024)}tail`, {
status: 500,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetcher = vi.fn(async () => tracked.response);
let caught: Error | undefined;
try {
await fetchPluralKitMessageInfo({
messageId: "boom",
config: { enabled: true },
fetcher: fetcher as unknown as typeof fetch,
});
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("PluralKit API failed (500): plural failure");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_400);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,9 @@
// Discord plugin module implements pluralkit behavior.
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2";
const PLURALKIT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
export type DiscordPluralKitConfig = {
enabled?: boolean;
@@ -51,7 +53,9 @@ export async function fetchPluralKitMessageInfo(params: {
return null;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = text.trim() ? `: ${text.trim()}` : "";
throw new Error(`PluralKit API failed (${res.status})${detail}`);
}

View File

@@ -16,6 +16,28 @@ vi.mock("openclaw/plugin-sdk/fetch-runtime", async () => {
};
});
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("sendWebhookMessageDiscord proxy support", () => {
beforeEach(() => {
makeProxyFetchMock.mockReset();
@@ -208,4 +230,39 @@ describe("sendWebhookMessageDiscord proxy support", () => {
expect(error.rawBody).toEqual({ message: "upstream unavailable" });
globalFetchMock.mockRestore();
});
it("bounds webhook error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const globalFetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(tracked.response);
const cfg = {
channels: {
discord: {
token: "Bot test-token",
},
},
} as OpenClawConfig;
const thrown = await sendWebhookMessageDiscord("hello", {
cfg,
accountId: "default",
webhookId: "123",
webhookToken: "abc",
wait: true,
}).then(
() => undefined,
(error: unknown) => error,
);
expect(thrown).toBeInstanceOf(DiscordError);
const error = thrown as DiscordError;
expect(error.message).toContain("upstream unavailable");
expect(JSON.stringify(error.rawBody)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
globalFetchMock.mockRestore();
});
});

View File

@@ -1,6 +1,7 @@
// Discord plugin module implements send.webhook behavior.
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDiscordClientAccountContext } from "./client.js";
import {
@@ -14,6 +15,8 @@ import { rewriteDiscordKnownMentions } from "./mentions.js";
import { createDiscordSendResult } from "./send.receipt.js";
import type { DiscordSendResult } from "./send.types.js";
const DISCORD_WEBHOOK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type DiscordWebhookSendOpts = {
cfg: OpenClawConfig;
webhookId: string;
@@ -54,7 +57,9 @@ function coerceWebhookErrorBody(raw: string): unknown {
}
async function throwWebhookResponseError(response: Response): Promise<never> {
const raw = await response.text().catch(() => "");
const raw = await readResponseTextLimited(response, DISCORD_WEBHOOK_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const parsed = coerceWebhookErrorBody(raw);
if (response.status === 429) {
throw new RateLimitError(response, {

View File

@@ -52,6 +52,28 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
let ensureOggOpus: typeof import("./voice-message.js").ensureOggOpus;
let sendDiscordVoiceMessage: typeof import("./voice-message.js").sendDiscordVoiceMessage;
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("ensureOggOpus", () => {
beforeAll(async () => {
({ ensureOggOpus, sendDiscordVoiceMessage } = await import("./voice-message.js"));
@@ -374,4 +396,57 @@ describe("sendDiscordVoiceMessage", () => {
message: "cdn unavailable",
});
});
it("bounds voice upload error bodies without using response.text()", async () => {
const rest = createRest();
const tracked = cancelTrackedResponse(`${"cdn unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
const method = input instanceof Request ? input.method : (init?.method ?? "GET");
if (method === "POST" && url.endsWith("/channels/channel-1/attachments")) {
return new Response(
JSON.stringify({
attachments: [
{
id: 0,
upload_url: "https://cdn.test/upload",
upload_filename: "uploaded.ogg",
},
],
}),
{ status: 200 },
);
}
if (method === "PUT" && url === "https://cdn.test/upload") {
return tracked.response;
}
throw new Error(`unexpected fetch ${method} ${url}`);
});
let error: unknown;
try {
await sendDiscordVoiceMessage(
rest,
"channel-1",
Buffer.from("ogg"),
metadata,
undefined,
async (fn) => await fn(),
false,
"bot-token",
);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).name).toBe("DiscordError");
expect((error as Error).message).toContain("cdn unavailable");
expect(JSON.stringify((error as { rawBody?: unknown }).rawBody)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -22,6 +22,7 @@ import {
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime";
import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -34,6 +35,7 @@ const DISCORD_VOICE_MESSAGE_FLAG = 1 << 13;
const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
const WAVEFORM_SAMPLES = 256;
const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000;
const DISCORD_VOICE_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const DISCORD_VOICE_UPLOAD_SSRF_POLICY: SsrFPolicy = {
allowRfc2544BenchmarkRange: true,
allowIpv6UniqueLocalRange: true,
@@ -299,7 +301,9 @@ async function createVoiceRequestError(
response: Response,
fallbackMessage: string,
): Promise<Error> {
const raw = await response.text().catch(() => "");
const raw = await readResponseTextLimited(response, DISCORD_VOICE_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const parsed = coerceDiscordErrorBody(raw);
if (response.status === 429) {
throw createRateLimitError(response, {

View File

@@ -47,6 +47,28 @@ function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: u
return { data: models };
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function mockDiscoveryResponse(spec: {
ok: boolean;
status?: number;
@@ -116,6 +138,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
@@ -221,6 +244,63 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
});
it("bounds model discovery error bodies", async () => {
const tracked = cancelTrackedResponse(`${"discovery denied ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: tracked.response,
release: vi.fn(async () => {}),
}));
let caught: Error | undefined;
try {
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 503");
expect(caught?.message).toContain("discovery denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds embeddings error bodies", async () => {
mockDiscoveryResponse({
ok: true,
json: buildModelsResponse([
{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] },
]),
});
const tracked = cancelTrackedResponse(`${"embedding denied ".repeat(1024)}tail`, {
status: 429,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchImpl = vi.fn(async () => tracked.response);
vi.stubGlobal("fetch", fetchImpl);
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
let caught: Error | undefined;
try {
await result.provider?.embedQuery("hello");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429");
expect(caught?.message).toContain("embedding denied");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("honors remote overrides when creating the provider", async () => {
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" });
mockDiscoveryResponse({

View File

@@ -7,6 +7,7 @@ import {
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
@@ -27,6 +28,7 @@ const COPILOT_HEADERS_STATIC: Record<string, string> = {
"Content-Type": "application/json",
...buildCopilotIdeHeaders(),
};
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined {
try {
@@ -95,9 +97,8 @@ async function discoverEmbeddingModels(params: {
});
try {
if (!response.ok) {
throw new Error(
`GitHub Copilot model discovery HTTP ${response.status}: ${await response.text()}`,
);
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`);
}
let payload: unknown;
try {
@@ -241,9 +242,8 @@ async function createGitHubCopilotEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
);
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`);
}
let payload: unknown;

View File

@@ -191,10 +191,8 @@ async function fetchGoogleCalendarEvents(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Calendar events.list",
scopes: [GOOGLE_CALENDAR_EVENTS_SCOPE],
});

View File

@@ -58,10 +58,8 @@ export async function exportGoogleDriveDocumentText(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Drive files.export",
scopes: [GOOGLE_DRIVE_MEET_SCOPE],
});

View File

@@ -0,0 +1,47 @@
// Google Meet tests cover bounded Google API error handling.
import { describe, expect, it, vi } from "vitest";
import { googleApiError } from "./google-api-errors.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("googleApiError", () => {
it("bounds Google API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"access denied ".repeat(1024)}tail`, {
status: 403,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const error = await googleApiError({
response: tracked.response,
prefix: "Google Meet spaces.get",
scopes: ["https://www.googleapis.com/auth/meetings.space.readonly"],
});
expect(error.message).toContain("Google Meet spaces.get failed (403): access denied");
expect(error.message).not.toContain("tail");
expect(error.message.length).toBeLessThan(8_400);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,21 +1,26 @@
// Google Meet plugin module implements google api errors behavior.
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
const REAUTH_HINT = "Re-run `openclaw googlemeet auth login` and store the refreshed oauth block.";
const GOOGLE_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function scopeText(scopes: readonly string[]): string {
return scopes.map((scope) => `\`${scope}\``).join(", ");
}
export async function readGoogleApiErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, GOOGLE_API_ERROR_BODY_LIMIT_BYTES);
}
export async function googleApiError(params: {
response: Response;
detail: string;
prefix: string;
scopes?: readonly string[];
}): Promise<Error> {
const detail = await readGoogleApiErrorDetail(params.response);
const scopeHint =
params.scopes && params.scopes.length > 0
? ` Required OAuth scope: ${scopeText(params.scopes)}. ${REAUTH_HINT}`
: "";
return new Error(
`${params.prefix} failed (${params.response.status}): ${params.detail}${scopeHint}`,
);
return new Error(`${params.prefix} failed (${params.response.status}): ${detail}${scopeHint}`);
}

View File

@@ -283,10 +283,8 @@ async function fetchGoogleMeetJson<T>(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: params.errorPrefix,
scopes: [GOOGLE_MEET_MEDIA_SCOPE],
});
@@ -350,10 +348,8 @@ export async function fetchGoogleMeetSpace(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.get",
scopes: [GOOGLE_MEET_SPACE_SCOPE],
});
@@ -392,10 +388,8 @@ export async function createGoogleMeetSpace(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.create",
scopes:
params.config && Object.keys(params.config).length > 0
@@ -442,10 +436,8 @@ export async function endGoogleMeetActiveConference(params: {
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.endActiveConference",
scopes: [GOOGLE_MEET_SPACE_CREATED_SCOPE],
});

View File

@@ -11,6 +11,7 @@ import {
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { readGoogleApiErrorDetail } from "./google-api-errors.js";
const GOOGLE_MEET_REDIRECT_URI = "http://localhost:8085/oauth2callback";
const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -85,7 +86,7 @@ async function executeGoogleTokenRequest(body: URLSearchParams): Promise<GoogleM
});
try {
if (!response.ok) {
const detail = await response.text();
const detail = await readGoogleApiErrorDetail(response);
throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`);
}
const payload = (await response.json()) as {

View File

@@ -1,5 +1,16 @@
// Mattermost tests cover client plugin behavior.
import { describe, expect, it, vi } from "vitest";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
import {
createMattermostClient,
createMattermostPost,
@@ -49,6 +60,55 @@ function parseRequestJson(init: RequestInit | undefined): Record<string, unknown
return parsed as Record<string, unknown>;
}
function streamingMattermostResponse(body: unknown): {
response: Response;
arrayBuffer: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(JSON.stringify(body));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoded);
controller.close();
},
});
const arrayBuffer = vi.fn(async () => {
throw new Error("guarded Mattermost responses must stay streaming");
});
return {
response: {
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
body: stream,
arrayBuffer,
} as unknown as Response,
arrayBuffer,
};
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function createTestClient(response?: { status?: number; body?: unknown; contentType?: string }) {
const { mockFetch, calls } = createMockFetch(response);
const client = createMattermostClient({
@@ -98,6 +158,72 @@ describe("normalizeMattermostBaseUrl", () => {
// ── createMattermostClient ───────────────────────────────────────────
describe("createMattermostClient", () => {
it("keeps guarded Mattermost responses streaming until callers consume them", async () => {
const release = vi.fn(async () => {});
const { response, arrayBuffer } = streamingMattermostResponse({ id: "u1" });
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).resolves.toEqual({ id: "u1" });
expect(arrayBuffer).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels guarded Mattermost error bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedResponse(`${"upstream unavailable ".repeat(512)}tail`, {
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "text/plain" },
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
let caught: Error | undefined;
try {
await client.request("/users/me");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("Mattermost API 503 Service Unavailable");
expect(caught?.message).toContain("upstream unavailable");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("releases guarded Mattermost responses when upstream body reads fail", async () => {
const release = vi.fn(async () => {});
const stream = new ReadableStream<Uint8Array>({
pull() {
throw new Error("upstream body failed");
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release,
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).rejects.toThrow("upstream body failed");
expect(release).toHaveBeenCalledTimes(1);
});
it("creates a client with normalized baseUrl", () => {
const { mockFetch } = createMockFetch();
const client = createMattermostClient({

View File

@@ -1,5 +1,6 @@
// Mattermost plugin module implements client behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
fetchWithSsrFGuard,
@@ -11,6 +12,9 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
const MATTERMOST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export type MattermostClient = {
@@ -82,14 +86,79 @@ function buildMattermostApiUrl(baseUrl: string, path: string): string {
export async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) {
return data.message;
if (!res.body) {
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) {
return data.message;
}
return JSON.stringify(data);
}
return JSON.stringify(data);
return await res.text();
}
return await res.text();
const text = await readResponseTextLimited(res, MATTERMOST_ERROR_BODY_LIMIT_BYTES);
if (contentType.includes("application/json")) {
try {
const data = JSON.parse(text) as { message?: string } | undefined;
if (data?.message) {
return data.message;
}
return JSON.stringify(data);
} catch {
return text;
}
}
return text;
}
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
await releaseOnce();
controller.close();
return;
}
if (value) {
controller.enqueue(value);
}
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
await reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function createMattermostClient(params: {
@@ -110,11 +179,6 @@ export function createMattermostClient(params: {
// A custom fetchImpl is accepted for testing and special cases.
const externalFetchImpl = params.fetchImpl;
// Guarded fetch adapter: calls fetchWithSsrFGuard and returns a plain Response.
// Body is buffered before releasing the dispatcher so callers get a complete Response.
// Null-body status codes per Fetch spec — Response constructor rejects a body for these.
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
const guardedFetchImpl: MattermostFetch = async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
@@ -124,14 +188,7 @@ export function createMattermostClient(params: {
auditContext: "mattermost-api",
policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork),
});
try {
const bodyBytes = NULL_BODY_STATUSES.has(response.status)
? null
: await response.arrayBuffer();
return new Response(bodyBytes, { status: response.status, headers: response.headers });
} finally {
await release();
}
return responseWithRelease(response, release);
};
const fetchImpl = externalFetchImpl ?? guardedFetchImpl;

View File

@@ -92,6 +92,33 @@ function mockTextFetchResponse(body: string, init?: ResponseInit) {
mockFetch(async () => textResponse(body, init));
}
function graphStreamResponse(body: unknown): {
response: Response;
arrayBuffer: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(JSON.stringify(body));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoded);
controller.close();
},
});
const arrayBuffer = vi.fn(async () => {
throw new Error("Graph response must stay streaming");
});
return {
response: {
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
body: stream,
arrayBuffer,
} as unknown as Response,
arrayBuffer,
};
}
function graphCollection<T>(...items: T[]) {
return { value: items };
}
@@ -229,6 +256,20 @@ describe("msteams graph helpers", () => {
);
});
it("keeps successful Graph responses streaming for bounded JSON parsing", async () => {
const { response, arrayBuffer } = graphStreamResponse(graphCollection(groupOne));
mockFetch(async () => response);
await expect(
fetchGraphJson<{ value: Array<{ id: string }> }>({
token: graphToken,
path: "/groups?$select=id",
}),
).resolves.toEqual(graphCollection(groupOne));
expect(arrayBuffer).not.toHaveBeenCalled();
});
it("posts Graph JSON to v1 and beta roots and treats empty mutation responses as undefined", async () => {
mockFetch(async (input) => {
if (requestUrl(input).startsWith("https://graph.microsoft.com/beta")) {

View File

@@ -31,6 +31,50 @@ type GraphChannel = {
export type GraphResponse<T> = { value?: T[] };
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}
@@ -66,6 +110,7 @@ async function requestGraph(params: {
},
auditContext: "msteams.graph",
});
let releaseInFinally = true;
try {
if (!response.ok) {
throw await createMSTeamsHttpError(
@@ -73,14 +118,12 @@ async function requestGraph(params: {
`${params.errorPrefix ?? "Graph"} ${params.path} failed`,
);
}
const body = NULL_BODY_STATUSES.has(response.status) ? null : await response.arrayBuffer();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
});
releaseInFinally = false;
return responseWithRelease(response, release);
} finally {
await release();
if (releaseInFinally) {
await release();
}
}
}

View File

@@ -734,7 +734,7 @@ describe("msteams monitor handler authz", () => {
expect(ctxPayload.CommandAuthorized).toBe(true);
});
it("marks skipped channel message system events as non-owner", async () => {
it("marks skipped channel message system events as non-owner without duplicating body text", async () => {
resetThreadMocks();
const { deps, enqueueSystemEvent } = createDeps({
channels: {
@@ -768,15 +768,16 @@ describe("msteams monitor handler authz", () => {
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
const systemEventCall = enqueueSystemEvent.mock.calls.find(
([text]) => typeof text === "string" && text.includes("please run the deployment"),
([text]) => text === "Teams message in channel from Member",
);
if (!systemEventCall) {
throw new Error("expected skipped Teams message system event");
}
expect(systemEventCall[1]).toMatchObject({});
expect(systemEventCall[0]).not.toContain("please run the deployment");
});
it("keeps dispatched primary message system events owner-neutral", async () => {
it("keeps dispatched primary message system events owner-neutral without duplicating body text", async () => {
resetThreadMocks();
const { deps, enqueueSystemEvent } = createDeps({
channels: {
@@ -810,11 +811,14 @@ describe("msteams monitor handler authz", () => {
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalled();
const systemEventCall = enqueueSystemEvent.mock.calls.find(
([text]) => typeof text === "string" && text.includes("please check the build"),
([text]) => text === "Teams message in channel from Member",
);
if (!systemEventCall) {
throw new Error("expected active Teams message system event");
}
expect(systemEventCall[0]).not.toContain("please check the build");
const dispatched = firstSettledDispatch();
expect(recordFromMockCall(dispatched.ctxPayload).BodyForAgent).toBe("please check the build");
});
it("authorizes text control commands from static access groups", async () => {

View File

@@ -508,7 +508,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
: `Teams message in ${conversationType} from ${senderName}`;
const enqueuePrimaryMessageSystemEvent = () =>
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
core.system.enqueueSystemEvent(inboundLabel, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});

View File

@@ -50,19 +50,22 @@ describe("qa scenario catalog", () => {
expect(
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
).toEqual(requiredScenarioIds);
const nativeExecutionScenarios = pack.scenarios.filter(
(scenario) => scenario.execution.kind !== "flow",
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.map((scenario) => scenario.id)
.toSorted(),
).toStrictEqual(
[
"channel-message-flows",
"control-ui-chat-flow-playwright",
"gateway-smoke",
"package-openclaw-for-docker",
"plugin-lifecycle-probe",
"qa-otel-smoke",
"ux-matrix-evidence-dashboard",
].toSorted(),
);
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
for (const scenario of nativeExecutionScenarios) {
const execution = scenario.execution;
if (execution.kind === "flow") {
throw new Error(`expected native execution scenario: ${scenario.id}`);
}
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
expect(execution.flow).toBeUndefined();
}
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")

View File

@@ -548,6 +548,108 @@ describe("handleSlackMessageAction", () => {
}),
).rejects.toThrow(/fileId/i);
});
it("defaults member-info userId to the inbound sender when omitted", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: {},
params: {},
accountId: "OPS",
requesterAccountId: "ops",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: " Slack " },
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U123" }),
expect.any(Object),
);
});
it("defaults member-info userId through the configured default Slack account", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: { channels: { slack: { defaultAccount: "ops", accounts: { ops: {} } } } },
params: {},
requesterAccountId: "OPS",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "slack" },
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U123" }),
expect.any(Object),
);
});
it.each([
["has no inbound sender", { toolContext: { currentChannelProvider: "slack" } }],
["has no source provider", { requesterSenderId: "U123" }],
[
"has no source account",
{
accountId: "default",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "slack" },
},
],
[
"targets another Slack account",
{
accountId: "other",
requesterAccountId: "default",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "slack" },
},
],
[
"comes from another provider",
{ requesterSenderId: "U123", toolContext: { currentChannelProvider: "telegram" } },
],
])("rejects member-info without userId when the request %s", async (_label, context) => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: { action: "member-info", cfg: {}, params: {}, ...context } as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/member-info requires a userId/i);
});
it("prefers an explicit member-info userId over the inbound sender", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "member-info",
cfg: {},
params: { userId: "U999" },
accountId: "other",
requesterAccountId: "default",
requesterSenderId: "U123",
toolContext: { currentChannelProvider: "telegram" },
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ action: "memberInfo", userId: "U999" }),
expect.any(Object),
);
});
});
describe("extractSlackToolSend", () => {

View File

@@ -1,4 +1,5 @@
// Slack plugin module implements message action dispatch behavior.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
@@ -7,6 +8,11 @@ import {
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDefaultSlackAccountId } from "./accounts.js";
import {
buildSlackInteractiveBlocks,
buildSlackPresentationBlocks,
@@ -197,7 +203,20 @@ export async function handleSlackMessageAction(params: {
}
if (action === "member-info") {
const userId = readStringParam(actionParams, "userId", { required: true });
const requesterAccountId = ctx.requesterAccountId
? normalizeAccountId(ctx.requesterAccountId)
: undefined;
const targetAccountId = normalizeAccountId(accountId ?? resolveDefaultSlackAccountId(cfg));
const requesterUserId =
normalizeOptionalLowercaseString(ctx.toolContext?.currentChannelProvider) === "slack" &&
requesterAccountId !== undefined &&
requesterAccountId === targetAccountId
? normalizeOptionalString(ctx.requesterSenderId)
: undefined;
const userId = readStringParam(actionParams, "userId") ?? requesterUserId;
if (!userId) {
throw new Error("member-info requires a userId outside a current Slack conversation.");
}
return await invoke({ action: "memberInfo", userId, accountId }, cfg);
}

View File

@@ -13,6 +13,7 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {});
const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" }));
const recordInboundSessionMock = vi.fn(async () => undefined);
const recordSlackThreadParticipationMock = vi.fn();
const updateLastRouteMock = vi.fn(async () => {});
const appendSlackStreamMock = vi.fn(async () => {});
const startSlackStreamMock = vi.fn(async () => ({
@@ -830,7 +831,7 @@ vi.mock("../../limits.js", () => ({
}));
vi.mock("../../sent-thread-cache.js", () => ({
recordSlackThreadParticipation: () => {},
recordSlackThreadParticipation: recordSlackThreadParticipationMock,
}));
vi.mock("../../stream-mode.js", () => ({
@@ -1228,6 +1229,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
postMessageMock.mockClear();
chatUpdateMock.mockClear();
recordInboundSessionMock.mockReset();
recordSlackThreadParticipationMock.mockReset();
updateLastRouteMock.mockReset();
appendSlackStreamMock.mockReset();
startSlackStreamMock.mockReset();
@@ -3506,6 +3508,67 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(session.stopped).toBe(true);
});
it("routes pending native stream text through chunked sender for unexpected finalize failures", async () => {
mockedNativeStreaming = true;
const session = {
channel: "C123",
threadTs: THREAD_TS,
stopped: false,
delivered: false,
pendingText: FINAL_REPLY_TEXT,
};
startSlackStreamMock.mockResolvedValueOnce(session);
stopSlackStreamMock.mockRejectedValueOnce(
new TestSlackStreamNotDeliveredError(
FINAL_REPLY_TEXT,
"method_not_supported_for_channel_type",
),
);
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
expect(postMessageMock).not.toHaveBeenCalled();
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledWith(
expect.objectContaining({
replyThreadTs: THREAD_TS,
replies: [expect.objectContaining({ text: FINAL_REPLY_TEXT })],
}),
);
expect(session.stopped).toBe(true);
});
it("fails dispatch when an unexpected finalize fallback cannot deliver a buffered tail", async () => {
mockedNativeStreaming = true;
const session = {
channel: "C123",
threadTs: THREAD_TS,
stopped: false,
delivered: true,
pendingText: "buffered tail",
};
startSlackStreamMock.mockResolvedValueOnce(session);
stopSlackStreamMock.mockRejectedValueOnce(
new TestSlackStreamNotDeliveredError(
"buffered tail",
"method_not_supported_for_channel_type",
),
);
deliverRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
await expect(dispatchPreparedSlackMessage(createPreparedSlackMessage())).rejects.toThrowError(
"slack-stream not delivered: method_not_supported_for_channel_type",
);
expectDeliverReplyCall(0, "buffered tail");
expect(recordSlackThreadParticipationMock).toHaveBeenCalledWith(
expect.any(String),
"C123",
THREAD_TS,
expect.any(Object),
);
});
it("routes all pending native stream text through chunked sender when an append flush fails", async () => {
mockedNativeStreaming = true;
mockedDispatchSequence = [

View File

@@ -2043,11 +2043,17 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
} catch (err) {
if (err instanceof SlackStreamNotDeliveredError) {
streamFallbackDelivered = await deliverPendingStreamFallback(finalStream, err);
if (!streamFallbackDelivered) {
dispatchError ??= err;
}
} else {
const error = formatSlackError(err);
emitAcknowledgedStreamedDeliveries();
emitFailedPendingStreamedDeliveries(error);
runtime.error?.(danger(`slack-stream: failed to stop stream: ${error}`));
if (!finalStream.delivered) {
dispatchError ??= err;
}
}
}
}
@@ -2105,10 +2111,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
}
if (dispatchError) {
throw toLintErrorObject(dispatchError, "Slack dispatch failed");
}
// Record thread participation only when we actually delivered a reply and
// know the thread ts that was used (set by deliverNormally, streaming start,
// or draft stream). Falls back to statusThreadTs for edge cases.
@@ -2118,6 +2120,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
agentId: route.agentId,
});
}
if (dispatchError) {
throw toLintErrorObject(dispatchError, "Slack dispatch failed");
}
if (!anyReplyDelivered && !draftPreviewCommitted) {
await draftStream?.clear();
return;

View File

@@ -158,14 +158,17 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
}
it("queues inbound message system events as untrusted", async () => {
const prepared = await prepareWithDefaultCtx(createSlackMessage({}));
it("queues inbound message system events without duplicating body text", async () => {
const body =
"please summarize the deployment, rollback checks, health checks, and follow-up items";
const prepared = await prepareWithDefaultCtx(createSlackMessage({ text: body }));
assertPrepared(prepared);
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Slack DM from Alice: hi", {
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Slack DM from Alice", {
sessionKey: prepared.ctxPayload.SessionKey,
contextKey: "slack:message:D123:1.000",
});
expect(prepared.ctxPayload.BodyForAgent).toContain(body);
});
it("prepares wildcard open-policy account DMs", async () => {

View File

@@ -1131,7 +1131,7 @@ export async function prepareSlackMessage(params: {
? `slack:channel:${message.channel}`
: `slack:group:${message.channel}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
enqueueSystemEvent(inboundLabel, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
});

View File

@@ -132,6 +132,37 @@ describe("stopSlackStream finalize error handling", () => {
expect((thrown as SlackStreamNotDeliveredError).pendingText).toBe("hello world");
});
it("throws SlackStreamNotDeliveredError for unexpected finalize codes while text is buffered", async () => {
const session = makeSession({
appendImpl: async () => null,
stopImpl: async () => {
throw slackApiError("method_not_supported_for_channel_type");
},
});
await appendSlackStream({ session, text: "short thread reply" });
const thrown = await stopSlackStream({ session }).catch((err: unknown) => err);
expect(thrown).toBeInstanceOf(SlackStreamNotDeliveredError);
expect((thrown as SlackStreamNotDeliveredError).slackCode).toBe(
"method_not_supported_for_channel_type",
);
expect((thrown as SlackStreamNotDeliveredError).pendingText).toBe("short thread reply");
});
it("does not retry ambiguous transport failures while text is buffered", async () => {
const session = makeSession({
appendImpl: async () => null,
stopImpl: async () => {
throw new Error("socket reset");
},
});
await appendSlackStream({ session, text: "locally buffered reply" });
await expect(stopSlackStream({ session })).rejects.toThrow("socket reset");
expect(session.pendingText).toBe("locally buffered reply");
});
it("clears pendingText after an append flush is acknowledged by Slack", async () => {
const session = makeSession({
appendImpl: async () => ({ ts: "1700000000.100203" }),
@@ -372,13 +403,24 @@ describe("stopSlackStream finalize error handling", () => {
describe("error classification", () => {
it("isBenignSlackFinalizeError matches each allowlisted code", () => {
for (const code of ["user_not_found", "team_not_found", "missing_recipient_user_id"]) {
for (const code of [
"user_not_found",
"team_not_found",
"missing_recipient_user_id",
"method_not_supported_for_channel_type",
]) {
expect(isBenignSlackFinalizeError(slackApiError(code))).toBe(true);
}
});
it("isBenignSlackFinalizeError rejects non-listed codes", () => {
for (const code of ["not_authed", "ratelimited", "channel_not_found"]) {
for (const code of [
"not_authed",
"ratelimited",
"channel_not_found",
"internal_error",
"fatal_error",
]) {
expect(isBenignSlackFinalizeError(slackApiError(code))).toBe(false);
}
});

View File

@@ -80,9 +80,8 @@ type StopSlackStreamParams = {
};
/**
* Thrown when Slack rejects a stream flush/finalize with a recipient-resolution
* error (see {@link BENIGN_SLACK_FINALIZE_ERROR_CODES}) while text is still
* only buffered locally by the Slack SDK. Carries the pending text so the
* Thrown when Slack definitively rejects a stream flush/finalize while text
* remains buffered locally by the Slack SDK. Carries the pending text so the
* caller can deliver it via the normal Slack reply path.
*/
export class SlackStreamNotDeliveredError extends Error {
@@ -90,7 +89,7 @@ export class SlackStreamNotDeliveredError extends Error {
readonly slackCode: string;
constructor(pendingText: string, slackCode: string) {
super(
`slack-stream: finalize failed with ${slackCode} before any text reached Slack ` +
`slack-stream: finalize failed with ${slackCode} before buffered text reached Slack ` +
`(${pendingText.length} chars pending)`,
);
this.name = "SlackStreamNotDeliveredError";
@@ -235,17 +234,18 @@ export type StopSlackStreamResult = {
* After calling this the stream message becomes a normal Slack message.
* Optionally include final text to append before stopping.
*
* If Slack's `chat.stopStream` responds with a known benign finalize error
* (see {@link BENIGN_SLACK_FINALIZE_ERROR_CODES}) AND any prior `append`
* has already landed on Slack, the error is swallowed and the session is
* marked stopped - the already-delivered text stays visible.
* If Slack's `chat.stopStream` responds with a definitive recipient/channel
* rejection while text is still buffered locally, this function throws a
* {@link SlackStreamNotDeliveredError} carrying that pending text so the caller
* can deliver it through the normal Slack reply path. Ambiguous failures
* propagate unchanged because Slack may have committed the request.
*
* If the same benign error fires while text is still only buffered locally
* (e.g. short replies that never exceeded the SDK's buffer_size), this
* function throws a {@link SlackStreamNotDeliveredError} carrying that pending
* text so the caller can deliver it through the normal Slack reply path.
* If Slack responds with a known benign finalize error (see
* {@link BENIGN_SLACK_FINALIZE_ERROR_CODES}) after prior `append` calls already
* landed, the error is swallowed and the session is marked stopped - the
* already-delivered text stays visible.
*
* All other errors propagate unchanged.
* Errors without buffered text propagate unchanged.
*
* On success, returns the finalized message's Slack `ts` (when reported) so the
* caller can emit the `message_sent` hook with a populated `messageId`.
@@ -293,7 +293,8 @@ export async function stopSlackStream(
const code = extractSlackErrorCode(err) ?? "unknown";
if (session.pendingText) {
// stop() can be the first network call for short replies. If Slack
// Connect rejects it, the user has not seen the SDK-buffered text yet.
// definitively rejects that finalize, the user has not seen the
// SDK-buffered text. Let the caller fall back to chat.postMessage.
throw new SlackStreamNotDeliveredError(session.pendingText, code);
}
if (session.delivered) {
@@ -327,6 +328,8 @@ const BENIGN_SLACK_FINALIZE_ERROR_CODES = new Set<string>([
"team_not_found",
// DMs that closed between stream start and stop.
"missing_recipient_user_id",
// Channels where Slack accepts ordinary messages but not native streaming.
"method_not_supported_for_channel_type",
]);
export function isBenignSlackFinalizeError(err: unknown): boolean {

View File

@@ -53,6 +53,28 @@ function readUrlEncodedRequestBody(init: RequestInit | undefined): URLSearchPara
throw new Error("Expected Twilio request body to be URL-encoded.");
}
function cancelTrackedTextResponse(
text: string,
init?: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("Twilio SMS helpers", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
@@ -472,6 +494,35 @@ describe("Twilio SMS helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels oversized guarded Twilio error bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse(`${"upstream unavailable ".repeat(512)}tail`, {
status: 503,
});
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
let caught: Error | undefined;
try {
await sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
});
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("Twilio SMS send failed (503): upstream unavailable");
expect(caught?.message).toContain("... [truncated]");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects malformed JSON from successful Twilio sends", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => new Response("not json", { status: 201 }));
@@ -503,6 +554,28 @@ describe("Twilio SMS helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels oversized guarded Twilio success bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(1024 * 1024 + 1), { status: 201 });
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
release,
});
await expect(
sendSmsViaTwilio({
account: createAccount(),
to: "+15551234567",
text: "hello",
}),
).rejects.toThrow(
"Twilio SMS API response body too large: 1048577 bytes (limit: 1048576 bytes)",
);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("exposes a typed Twilio SMS API error", () => {
const error = new TwilioSmsApiError(
429,

View File

@@ -11,6 +11,9 @@ const TWILIO_MESSAGING_URL = "https://messaging.twilio.com/v1";
const TWILIO_API_HOSTNAME = "api.twilio.com";
const TWILIO_MESSAGING_HOSTNAME = "messaging.twilio.com";
const TWILIO_API_TIMEOUT_MS = 30_000;
const TWILIO_API_SUCCESS_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
const TWILIO_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const TRUNCATED_RESPONSE_SUFFIX = "... [truncated]";
const WEBHOOK_BODY_LIMIT_BYTES = 32 * 1024;
const WEBHOOK_BODY_TIMEOUT_MS = 5_000;
@@ -265,6 +268,61 @@ function basicAuthHeader(account: ResolvedSmsAccount): string {
return `Basic ${Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64")}`;
}
function appendTruncatedResponseSuffix(text: string): string {
return `${text.trimEnd()}${TRUNCATED_RESPONSE_SUFFIX}`;
}
async function readTwilioApiResponseText(response: Response): Promise<string> {
if (!response.body) {
return "";
}
const maxBytes = response.ok
? TWILIO_API_SUCCESS_BODY_LIMIT_BYTES
: TWILIO_API_ERROR_BODY_LIMIT_BYTES;
const truncateOnLimit = !response.ok;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let totalBytes = 0;
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return text + decoder.decode();
}
if (!value?.byteLength) {
continue;
}
const remainingBytes = maxBytes - totalBytes;
if (value.byteLength > remainingBytes) {
const clipped = remainingBytes > 0 ? value.slice(0, remainingBytes) : undefined;
if (truncateOnLimit) {
if (clipped) {
text += decoder.decode(clipped, { stream: true });
}
await reader.cancel().catch(() => undefined);
return appendTruncatedResponseSuffix(text + decoder.decode());
}
await reader.cancel().catch(() => undefined);
throw new Error(
`Twilio SMS API response body too large: ${totalBytes + value.byteLength} bytes ` +
`(limit: ${maxBytes} bytes)`,
);
}
text += decoder.decode(value, { stream: true });
totalBytes += value.byteLength;
}
} finally {
try {
reader.releaseLock();
} catch {}
}
}
function normalizeRequestHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) {
return {};
@@ -298,7 +356,7 @@ async function requestTwilioApi(params: {
return {
ok: response.ok,
status: response.status,
text: await response.text(),
text: await readTwilioApiResponseText(response),
};
}
@@ -314,7 +372,7 @@ async function requestTwilioApi(params: {
return {
ok: guarded.response.ok,
status: guarded.response.status,
text: await guarded.response.text(),
text: await readTwilioApiResponseText(guarded.response),
};
} finally {
await guarded.release();

View File

@@ -15,7 +15,7 @@ describe("whatsapp bundled entries", () => {
it("declares account config as channel-restart reload metadata", () => {
expect(whatsappPlugin.reload).toEqual({
configPrefixes: ["web", "channels.whatsapp.accounts"],
configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"],
noopPrefixes: ["channels.whatsapp"],
});
});

View File

@@ -181,7 +181,7 @@ export function createWhatsAppPluginBase(params: {
// the broad `channels.whatsapp` noop prefix below otherwise swallows it as a
// hot no-op and leaves the account connected until a full restart.
reload: {
configPrefixes: ["web", "channels.whatsapp.accounts"],
configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"],
noopPrefixes: ["channels.whatsapp"],
},
gatewayMethodDescriptors: [{ name: "web.login.start" }, { name: "web.login.wait" }],

View File

@@ -31,6 +31,45 @@
}
}
},
"modelCatalog": {
"suppressions": [
{
"provider": "xai",
"model": "grok-4.20-multi-agent-0309",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-beta-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-experimental-beta-0304",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-experimental-beta-latest",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
},
{
"provider": "xai",
"model": "grok-4.20-multi-agent-beta-0309",
"reason": "OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai."
}
]
},
"syntheticAuthRefs": ["xai"],
"setup": {
"providers": [

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const manifest = JSON.parse(
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as {
modelCatalog?: {
suppressions?: Array<{ provider?: string; model?: string }>;
};
};
const XAI_MULTI_AGENT_MODELS = [
"grok-4.20-multi-agent-0309",
"grok-4.20-multi-agent",
"grok-4.20-multi-agent-latest",
"grok-4.20-multi-agent-beta-latest",
"grok-4.20-multi-agent-experimental-beta-0304",
"grok-4.20-multi-agent-experimental-beta-latest",
"grok-4.20-multi-agent-beta-0309",
] as const;
describe("xAI plugin manifest", () => {
it("suppresses the unsupported multi-agent model aliases", () => {
const suppressionRefs = new Set(
(manifest.modelCatalog?.suppressions ?? []).map(
(suppression) => `${suppression.provider}/${suppression.model}`,
),
);
for (const model of XAI_MULTI_AGENT_MODELS) {
expect(suppressionRefs).toContain(`xai/${model}`);
}
});
});

View File

@@ -105,6 +105,7 @@ export const MessageActionParamsSchema = Type.Object(
action: NonEmptyString,
params: Type.Record(Type.String(), Type.Unknown()),
accountId: Type.Optional(Type.String()),
requesterAccountId: Type.Optional(Type.String()),
requesterSenderId: Type.Optional(Type.String()),
// Honored only when the RPC caller has the full operator scope set
// (shared-secret bearer or `operator.admin`). For narrowly-scoped

View File

@@ -43,7 +43,13 @@ function runCommand(
const stderr: string[] = [];
const child = spawn(command, args, {
cwd: options.cwd,
env: { ...process.env, npm_config_audit: "false", npm_config_fund: "false" },
env: {
...process.env,
CI: process.env.CI ?? "true",
npm_config_audit: "false",
npm_config_fund: "false",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
},
stdio: ["ignore", "pipe", "pipe"],
});
const timer = setTimeout(() => {

View File

@@ -1,29 +0,0 @@
title: OpenAI-compatible chat tools HTTP API
scenario:
id: openai-compatible-chat-tools
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-tool-use
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
successCriteria:
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
- The generated config preserves strict positive gateway port and timeout values.
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
docsRefs:
- docs/gateway/protocol.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-chat-tools/client.mjs
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
- scripts/e2e/openai-chat-tools-docker.sh
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.

View File

@@ -1,29 +0,0 @@
title: OpenAI web_search minimal reasoning gate
scenario:
id: openai-web-search-minimal
surface: model-provider
coverage:
primary:
- runtime.reasoning-and-cache-controls
secondary:
- web-search.openai-native-web-search
- tools.web-search
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
successCriteria:
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
- Gateway ports are parsed strictly before connecting.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
- scripts/e2e/openai-web-search-minimal-docker.sh
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.

View File

@@ -1,30 +0,0 @@
title: OpenAI native web_search request assertions
scenario:
id: openai-web-search-native-assertions
surface: model-provider
coverage:
primary:
- web-search.openai-native-web-search
- plugins.web-search-and-fetch
secondary:
- web-search.model-and-filter-routing
- tools.web-search
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
successCriteria:
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
- Large request logs are scanned without missing later success requests.
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
- Function-shaped `web_search` is rejected as native Responses proof.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
summary: Vitest coverage for native OpenAI web_search request-log assertions.

View File

@@ -1,28 +0,0 @@
title: OpenWebUI OpenAI-compatible API probe
scenario:
id: openwebui-openai-compatible
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-provider-turns
- runtime.provider-specific-model-options
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
successCriteria:
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
docsRefs:
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/openwebui-probe.mjs
- scripts/e2e/openwebui-docker.sh
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.

View File

@@ -68,7 +68,7 @@ function readManifest(manifestPath) {
if (!Array.isArray(parsed.targets) || parsed.targets.length === 0) {
throw new Error("Signing manifest must include targets.");
}
if (typeof parsed.appGroupId !== "undefined") {
if (parsed.appGroupId !== undefined) {
validateAppGroupId(parsed.appGroupId, "Signing manifest appGroupId");
}

View File

@@ -121,6 +121,45 @@
}
}
},
{
"name": "@zalo-platforms/openclaw-zaloclawbot",
"description": "OpenClaw Zalo ClawBot channel plugin by the Zalo Platforms team.",
"source": "external",
"kind": "channel",
"openclaw": {
"plugin": {
"id": "openclaw-zaloclawbot",
"label": "Zalo ClawBot"
},
"channel": {
"id": "openclaw-zaloclawbot",
"label": "Zalo ClawBot",
"selectionLabel": "Zalo ClawBot (QR)",
"detailLabel": "Zalo ClawBot",
"docsPath": "/channels/zaloclawbot",
"docsLabel": "zaloclawbot",
"blurb": "Personal Zalo assistant bot via QR-code login — owner-bound, no setup.",
"aliases": ["zaloclawbot", "zalo-clawbot"],
"order": 82
},
"channelConfigs": {
"openclaw-zaloclawbot": {
"label": "Zalo ClawBot",
"description": "Personal Zalo assistant — QR-onboarded, owner-bound.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"install": {
"npmSpec": "@zalo-platforms/openclaw-zaloclawbot@0.1.4",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-5IxZriHJYACLLGqkCPPsTP9tas62kXEOFqTFAFMdunAM3SPhIJwVFRp0WvoP/m7L2PX85weD0g8LOtxM93VDYg==",
"minHostVersion": ">=2026.4.10"
}
}
},
{
"name": "@openclaw/discord",
"description": "OpenClaw Discord channel plugin",

View File

@@ -77,7 +77,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["ChatHistoryParams", ["agentId"]],
["ChatInjectParams", ["agentId"]],
["ChatSendParams", ["agentId"]],
["MessageActionParams", ["inboundTurnKind"]],
["MessageActionParams", ["inboundTurnKind", "requesterAccountId"]],
["CronListParams", ["compact"]],
["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]],
["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]],

View File

@@ -425,15 +425,20 @@ export function resolveVitestSpawnParams(env = process.env, platform = process.p
*/
export function resolveVitestSpawnEnv(env = process.env) {
const nextEnv = resolveLocalVitestEnv(env);
if (!shouldApplyNativeWorkerBudget(nextEnv)) {
return nextEnv;
const linkedSourceBundledPluginsEnv = resolveLinkedSourceBundledPluginsEnv(nextEnv);
const baseEnv =
Object.keys(linkedSourceBundledPluginsEnv).length > 0
? { ...nextEnv, ...linkedSourceBundledPluginsEnv }
: nextEnv;
if (!shouldApplyNativeWorkerBudget(baseEnv)) {
return baseEnv;
}
const nativeWorkerCount = String(resolveNativeWorkerCount(nextEnv));
const nativeWorkerCount = String(resolveNativeWorkerCount(baseEnv));
return {
...nextEnv,
RAYON_NUM_THREADS: nextEnv.RAYON_NUM_THREADS?.trim() || nativeWorkerCount,
TOKIO_WORKER_THREADS: nextEnv.TOKIO_WORKER_THREADS?.trim() || nativeWorkerCount,
...baseEnv,
RAYON_NUM_THREADS: baseEnv.RAYON_NUM_THREADS?.trim() || nativeWorkerCount,
TOKIO_WORKER_THREADS: baseEnv.TOKIO_WORKER_THREADS?.trim() || nativeWorkerCount,
};
}
@@ -454,6 +459,59 @@ function resolveExplicitVitestWorkerBudget(env) {
return parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS);
}
function hasUsableSourceBundledPluginsDir(extensionsDir, fsImpl = fs) {
if (!fsImpl.existsSync(extensionsDir)) {
return false;
}
try {
return fsImpl.readdirSync(extensionsDir, { withFileTypes: true }).some((entry) => {
if (!entry.isDirectory()) {
return false;
}
const pluginDir = path.join(extensionsDir, entry.name);
return (
fsImpl.existsSync(path.join(pluginDir, "package.json")) ||
fsImpl.existsSync(path.join(pluginDir, "openclaw.plugin.json"))
);
});
} catch {
return false;
}
}
function isSymlinkedNodeModules(baseDir, fsImpl = fs) {
try {
return fsImpl.lstatSync(path.join(baseDir, "node_modules")).isSymbolicLink();
} catch {
return false;
}
}
export function resolveLinkedSourceBundledPluginsEnv(
env = process.env,
{ baseDir = repoRoot, fsImpl = fs } = {},
) {
if (env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim()) {
return {};
}
const workingDir = env.PWD?.trim();
if (!workingDir || path.resolve(workingDir) !== path.resolve(baseDir)) {
return {};
}
if (!isSymlinkedNodeModules(baseDir, fsImpl)) {
return {};
}
const extensionsDir = path.join(baseDir, "extensions");
if (!hasUsableSourceBundledPluginsDir(extensionsDir, fsImpl)) {
return {};
}
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: extensionsDir,
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR:
env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR?.trim() || "1",
};
}
/**
* Filters known noisy Vitest stderr lines after stripping ANSI escapes.
*/

View File

@@ -1067,70 +1067,28 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"src/image-generation/openai-compatible-image-provider.test.ts",
],
],
[
"scripts/e2e/lib/openai-chat-tools/client.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/scenario.sh",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/write-config.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/openai-chat-tools-docker.sh",
[
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/assertions.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
["test/scripts/openai-chat-tools-client.test.ts", "test/scripts/docker-e2e-plan.test.ts"],
],
[
"scripts/e2e/openai-web-search-minimal-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
"test/scripts/openai-web-search-minimal-client.test.ts",
"test/scripts/openai-web-search-minimal-assertions.test.ts",
],
],
[
"scripts/e2e/lib/openwebui/http-probe.mjs",
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
],
[
"scripts/e2e/openwebui-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts",
"test/scripts/openwebui-probe.test.ts",
"test/scripts/fixture-config.test.ts",
],
],
["scripts/e2e/openwebui-probe.mjs", ["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"]],
[
"scripts/e2e/plugin-binding-command-escape-docker.sh",
[

View File

@@ -241,6 +241,8 @@ function normalizeLiveUsage(
function buildEmbeddedRunnerConfig(
params: LiveResolvedModel & {
cacheRetention: "none" | "short" | "long";
compactionModel?: string;
modelAlias?: string;
transport?: "sse" | "websocket";
},
): OpenClawConfig {
@@ -264,12 +266,14 @@ function buildEmbeddedRunnerConfig(
defaults: {
models: {
[modelKey]: {
...(params.modelAlias ? { alias: params.modelAlias } : {}),
params: {
cacheRetention: params.cacheRetention,
...(params.transport ? { transport: params.transport } : {}),
},
},
},
...(params.compactionModel ? { compaction: { model: params.compactionModel } } : {}),
},
},
};
@@ -371,7 +375,9 @@ async function compactLiveCacheSession(params: {
config: buildEmbeddedRunnerConfig({
apiKey: params.apiKey,
cacheRetention: params.cacheRetention,
compactionModel: "live-compaction",
model: params.model,
modelAlias: "live-compaction",
}),
provider: params.model.provider,
model: params.model.id,

View File

@@ -391,6 +391,162 @@ describe("buildEmbeddedCompactionRuntimeContext", () => {
expect(result.authProfileId).toBeUndefined();
});
it("resolves compaction.model alias to canonical model ref on same provider (#90340)", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"openai/gpt-5.4-mini": {
alias: "gpt54mini",
params: { thinking: "high" },
},
},
compaction: { model: "gpt54mini" },
},
},
} as unknown as OpenClawConfig,
provider: "openai",
modelId: "gpt-5.5",
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.authProfileId).toBe("openai:default");
});
it("resolves compaction.model alias to canonical model ref on different provider", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-6": {
alias: "thinky",
},
},
compaction: { model: "thinky" },
},
},
} as unknown as OpenClawConfig,
provider: "openai",
modelId: "gpt-5.5",
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-opus-4-6");
// Auth profile must be dropped when provider changes
expect(result.authProfileId).toBeUndefined();
});
it("falls back to literal model when alias does not match", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"openai/gpt-5.4-mini": {
alias: "gpt54mini",
},
},
compaction: { model: "nonexistent-alias" },
},
},
} as unknown as OpenClawConfig,
provider: "openai",
modelId: "gpt-5.5",
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("nonexistent-alias");
expect(result.authProfileId).toBe("openai:default");
});
it("preserves auth when an omitted provider uses the effective default", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"openai/gpt-5.4-mini": {
alias: "summary",
},
},
compaction: { model: "summary" },
},
},
} as unknown as OpenClawConfig,
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.authProfileId).toBe("openai:default");
});
it("prefers literal configured model ids over alias collisions (#90340)", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"openai/gpt-5.4-mini": {
alias: "gpt54mini",
},
"openai/gpt54mini": {},
},
compaction: { model: "gpt54mini" },
},
},
} as unknown as OpenClawConfig,
provider: "openai",
modelId: "gpt-5.5",
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt54mini");
expect(result.authProfileId).toBe("openai:default");
});
it("keeps current-provider configured model ids over cross-provider alias collisions (#90340)", () => {
const result = resolveEmbeddedCompactionTarget({
config: {
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-6": {
alias: "gpt-5.4-mini",
},
},
compaction: { model: "gpt-5.4-mini" },
},
},
models: {
providers: {
openai: { models: [{ id: "gpt-5.4-mini" }] },
},
},
} as unknown as OpenClawConfig,
provider: "openai",
modelId: "gpt-5.5",
authProfileId: "openai:default",
defaultProvider: "openai",
defaultModel: "gpt-5.5",
});
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.authProfileId).toBe("openai:default");
});
it("leaves non-openai providers unchanged", () => {
const result = resolveEmbeddedCompactionTarget({
provider: "anthropic",

View File

@@ -12,6 +12,12 @@ import {
type ActiveProcessSessionReference,
} from "../bash-process-references.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_PROVIDER } from "../defaults.js";
import {
buildModelAliasIndex,
inferUniqueProviderFromConfiguredModels,
resolveModelRefFromString,
} from "../model-selection-shared.js";
import {
openAIProviderUsesCodexRuntimeByDefault,
resolveSelectedOpenAIRuntimeProvider,
@@ -111,9 +117,7 @@ export function resolveEmbeddedCompactionTarget(params: {
// When switching provider via override, drop the primary auth profile to
// avoid sending the wrong credentials.
const authProfileId =
overrideProvider !== (params.provider ?? "")?.trim()
? undefined
: (params.authProfileId ?? undefined);
overrideProvider !== provider ? undefined : (params.authProfileId ?? undefined);
return {
provider: overrideProvider,
...resolveTargetProviders(overrideProvider, authProfileId),
@@ -121,6 +125,59 @@ export function resolveEmbeddedCompactionTarget(params: {
authProfileId,
};
}
const config = params.config ?? {};
const currentProvider = provider?.trim();
if (
currentProvider &&
hasBareConfiguredModelForProvider({
cfg: config,
provider: currentProvider,
model: override,
})
) {
const authProfileId = params.authProfileId ?? undefined;
return {
provider: currentProvider,
...resolveTargetProviders(currentProvider, authProfileId),
model: override,
authProfileId,
};
}
const inferredLiteralProvider = inferUniqueProviderFromConfiguredModels({
cfg: config,
model: override,
});
if (inferredLiteralProvider) {
const authProfileId =
inferredLiteralProvider !== provider ? undefined : (params.authProfileId ?? undefined);
return {
provider: inferredLiteralProvider,
...resolveTargetProviders(inferredLiteralProvider, authProfileId),
model: override,
authProfileId,
};
}
const defaultProvider = provider || DEFAULT_PROVIDER;
const aliasResolution = resolveModelRefFromString({
cfg: config,
raw: override,
defaultProvider,
aliasIndex: buildModelAliasIndex({
cfg: config,
defaultProvider,
}),
});
if (aliasResolution?.alias) {
const resolvedProvider = aliasResolution.ref.provider;
const authProfileId =
resolvedProvider !== provider ? undefined : (params.authProfileId ?? undefined);
return {
provider: resolvedProvider,
...resolveTargetProviders(resolvedProvider, authProfileId),
model: aliasResolution.ref.model,
authProfileId,
};
}
const authProfileId = params.authProfileId ?? undefined;
return {
provider,
@@ -130,6 +187,42 @@ export function resolveEmbeddedCompactionTarget(params: {
};
}
function normalizeCompactionConfigKey(value: string): string {
return value.trim().toLowerCase();
}
function hasBareConfiguredModelForProvider(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
}): boolean {
const providerKey = normalizeCompactionConfigKey(params.provider);
const modelKey = normalizeCompactionConfigKey(params.model);
if (!providerKey || !modelKey || params.model.includes("/")) {
return false;
}
for (const rawRef of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
const slashIdx = rawRef.indexOf("/");
if (slashIdx <= 0 || rawRef.endsWith("/*")) {
continue;
}
const rawProvider = rawRef.slice(0, slashIdx);
const rawModel = rawRef.slice(slashIdx + 1);
if (
normalizeCompactionConfigKey(rawProvider) === providerKey &&
normalizeCompactionConfigKey(rawModel) === modelKey
) {
return true;
}
}
const configuredProvider = Object.entries(params.cfg.models?.providers ?? {}).find(([key]) => {
return normalizeCompactionConfigKey(key) === providerKey;
})?.[1];
return (configuredProvider?.models ?? []).some((entry) => {
return normalizeCompactionConfigKey(entry?.id ?? "") === modelKey;
});
}
function shouldUseCodexRuntimeProviderForCompaction(params: {
config?: OpenClawConfig;
provider: string;

View File

@@ -61,6 +61,10 @@ vi.mock("../model-suppression.js", () => {
return undefined;
}
function isUnsupportedXaiMultiAgentModel(provider?: string, id?: string): boolean {
return provider === "xai" && id?.trim().toLowerCase() === "grok-4.20-multi-agent-0309";
}
return {
shouldSuppressBuiltInModel: ({
provider,
@@ -79,6 +83,9 @@ vi.mock("../model-suppression.js", () => {
) {
return true;
}
if (isUnsupportedXaiMultiAgentModel(provider, id)) {
return true;
}
return (
(provider === "qwen" || provider === "modelstudio") &&
id?.trim().toLowerCase() === "qwen3.6-plus" &&
@@ -92,7 +99,7 @@ vi.mock("../model-suppression.js", () => {
) {
return true;
}
return false;
return isUnsupportedXaiMultiAgentModel(provider, id);
},
buildSuppressedBuiltInModelError: ({
provider,
@@ -116,6 +123,9 @@ vi.mock("../model-suppression.js", () => {
) {
return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is available only through ChatGPT/Codex OAuth. Run \`openclaw models auth login --provider openai\` and use openai/gpt-5.3-codex-spark with that OAuth profile; OpenAI API-key auth cannot use this model.`;
}
if (isUnsupportedXaiMultiAgentModel(provider, id)) {
return "Unknown model: xai/grok-4.20-multi-agent-0309. OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai.";
}
return undefined;
},
};
@@ -3451,6 +3461,27 @@ describe("resolveModel", () => {
);
});
it("does not build a configured fallback for unsupported xAI multi-agent models", () => {
const cfg = {
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
api: "openai-completions",
models: [],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("xai", "grok-4.20-multi-agent-0309", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: xai/grok-4.20-multi-agent-0309. OpenClaw does not currently support xAI multi-agent models; choose another xAI model. See https://docs.openclaw.ai/providers/xai.",
);
});
it("rejects stale openai gpt-5.3-codex-spark discovery rows", () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",

View File

@@ -3396,8 +3396,13 @@ describe("buildAfterTurnRuntimeContext", () => {
config: {
agents: {
defaults: {
models: {
"openrouter/anthropic/claude-sonnet-4-5": {
alias: "summary",
},
},
compaction: {
model: "openrouter/anthropic/claude-sonnet-4-5",
model: "summary",
},
},
},
@@ -3415,9 +3420,8 @@ describe("buildAfterTurnRuntimeContext", () => {
agentDir: "/tmp/agent",
});
// buildEmbeddedCompactionRuntimeContext now resolves the override eagerly
// so that context engines (including third-party ones) receive the correct
// compaction model in the runtime context.
// Resolve aliases before handing runtime context to any context engine;
// otherwise third-party engines can dispatch the bare alias as a model id.
expect(legacy.provider).toBe("openrouter");
expect(legacy.model).toBe("anthropic/claude-sonnet-4-5");
// Auth profile dropped because provider changed from openai to openrouter.

View File

@@ -1,10 +1,7 @@
// Gateway config mutation guard coverage keeps agent-driven config edits inside
// the documented low-risk allowlist.
import { describe, expect, it } from "vitest";
import {
ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST,
assertGatewayConfigMutationAllowedForTest,
} from "./gateway-tool.js";
import { assertGatewayConfigMutationAllowedForTest } from "./gateway-tool.js";
function expectBlocked(
currentConfig: Record<string, unknown>,
@@ -59,23 +56,6 @@ function expectAllowedApply(
}
describe("gateway config mutation guard coverage", () => {
it("keeps a narrow allowlist of agent-tunable config paths", () => {
// This list is the contract between the public gateway tool and protected
// operator-owned config surfaces.
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).not.toContain("agents.defaults.promptOverlays");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).not.toContain("agents.defaults.model");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.subagents.thinking");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].id");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].model");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].subagents.thinking");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("channels.*.requireMention");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("messages.visibleReplies");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("messages.groupChat.visibleReplies");
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain(
"messages.groupChat.unmentionedInbound",
);
});
it("blocks global prompt overlay edits via config.patch", () => {
expectBlocked(
{ agents: { defaults: { promptOverlays: { gpt5: { personality: "off" } } } } },
@@ -167,7 +147,10 @@ describe("gateway config mutation guard coverage", () => {
{
messages: {
visibleReplies: "automatic",
groupChat: { visibleReplies: "automatic" },
groupChat: {
visibleReplies: "automatic",
unmentionedInbound: "user_request",
},
},
},
);
@@ -181,7 +164,10 @@ describe("gateway config mutation guard coverage", () => {
{
messages: {
visibleReplies: "message_tool",
groupChat: { visibleReplies: "automatic" },
groupChat: {
visibleReplies: "automatic",
unmentionedInbound: "room_event",
},
},
},
);

View File

@@ -79,9 +79,6 @@ const ALLOWED_GATEWAY_CONFIG_PATHS = [
"messages.groupChat.unmentionedInbound",
] as const;
/** @internal Exposed for regression tests only; do not import from runtime code. */
export const ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST = ALLOWED_GATEWAY_CONFIG_PATHS;
/** @internal Exposed for regression tests only; do not import from runtime code. */
export function assertGatewayConfigMutationAllowedForTest(params: {
action: "config.apply" | "config.patch";

View File

@@ -1406,6 +1406,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
action,
params: actionParams,
defaultAccountId: accountId ?? undefined,
requesterAccountId: agentAccountId,
requesterSenderId: options?.requesterSenderId,
senderIsOwner: options?.senderIsOwner,
gateway,

View File

@@ -49,3 +49,9 @@ describeChannelCatalogEntryContract({
npmSpec: "openclaw-plugin-yuanbao@2.13.1",
alias: "yb",
});
describeChannelCatalogEntryContract({
channelId: "openclaw-zaloclawbot",
npmSpec: "@zalo-platforms/openclaw-zaloclawbot@0.1.4",
alias: "zaloclawbot",
});

View File

@@ -692,6 +692,8 @@ export type ChannelMessageActionContext = {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
/** Trusted originating account id paired with requesterSenderId. */
requesterAccountId?: string | null;
/**
* Trusted sender id from inbound context. This is server-injected and must
* never be sourced from tool/model-controlled params.

View File

@@ -8,7 +8,7 @@ import {
const callGateway = vi.hoisted(() => vi.fn());
const isGatewayCredentialsRequiredError = vi.hoisted(() => vi.fn(() => false));
const isGatewayTransportError = vi.hoisted(() => vi.fn(() => false));
const isGatewayTransportError = vi.hoisted(() => vi.fn((_value: unknown) => false));
const isGatewaySecretRefUnavailableError = vi.hoisted(() => vi.fn(() => false));
const probeGatewayStatus = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());

View File

@@ -88,7 +88,7 @@ async function withTaskCommandStateDir(
await withOpenClawTestState(
{ layout: "state-only", prefix: "openclaw-tasks-command-" },
async (state) => {
taskRegistryMaintenance.stopTaskRegistryMaintenanceForTests();
taskRegistryMaintenance.stopTaskRegistryMaintenance();
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
resetConfigRuntimeState();
resetDetachedTaskLifecycleRuntimeForTests();
@@ -99,7 +99,7 @@ async function withTaskCommandStateDir(
try {
await run(state);
} finally {
taskRegistryMaintenance.stopTaskRegistryMaintenanceForTests();
taskRegistryMaintenance.stopTaskRegistryMaintenance();
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
resetConfigRuntimeState();
resetDetachedTaskLifecycleRuntimeForTests();
@@ -119,7 +119,7 @@ describe("tasks commands", () => {
afterEach(() => {
vi.useRealTimers();
taskRegistryMaintenance.stopTaskRegistryMaintenanceForTests();
taskRegistryMaintenance.stopTaskRegistryMaintenance();
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
resetConfigRuntimeState();
resetDetachedTaskLifecycleRuntimeForTests();

View File

@@ -936,6 +936,7 @@ describe("config help copy quality", () => {
const compactionModel = FIELD_HELP["agents.defaults.compaction.model"];
expect(/provider\/model|different model|primary agent model/i.test(compactionModel)).toBe(true);
expect(/alias/i.test(compactionModel)).toBe(true);
const transcriptBytes = FIELD_HELP["agents.defaults.compaction.maxActiveTranscriptBytes"];
expect(/transcript|bytes|compaction/i.test(transcriptBytes)).toBe(true);

View File

@@ -1498,7 +1498,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.timeoutSeconds":
"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 180). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.",
"agents.defaults.compaction.model":
"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"Optional provider/model or configured bare alias used only for compaction summarization. Bare aliases resolve before dispatch; a configured literal model ID wins if it collides with an alias. Leave unset to keep using the primary agent model.",
"agents.defaults.compaction.truncateAfterCompaction":
"When enabled, rotates the active session JSONL file after compaction so future turns load only the summary and unsummarized tail while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: false.",
"agents.defaults.compaction.maxActiveTranscriptBytes":

View File

@@ -538,7 +538,7 @@ export type AgentCompactionConfig = {
* Explicit ["Session Startup", "Red Lines"] preserves legacy fallback headings.
*/
postCompactionSections?: string[];
/** Optional model override for compaction summarization (e.g. "openrouter/anthropic/claude-sonnet-4-6").
/** Optional provider/model or configured bare alias for compaction summarization.
* When set, compaction uses this model instead of the agent's primary model.
* Falls back to the primary model when unset. */
model?: string;

View File

@@ -2,10 +2,10 @@
import { describe, expect, it } from "vitest";
import type { AuthProfileFailurePolicy } from "../agents/embedded-agent-runner/run/auth-profile-failure-policy.types.js";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./isolated-agent/run.suite-helpers.js";
makeIsolatedAgentJobFixture,
makeIsolatedAgentParamsFixture,
} from "./isolated-agent/job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js";
import {
loadRunCronIsolatedAgentTurn,
mockRunCronFallbackPassthrough,
@@ -35,8 +35,8 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624, #90991)", (
mockRunCronFallbackPassthrough();
await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
delivery: { mode: "none" },
payload: { kind: "agentTurn", message: "check status" },
}),
@@ -61,7 +61,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624, #90991)", (
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
cfg: {
auth: {
profiles: {
@@ -73,7 +73,7 @@ describe("runCronIsolatedAgentTurn auth profile propagation (#20624, #90991)", (
order: { openrouter: ["openrouter:default"] },
},
},
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
delivery: { mode: "none" },
payload: {
kind: "agentTurn",

View File

@@ -2,10 +2,10 @@
import { describe, expect, it } from "vitest";
import { resolveCronAgentLane } from "../agents/lanes.js";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./isolated-agent/run.suite-helpers.js";
makeIsolatedAgentJobFixture,
makeIsolatedAgentParamsFixture,
} from "./isolated-agent/job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js";
import {
loadRunCronIsolatedAgentTurn,
mockRunCronFallbackPassthrough,
@@ -28,8 +28,8 @@ async function runLaneCase(lane?: string) {
mockRunCronFallbackPassthrough();
await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
delivery: { mode: "none" },
payload: { kind: "agentTurn", message: "do it" },
}),

View File

@@ -1,10 +1,7 @@
// Fast mode tests cover isolated cron run behavior in fast execution mode.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
loadRunCronIsolatedAgentTurn,
makeCronSession,
@@ -78,7 +75,7 @@ async function runFastModeCase(params: {
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
cfg: {
agents: {
defaults: {
@@ -92,7 +89,7 @@ async function runFastModeCase(params: {
},
},
},
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
sessionTarget: params.sessionTarget ?? "isolated",
payload: {
kind: "agentTurn",

View File

@@ -1,9 +1,7 @@
// Interim retry tests cover retry behavior for incomplete isolated cron runs.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
countActiveDescendantRunsMock,
dispatchCronDeliveryMock,
@@ -47,7 +45,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
setupRunCronIsolatedAgentTurnSuite();
const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => {
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("ok");
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls);
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(expectedAgentCalls);
@@ -120,7 +118,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
});
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("error");
expect(result.error).toBe("SYSTEM_RUN_DENIED: approval required");
@@ -153,7 +151,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
});
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("error");
expect(result.error).toBe("SYSTEM_RUN_DENIED: approval required");

View File

@@ -1,11 +1,8 @@
// Run meta error tests cover status reporting when cron run metadata fails.
import { describe, expect, it } from "vitest";
import { CommandLaneTaskTimeoutError } from "../../process/command-queue.js";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
cleanupDirectCronSessionMock,
loadRunCronIsolatedAgentTurn,
@@ -31,7 +28,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
attempts: [],
});
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("error");
expect(result.error).toBe("cron isolated run failed: model provider unreachable");
@@ -52,7 +49,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
attempts: [],
});
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("error");
expect(result.error).toBe("cron isolated run failed: retry limit exceeded");
@@ -74,8 +71,8 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({ deleteAfterRun: true }),
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({ deleteAfterRun: true }),
}),
);
@@ -94,7 +91,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
new CommandLaneTaskTimeoutError("cron-nested", 330_000),
);
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("error");
expect(result.error).toBe("cron: job execution timed out");
@@ -116,7 +113,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
);
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({ abortSignal: abortController.signal }),
makeIsolatedAgentParamsFixture({ abortSignal: abortController.signal }),
);
expect(result.status).toBe("error");

View File

@@ -1,10 +1,7 @@
// Payload fallback tests cover fallback prompt payloads for isolated cron runs.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
@@ -44,8 +41,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
const dispatchMessage = "SERIALIZATION_PROBE should not be wrapped";
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message:
@@ -92,8 +89,8 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
}
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({ payload }),
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({ payload }),
}),
);
@@ -127,7 +124,7 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
cfg: {
agents: {
defaults: {
@@ -160,7 +157,7 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
cfg: {
agents: {
defaults: {

View File

@@ -1,9 +1,7 @@
// Runtime plugin tests cover plugin availability during isolated cron runs.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
loadRunCronIsolatedAgentTurn,
ensureRuntimePluginsLoadedMock,
@@ -17,7 +15,7 @@ describe("runCronIsolatedAgentTurn runtime plugins loading", () => {
setupRunCronIsolatedAgentTurnSuite();
it("loads runtime plugins eagerly using the lazily loaded module", async () => {
const params = makeIsolatedAgentTurnParams();
const params = makeIsolatedAgentParamsFixture();
const result = await runCronIsolatedAgentTurn(params);

View File

@@ -1,10 +1,7 @@
// Session key isolation tests cover separate keys for concurrent cron runs.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
@@ -40,9 +37,9 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
sessionKey: "cron:daily-monitor",
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message: "test",
@@ -98,9 +95,9 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
);
mockRunCronFallbackPassthrough();
const params = makeIsolatedAgentTurnParams({
const params = makeIsolatedAgentParamsFixture({
sessionKey: "cron:daily-monitor",
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message: "test",
@@ -138,9 +135,9 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
mockRunCronFallbackPassthrough();
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
sessionKey: "project-alpha-monitor",
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
sessionTarget: "session:project-alpha-monitor",
}),
}),
@@ -180,9 +177,9 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
sessionKey: "cron:cli-monitor",
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message: "test",
@@ -219,9 +216,9 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
makeIsolatedAgentParamsFixture({
sessionKey: "hook:webhook:cli-monitor",
job: makeIsolatedAgentTurnJob({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message: "test",

View File

@@ -1,10 +1,7 @@
// Skill filter tests cover active skill selection for isolated cron runs.
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
buildWorkspaceSkillSnapshotMock,
dispatchCronDeliveryMock,
@@ -24,8 +21,6 @@ import {
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
const makeSkillJob = makeIsolatedAgentTurnJob;
const makeSkillParams = makeIsolatedAgentTurnParams;
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -60,7 +55,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
setupRunCronIsolatedAgentTurnSuite();
async function runSkillFilterCase(overrides?: Record<string, unknown>) {
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams(overrides));
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture(overrides));
expect(result.status).toBe("ok");
return result;
}
@@ -233,8 +228,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
const result = await runCronIsolatedAgentTurn(
makeSkillParams({
job: makeSkillJob({
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
}),
@@ -254,7 +249,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
const result = await runCronIsolatedAgentTurn(
makeSkillParams({
makeIsolatedAgentParamsFixture({
cfg: {
agents: {
defaults: {
@@ -263,7 +258,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
},
},
},
job: makeSkillJob({
job: makeIsolatedAgentJobFixture({
payload: {
kind: "agentTurn",
message: "test",
@@ -287,8 +282,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
const result = await runCronIsolatedAgentTurn(
makeSkillParams({
job: makeSkillJob({
makeIsolatedAgentParamsFixture({
job: makeIsolatedAgentJobFixture({
payload: { kind: "agentTurn", message: "test", model: "openai/" },
}),
}),
@@ -327,7 +322,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
mockCliFallbackInvocation();
const runPromise = runCronIsolatedAgentTurn(
makeSkillParams({ abortSignal: abortController.signal }),
makeIsolatedAgentParamsFixture({ abortSignal: abortController.signal }),
);
await cliStarted;
abortController.abort("cron: job execution timed out");
@@ -364,7 +359,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: true,
});
await runCronIsolatedAgentTurn(makeSkillParams());
await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(runCliAgentMock).toHaveBeenCalledOnce();
// Fresh session: cliSessionId must be undefined, not the stored value.
@@ -395,7 +390,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: false,
});
await runCronIsolatedAgentTurn(makeSkillParams());
await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(runCliAgentMock).toHaveBeenCalledOnce();
// Continuation: cliSessionId should be passed through for session resume.

View File

@@ -1,6 +1,5 @@
/** Shared setup helpers for isolated-agent run test suites. */
import { afterEach, beforeEach } from "vitest";
import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import {
clearFastTestEnv,
makeCronSession,
@@ -24,6 +23,3 @@ export function setupRunCronIsolatedAgentTurnSuite(options?: { fast?: boolean })
restoreFastTestEnv(previousFastTestEnv);
});
}
export const makeIsolatedAgentTurnJob = makeIsolatedAgentJobFixture;
export const makeIsolatedAgentTurnParams = makeIsolatedAgentParamsFixture;

View File

@@ -1,8 +1,6 @@
import { describe, expect, it } from "vitest";
import {
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { makeIsolatedAgentParamsFixture } from "./job-fixtures.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
import {
deriveSessionTotalTokensMock,
loadRunCronIsolatedAgentTurn,
@@ -43,7 +41,7 @@ describe("runCronIsolatedAgentTurn usage accounting", () => {
},
});
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("ok");
expect(cronSession.sessionEntry.inputTokens).toBe(75000);
@@ -92,7 +90,7 @@ describe("runCronIsolatedAgentTurn usage accounting", () => {
},
});
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("ok");
expect(cronSession.sessionEntry.totalTokens).toBe(77000);
@@ -134,7 +132,7 @@ describe("runCronIsolatedAgentTurn usage accounting", () => {
},
});
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
expect(result.status).toBe("ok");
expect(cronSession.sessionEntry.totalTokens).toBe(77000);

View File

@@ -160,7 +160,7 @@ describe("buildGatewayReloadPlan", () => {
resolveAccount: () => ({}),
},
reload: {
configPrefixes: ["web", "channels.whatsapp.accounts"],
configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"],
noopPrefixes: ["channels.whatsapp"],
},
};
@@ -235,6 +235,14 @@ describe("buildGatewayReloadPlan", () => {
expect(plan.noopPaths).toStrictEqual([]);
});
it("restarts the WhatsApp channel when selfChatMode changes (configPrefix wins over broad noop prefix)", () => {
const plan = buildGatewayReloadPlan(["channels.whatsapp.selfChatMode"]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartChannels).toEqual(new Set(["whatsapp"]));
expect(plan.hotReasons).toContain("channels.whatsapp.selfChatMode");
expect(plan.noopPaths).toStrictEqual([]);
});
it("keeps other channels.whatsapp.* changes as hot no-ops", () => {
const plan = buildGatewayReloadPlan(["channels.whatsapp.replyToMode"]);
expect(plan.restartGateway).toBe(false);

View File

@@ -10,7 +10,7 @@ import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { closePluginStateSqliteStore } from "../plugin-state/plugin-state-store.js";
import { closePluginStateDatabase } from "../plugin-state/plugin-state-store.js";
import type { PluginServicesHandle } from "../plugins/services.js";
import {
abortTrackedChatRunById,
@@ -826,7 +826,7 @@ export function createGatewayCloseHandler(
}),
]);
});
await shutdownStep("plugin-state-store", () => closePluginStateSqliteStore(), warnings);
await shutdownStep("plugin-state-store", () => closePluginStateDatabase(), warnings);
await measureCloseStep("config-reloader", () =>
shutdownStep("config-reloader", () => params.configReloader.stop(), warnings),
);

View File

@@ -1534,10 +1534,11 @@ describe("gateway send mirroring", () => {
actions: {
describeMessageTool: () => ({ actions: ["react"] }),
supportsAction: ({ action }) => action === "react",
handleAction: async ({ params, requesterSenderId, toolContext }) =>
handleAction: async ({ params, requesterAccountId, requesterSenderId, toolContext }) =>
jsonResult({
ok: true,
messageId: params.messageId,
requesterAccountId,
requesterSenderId,
currentMessageId: toolContext?.currentMessageId,
currentMessagingTarget: toolContext?.currentMessagingTarget,
@@ -1564,6 +1565,7 @@ describe("gateway send mirroring", () => {
jsonResult({
ok: true,
messageId: "wamid.1",
requesterAccountId: "default",
requesterSenderId: "trusted-user",
currentMessageId: "wamid.1",
currentMessagingTarget: "user:15551234567",
@@ -1583,6 +1585,7 @@ describe("gateway send mirroring", () => {
messageId: "wamid.1",
emoji: "✅",
},
requesterAccountId: "default",
requesterSenderId: "trusted-user",
inboundTurnKind: "room_event",
toolContext: {
@@ -1603,6 +1606,7 @@ describe("gateway send mirroring", () => {
{
ok: true,
messageId: "wamid.1",
requesterAccountId: "default",
requesterSenderId: "trusted-user",
currentMessageId: "wamid.1",
currentMessagingTarget: "user:15551234567",
@@ -1616,7 +1620,10 @@ describe("gateway send mirroring", () => {
{ channel: "whatsapp" },
);
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith(
expect.objectContaining({ inboundEventKind: "room_event" }),
expect.objectContaining({
inboundEventKind: "room_event",
requesterAccountId: "default",
}),
);
});

View File

@@ -42,8 +42,8 @@ import { buildOutboundSessionContext } from "../../infra/outbound/session-contex
import { mirrorDeliveredSourceReplyToTranscript } from "../../infra/outbound/source-reply-mirror.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
import { normalizePollInput } from "../../polls.js";
import {
normalizeSessionKeyPreservingOpaquePeerIds,
@@ -462,6 +462,7 @@ export const sendHandlers: GatewayRequestHandlers = {
action: string;
params: Record<string, unknown>;
accountId?: string;
requesterAccountId?: string;
requesterSenderId?: string;
senderIsOwner?: boolean;
sessionKey?: string;
@@ -541,6 +542,7 @@ export const sendHandlers: GatewayRequestHandlers = {
cfg,
params: request.params,
accountId,
requesterAccountId: normalizeOptionalString(request.requesterAccountId) ?? undefined,
requesterSenderId: normalizeOptionalString(request.requesterSenderId) ?? undefined,
senderIsOwner: gatewayClientScopes.includes(ADMIN_SCOPE)
? request.senderIsOwner === true

View File

@@ -5,8 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConfigWriteNotification } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { consumeGatewaySigusr1RestartIntent } from "../infra/restart.js";
import {
pinActivePluginChannelRegistry,
releasePinnedPluginChannelRegistry,
} from "../plugins/runtime.js";
import { createEmptyRuntimeWebToolsMetadata } from "../secrets/runtime-fast-path.js";
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { diffConfigPaths } from "./config-diff.js";
import {
buildGatewayReloadPlan,
@@ -141,7 +146,13 @@ vi.mock("../agents/agent-bundle-mcp-tools.js", () => ({
disposeAllSessionMcpRuntimes: hoisted.disposeAllSessionMcpRuntimes,
}));
function createReloadHandlersForTest(logReload = { info: vi.fn(), warn: vi.fn() }) {
function createReloadHandlersForTest(
logReload = { info: vi.fn(), warn: vi.fn() },
channels?: {
start: (channel: ChannelKind) => Promise<void>;
stop: (channel: ChannelKind) => Promise<void>;
},
) {
const cron = { start: vi.fn(async () => {}), stop: vi.fn() };
const heartbeatRunner = {
stop: vi.fn(),
@@ -158,8 +169,8 @@ function createReloadHandlersForTest(logReload = { info: vi.fn(), warn: vi.fn()
channelHealthMonitor: null,
}),
setState: vi.fn(),
startChannel: vi.fn(async () => {}),
stopChannel: vi.fn(async () => {}),
startChannel: channels?.start ?? vi.fn(async () => {}),
stopChannel: channels?.stop ?? vi.fn(async () => {}),
stopPostReadySidecars: vi.fn(),
reloadPlugins: vi.fn(
async (): Promise<GatewayPluginReloadResult> => ({
@@ -889,6 +900,42 @@ describe("gateway channel hot reload handlers", () => {
}
}
it("restarts WhatsApp when the planner receives a selfChatMode change", async () => {
const whatsappPlugin = {
...createChannelTestPluginBase({ id: "whatsapp" }),
reload: {
configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"],
noopPrefixes: ["channels.whatsapp"],
},
};
const registry = createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
]);
const events: string[] = [];
const channels = {
stop: vi.fn(async (channel: ChannelKind) => {
events.push(`stop:${channel}`);
}),
start: vi.fn(async (channel: ChannelKind) => {
events.push(`start:${channel}`);
}),
};
pinActivePluginChannelRegistry(registry);
try {
const plan = buildGatewayReloadPlan(["channels.whatsapp.selfChatMode"]);
const { applyHotReload } = createReloadHandlersForTest(undefined, channels);
expect(plan.restartGateway).toBe(false);
expect(plan.restartChannels).toEqual(new Set(["whatsapp"]));
await withChannelReloadsEnabled(() => applyHotReload(plan, {}));
expect(events).toEqual(["stop:whatsapp", "start:whatsapp"]);
} finally {
releasePinnedPluginChannelRegistry(registry);
}
});
it("continues restarting later channels after a hot-reload stop failure", async () => {
const events: string[] = [];
const setState = vi.fn();

View File

@@ -10,12 +10,12 @@ import type {
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { runMessageAction } from "./message-action-runner.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
type ChannelActionHandler = NonNullable<NonNullable<ChannelPlugin["actions"]>["handleAction"]>;
@@ -401,6 +401,7 @@ describe("runMessageAction plugin dispatch", () => {
messageId: "om_123",
},
defaultAccountId: "ops",
requesterAccountId: "ops",
requesterSenderId: "trusted-user",
sessionKey: "agent:alpha:main",
sessionId: "session-123",
@@ -421,6 +422,7 @@ describe("runMessageAction plugin dispatch", () => {
{
action: "pin",
accountId: "ops",
requesterAccountId: "ops",
requesterSenderId: "trusted-user",
sessionKey: "agent:alpha:main",
sessionId: "session-123",

View File

@@ -36,6 +36,7 @@ import {
import type { OutboundMediaAccess } from "../../media/load-options.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
import { hasPollCreationParams } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
@@ -89,7 +90,6 @@ import { executePollAction, executeSendAction } from "./outbound-send-service.js
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js";
import { normalizeTargetForProvider } from "./target-normalization.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
export type MessageActionRunnerGateway = {
url?: string;
@@ -647,6 +647,7 @@ async function runGatewayPluginMessageActionOrNull(params: {
action: params.action,
params: params.params,
accountId: params.accountId ?? undefined,
requesterAccountId: params.input.requesterAccountId ?? undefined,
requesterSenderId: params.input.requesterSenderId ?? undefined,
senderIsOwner: params.input.senderIsOwner,
sessionKey: params.input.sessionKey,
@@ -1252,6 +1253,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
params,
accountId: accountId ?? undefined,
agentId,
requesterAccountId: input.requesterAccountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
sessionKey: input.sessionKey,
sessionId: input.sessionId,
@@ -1361,6 +1363,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
mediaLocalRoots: mediaAccess.localRoots,
mediaReadFile: mediaAccess.readFile,
accountId: accountId ?? undefined,
requesterAccountId: input.requesterAccountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
senderIsOwner: input.senderIsOwner,
sessionKey: input.sessionKey,

Some files were not shown because too many files have changed in this diff Show More