mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 21:22:05 +08:00
Compare commits
31 Commits
qa-fold-ht
...
pr-gate/90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b8a63e78 | ||
|
|
d16670043d | ||
|
|
c5a5d821d1 | ||
|
|
378c4134f1 | ||
|
|
cd2d837a1f | ||
|
|
29e44f5eba | ||
|
|
ce7f899165 | ||
|
|
4c3b15bae6 | ||
|
|
4723602e7e | ||
|
|
430682e97a | ||
|
|
5c8761976c | ||
|
|
7fafad8c49 | ||
|
|
47545e04c4 | ||
|
|
aea208f0ac | ||
|
|
4d37f42df7 | ||
|
|
56c5630107 | ||
|
|
99e69e16b7 | ||
|
|
f13dc76ba1 | ||
|
|
0de3d47195 | ||
|
|
f7c3775140 | ||
|
|
e2b52f29e4 | ||
|
|
482d6d59ac | ||
|
|
ff35b29a06 | ||
|
|
5a00720de0 | ||
|
|
817dd593bb | ||
|
|
c218255815 | ||
|
|
3bc936b675 | ||
|
|
4799fe7df6 | ||
|
|
f29af26326 | ||
|
|
b0c1010fbf | ||
|
|
f14a2cb9c5 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
|
||||
clientId: clientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
includeDeviceIdentity: false)
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "|")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1194,5 +1194,9 @@
|
||||
{
|
||||
"source": "cohere",
|
||||
"target": "cohere"
|
||||
},
|
||||
{
|
||||
"source": "Zalo ClawBot",
|
||||
"target": "Zalo ClawBot"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
95
docs/channels/zaloclawbot.md
Normal file
95
docs/channels/zaloclawbot.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
57
extensions/clickclack/src/http-client.test.ts
Normal file
57
extensions/clickclack/src/http-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
47
extensions/google-meet/src/google-api-errors.test.ts
Normal file
47
extensions/google-meet/src/google-api-errors.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"}`,
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"}`,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
34
extensions/xai/openclaw.plugin.test.ts
Normal file
34
extensions/xai/openclaw.plugin.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user