Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ccc1e542 | ||
|
|
62324eed0b | ||
|
|
0f6f7059d9 | ||
|
|
67f63ecd7e | ||
|
|
1420d113d8 | ||
|
|
5b4651d9ed | ||
|
|
5f22b68268 | ||
|
|
34590d2144 | ||
|
|
0c93b9b7bb | ||
|
|
b659db0a5b | ||
|
|
9fd9f4c896 | ||
|
|
005b831023 | ||
|
|
8013c4717c | ||
|
|
14e6b21b50 | ||
|
|
62354dff9c | ||
|
|
ccb30665f7 | ||
|
|
0fb2777c6d | ||
|
|
568b8ee96c | ||
|
|
fc60699f03 | ||
|
|
c1da78a271 | ||
|
|
0674f1fa3c | ||
|
|
3a0fd6be3c | ||
|
|
8b1bec11d0 | ||
|
|
f73dbdbaea | ||
|
|
05f49d2846 | ||
|
|
1d83389776 | ||
|
|
e0e8f11f70 | ||
|
|
36d88f6079 | ||
|
|
2c070952e1 | ||
|
|
ff9d069a33 |
No files matched your search
+28
-1
@@ -2,20 +2,47 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.18-1
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
|
||||
### Fixes
|
||||
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
|
||||
|
||||
## 2026.1.17-4
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
- Sessions: persist origin metadata across connectors for generic session explainers.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
@@ -214,9 +214,10 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
@@ -264,12 +265,14 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdbotNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
configRoot: configRoot,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
@@ -384,8 +387,11 @@ enum CommandResolver {
|
||||
let cliPath: String
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
static func connectionSettings(
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||
{
|
||||
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
|
||||
@@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
else { return nil }
|
||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||
let patchRaw = String(parts[2])
|
||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
||||
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||
let patchNumeric = Int(patchToken)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,27 +83,7 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$state.systemRunPolicy) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text("""
|
||||
Controls remote command execution on this Mac when it is paired as a node. \
|
||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||
"Never" disables `system.run`.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
SystemRunSettingsView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
|
||||
@@ -38,39 +38,14 @@ enum MacNodeConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
let raw = systemRun?["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
|
||||
root["systemRun"] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
systemRun["policy"] = policy.rawValue
|
||||
root["systemRun"] = systemRun
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func systemRunAllowlist() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
return systemRun?["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
var systemRun = self.systemRunSection(from: root)
|
||||
mutate(&systemRun)
|
||||
if systemRun.isEmpty {
|
||||
root.removeValue(forKey: "systemRun")
|
||||
} else {
|
||||
@@ -78,4 +53,147 @@ enum MacNodeConfigFile {
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
|
||||
let agents = systemRun["agents"] as? [String: Any]
|
||||
return agents?[agentId] as? [String: Any]
|
||||
}
|
||||
|
||||
private static func updateAgentSection(
|
||||
_ systemRun: inout [String: Any],
|
||||
agentId: String,
|
||||
mutate: (inout [String: Any]) -> Void)
|
||||
{
|
||||
var agents = systemRun["agents"] as? [String: Any] ?? [:]
|
||||
var entry = agents[agentId] as? [String: Any] ?? [:]
|
||||
mutate(&entry)
|
||||
if entry.isEmpty {
|
||||
agents.removeValue(forKey: agentId)
|
||||
} else {
|
||||
agents[agentId] = entry
|
||||
}
|
||||
if agents.isEmpty {
|
||||
systemRun.removeValue(forKey: "agents")
|
||||
} else {
|
||||
systemRun["agents"] = agents
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
let raw = agent["policy"] as? String
|
||||
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
|
||||
}
|
||||
let raw = systemRun["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
entry["policy"] = policy.rawValue
|
||||
}
|
||||
return
|
||||
}
|
||||
systemRun["policy"] = policy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
if let value = agent["autoAllowSkills"] as? Bool { return value }
|
||||
}
|
||||
return systemRun["autoAllowSkills"] as? Bool
|
||||
}
|
||||
|
||||
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
entry["autoAllowSkills"] = enabled
|
||||
}
|
||||
return
|
||||
}
|
||||
systemRun["autoAllowSkills"] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
let raw: [Any]? = {
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
return agent["allowlist"] as? [Any]
|
||||
}
|
||||
return systemRun["allowlist"] as? [Any]
|
||||
}()
|
||||
guard let raw else { return nil }
|
||||
|
||||
if raw.allSatisfy({ $0 is String }) {
|
||||
let legacy = raw.compactMap { $0 as? String }
|
||||
return legacy.compactMap { key in
|
||||
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return nil }
|
||||
return SystemRunAllowlistEntry(
|
||||
pattern: pattern,
|
||||
enabled: true,
|
||||
matchKind: .argv,
|
||||
source: .manual)
|
||||
}
|
||||
}
|
||||
|
||||
return raw.compactMap { item in
|
||||
guard let dict = item as? [String: Any] else { return nil }
|
||||
return SystemRunAllowlistEntry(dict: dict)
|
||||
}
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
|
||||
let cleaned = allowlist
|
||||
.map { $0 }
|
||||
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
let raw = cleaned.map { $0.asDict() }
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
if raw.isEmpty {
|
||||
entry.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
entry["allowlist"] = raw
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if raw.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAllowlistStrings() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
return systemRun["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,8 +428,32 @@ actor MacNodeRuntime {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
|
||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||
switch Self.systemRunPolicy() {
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
let policy = SystemRunPolicy.load(agentId: agentId)
|
||||
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
|
||||
let allowlistMatch = SystemRunAllowlistStore.match(
|
||||
command: command,
|
||||
resolution: resolution,
|
||||
entries: allowlistEntries)
|
||||
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||
let skillAllow: Bool
|
||||
if autoAllowSkills, let name = resolution?.executableName {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = bins.contains(name)
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
|
||||
let shouldPrompt: Bool = {
|
||||
if policy == .never { return false }
|
||||
if allowlistMatch != nil { return false }
|
||||
if skillAllow { return false }
|
||||
return policy == .ask
|
||||
}()
|
||||
|
||||
switch policy {
|
||||
case .never:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -438,16 +462,24 @@ actor MacNodeRuntime {
|
||||
case .always:
|
||||
break
|
||||
case .ask:
|
||||
if !wasAllowlisted {
|
||||
if shouldPrompt {
|
||||
let services = await self.mainActorServices()
|
||||
let decision = await services.confirmSystemRun(
|
||||
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
|
||||
command: SystemRunAllowlist.displayString(for: command),
|
||||
cwd: params.cwd)
|
||||
cwd: params.cwd,
|
||||
agentId: agentId,
|
||||
executablePath: resolution?.resolvedPath))
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
break
|
||||
case .allowAlways:
|
||||
SystemRunAllowlist.add(command)
|
||||
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
|
||||
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
|
||||
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
{
|
||||
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
|
||||
}
|
||||
case .deny:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -457,6 +489,14 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
SystemRunAllowlistStore.markUsed(
|
||||
entryId: match.id,
|
||||
command: command,
|
||||
resolvedPath: resolution?.resolvedPath,
|
||||
agentId: agentId)
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
|
||||
@@ -9,6 +9,13 @@ enum SystemRunDecision: Sendable {
|
||||
case deny
|
||||
}
|
||||
|
||||
struct SystemRunPromptContext: Sendable {
|
||||
let command: String
|
||||
let cwd: String?
|
||||
let agentId: String?
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
@@ -25,7 +32,7 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -67,16 +74,24 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
var details = "Clawdbot wants to run:\n\n\(context.command)"
|
||||
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedAgent.isEmpty {
|
||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||
}
|
||||
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPath.isEmpty {
|
||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||
}
|
||||
details += "\n\nThis runs on this Mac via node mode."
|
||||
alert.informativeText = details
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemRunAllowlistMatchKind: String {
|
||||
case glob
|
||||
case argv
|
||||
}
|
||||
|
||||
enum SystemRunAllowlistSource: String {
|
||||
case manual
|
||||
case skill
|
||||
}
|
||||
|
||||
struct SystemRunAllowlistEntry: Identifiable, Hashable {
|
||||
let id: String
|
||||
var pattern: String
|
||||
var enabled: Bool
|
||||
var matchKind: SystemRunAllowlistMatchKind
|
||||
var source: SystemRunAllowlistSource?
|
||||
var skillId: String?
|
||||
var lastUsedAt: Date?
|
||||
var lastUsedCommand: String?
|
||||
var lastUsedPath: String?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
pattern: String,
|
||||
enabled: Bool = true,
|
||||
matchKind: SystemRunAllowlistMatchKind = .glob,
|
||||
source: SystemRunAllowlistSource? = .manual,
|
||||
skillId: String? = nil,
|
||||
lastUsedAt: Date? = nil,
|
||||
lastUsedCommand: String? = nil,
|
||||
lastUsedPath: String? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.pattern = pattern
|
||||
self.enabled = enabled
|
||||
self.matchKind = matchKind
|
||||
self.source = source
|
||||
self.skillId = skillId
|
||||
self.lastUsedAt = lastUsedAt
|
||||
self.lastUsedCommand = lastUsedCommand
|
||||
self.lastUsedPath = lastUsedPath
|
||||
}
|
||||
|
||||
init?(dict: [String: Any]) {
|
||||
let id = dict["id"] as? String ?? UUID().uuidString
|
||||
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if pattern.isEmpty { return nil }
|
||||
let enabled = dict["enabled"] as? Bool ?? true
|
||||
let matchRaw = dict["matchKind"] as? String
|
||||
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
|
||||
let sourceRaw = dict["source"] as? String
|
||||
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
|
||||
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
|
||||
let lastUsedCommand = dict["lastUsedCommand"] as? String
|
||||
let lastUsedPath = dict["lastUsedPath"] as? String
|
||||
|
||||
self.init(
|
||||
id: id,
|
||||
pattern: pattern,
|
||||
enabled: enabled,
|
||||
matchKind: matchKind,
|
||||
source: source,
|
||||
skillId: skillId?.isEmpty == true ? nil : skillId,
|
||||
lastUsedAt: lastUsedAt,
|
||||
lastUsedCommand: lastUsedCommand,
|
||||
lastUsedPath: lastUsedPath)
|
||||
}
|
||||
|
||||
func asDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"id": self.id,
|
||||
"pattern": self.pattern,
|
||||
"enabled": self.enabled,
|
||||
"matchKind": self.matchKind.rawValue,
|
||||
]
|
||||
if let source = self.source { dict["source"] = source.rawValue }
|
||||
if let skillId = self.skillId { dict["skillId"] = skillId }
|
||||
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
|
||||
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
|
||||
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemRunCommandResolution: Sendable {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
|
||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||
let hasPathSeparator = expanded.contains("/")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
return CommandResolver.findExecutable(named: expanded)
|
||||
}()
|
||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunAllowlistStore {
|
||||
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
|
||||
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
|
||||
return entries
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
|
||||
var entries = self.load(agentId: agentId)
|
||||
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
|
||||
entries.append(entry)
|
||||
self.save(entries, agentId: agentId)
|
||||
return entry
|
||||
}
|
||||
|
||||
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
|
||||
var entries = self.load(agentId: agentId)
|
||||
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||
entries[index] = entry
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func remove(entryId: String, agentId: String?) {
|
||||
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
|
||||
var entries = self.load(agentId: agentId)
|
||||
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
|
||||
entries[index].lastUsedAt = Date()
|
||||
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
|
||||
entries[index].lastUsedPath = resolvedPath
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func match(
|
||||
command: [String],
|
||||
resolution: SystemRunCommandResolution?,
|
||||
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
|
||||
{
|
||||
guard !entries.isEmpty else { return nil }
|
||||
let argvKey = SystemRunAllowlist.legacyKey(for: command)
|
||||
let resolvedPath = resolution?.resolvedPath
|
||||
let executableName = resolution?.executableName
|
||||
let rawExecutable = resolution?.rawExecutable
|
||||
|
||||
for entry in entries {
|
||||
guard entry.enabled else { continue }
|
||||
switch entry.matchKind {
|
||||
case .argv:
|
||||
if argvKey == entry.pattern { return entry }
|
||||
case .glob:
|
||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if pattern.isEmpty { continue }
|
||||
let hasPath = pattern.contains("/") || pattern.contains("~")
|
||||
if hasPath {
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
|
||||
return entry
|
||||
}
|
||||
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunGlob {
|
||||
static func matches(pattern rawPattern: String, target: String) -> Bool {
|
||||
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||
guard let regex = self.regex(for: expanded) else { return false }
|
||||
let range = NSRange(location: 0, length: target.utf16.count)
|
||||
return regex.firstMatch(in: target, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||
var regex = "^"
|
||||
var idx = pattern.startIndex
|
||||
while idx < pattern.endIndex {
|
||||
let ch = pattern[idx]
|
||||
if ch == "*" {
|
||||
let next = pattern.index(after: idx)
|
||||
if next < pattern.endIndex, pattern[next] == "*" {
|
||||
regex += ".*"
|
||||
idx = pattern.index(after: next)
|
||||
} else {
|
||||
regex += "[^/]*"
|
||||
idx = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "?" {
|
||||
regex += "."
|
||||
idx = pattern.index(after: idx)
|
||||
continue
|
||||
}
|
||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||
idx = pattern.index(after: idx)
|
||||
}
|
||||
regex += "$"
|
||||
return try? NSRegularExpression(pattern: regex)
|
||||
}
|
||||
}
|
||||
|
||||
actor SkillBinsCache {
|
||||
static let shared = SkillBinsCache()
|
||||
|
||||
private var bins: Set<String> = []
|
||||
private var lastRefresh: Date?
|
||||
private let refreshInterval: TimeInterval = 90
|
||||
|
||||
func currentBins(force: Bool = false) async -> Set<String> {
|
||||
if force || self.isStale() {
|
||||
await self.refresh()
|
||||
}
|
||||
return self.bins
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
var next = Set<String>()
|
||||
for skill in report.skills {
|
||||
for bin in skill.requirements.bins {
|
||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||
}
|
||||
}
|
||||
self.bins = next
|
||||
self.lastRefresh = Date()
|
||||
} catch {
|
||||
if self.lastRefresh == nil {
|
||||
self.bins = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isStale() -> Bool {
|
||||
guard let lastRefresh else { return true }
|
||||
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
|
||||
return policy
|
||||
}
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||
return policy
|
||||
}
|
||||
@@ -40,7 +43,7 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum SystemRunAllowlist {
|
||||
static func key(for argv: [String]) -> String {
|
||||
static func legacyKey(for argv: [String]) -> String {
|
||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let data = try? JSONEncoder().encode(trimmed),
|
||||
@@ -62,28 +65,14 @@ enum SystemRunAllowlist {
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
|
||||
return Set(allowlist)
|
||||
}
|
||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
|
||||
return Set(legacy)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||
let key = key(for: argv)
|
||||
return self.load(from: defaults).contains(key)
|
||||
}
|
||||
|
||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||
let key = key(for: argv)
|
||||
guard !key.isEmpty else { return }
|
||||
var allowlist = self.load(from: defaults)
|
||||
if allowlist.insert(key).inserted {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SystemRunSettingsView: View {
|
||||
@State private var model = SystemRunSettingsModel()
|
||||
@State private var tab: SystemRunSettingsTab = .policy
|
||||
@State private var newPattern: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
Spacer(minLength: 0)
|
||||
if self.model.agentIds.count > 1 {
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentIds, id: \.self) { id in
|
||||
Text(id).tag(id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
ForEach(SystemRunSettingsTab.allCases) { tab in
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 280)
|
||||
|
||||
if self.tab == .policy {
|
||||
self.policyView
|
||||
} else {
|
||||
self.allowlistView
|
||||
}
|
||||
}
|
||||
.task { await self.model.refresh() }
|
||||
.onChange(of: self.tab) { _, _ in
|
||||
Task { await self.model.refreshSkillBins() }
|
||||
}
|
||||
}
|
||||
|
||||
private var policyView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("", selection: Binding(
|
||||
get: { self.model.policy },
|
||||
set: { self.model.setPolicy($0) }))
|
||||
{
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text("Controls remote command execution on this Mac when it is paired as a node. \"Always Ask\" prompts on each command; \"Always Allow\" runs without prompts; \"Never\" disables system.run.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var allowlistView: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Auto-allow skill CLIs", isOn: Binding(
|
||||
get: { self.model.autoAllowSkills },
|
||||
set: { self.model.setAutoAllowSkills($0) }))
|
||||
|
||||
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
|
||||
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
|
||||
SystemRunAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0) }),
|
||||
onRemove: { self.model.removeEntry($0.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
|
||||
case policy
|
||||
case allowlist
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .policy: "Policy"
|
||||
case .allowlist: "Allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemRunAllowlistRow: View {
|
||||
@Binding var entry: SystemRunAllowlistEntry
|
||||
let onRemove: (SystemRunAllowlistEntry) -> Void
|
||||
@State private var draftPattern: String = ""
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: self.$entry.enabled)
|
||||
.labelsHidden()
|
||||
|
||||
TextField("Pattern", text: self.patternBinding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if self.entry.matchKind == .argv {
|
||||
Text("Legacy")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
self.onRemove(self.entry)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
if let lastUsedAt = self.entry.lastUsedAt {
|
||||
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last used: \(lastUsedCommand)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.draftPattern = self.entry.pattern
|
||||
}
|
||||
}
|
||||
|
||||
private var patternBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
|
||||
set: { newValue in
|
||||
self.draftPattern = newValue
|
||||
self.entry.pattern = newValue
|
||||
if self.entry.matchKind == .argv {
|
||||
self.entry.matchKind = .glob
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SystemRunSettingsModel {
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
var policy: SystemRunPolicy = .ask
|
||||
var autoAllowSkills = false
|
||||
var entries: [SystemRunAllowlistEntry] = []
|
||||
var skillBins: [String] = []
|
||||
|
||||
func refresh() async {
|
||||
await self.refreshAgents()
|
||||
self.loadSettings(for: self.selectedAgentId)
|
||||
await self.refreshSkillBins()
|
||||
}
|
||||
|
||||
func refreshAgents() async {
|
||||
let root = await ConfigStore.load()
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let list = agents?["list"] as? [[String: Any]] ?? []
|
||||
var ids: [String] = []
|
||||
var seen = Set<String>()
|
||||
var defaultId: String?
|
||||
for entry in list {
|
||||
guard let raw = entry["id"] as? String else { continue }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
if !seen.insert(trimmed).inserted { continue }
|
||||
ids.append(trimmed)
|
||||
if (entry["default"] as? Bool) == true, defaultId == nil {
|
||||
defaultId = trimmed
|
||||
}
|
||||
}
|
||||
if ids.isEmpty {
|
||||
ids = ["main"]
|
||||
defaultId = "main"
|
||||
} else if defaultId == nil {
|
||||
defaultId = ids.first
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
}
|
||||
|
||||
func selectAgent(_ id: String) {
|
||||
self.selectedAgentId = id
|
||||
self.loadSettings(for: id)
|
||||
Task { await self.refreshSkillBins() }
|
||||
}
|
||||
|
||||
func loadSettings(for agentId: String) {
|
||||
self.policy = SystemRunPolicy.load(agentId: agentId)
|
||||
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||
}
|
||||
|
||||
func setPolicy(_ policy: SystemRunPolicy) {
|
||||
self.policy = policy
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
|
||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||
AppStateStore.shared.systemRunPolicy = policy
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoAllowSkills(_ enabled: Bool) {
|
||||
self.autoAllowSkills = enabled
|
||||
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
|
||||
self.entries.append(entry)
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: SystemRunAllowlistEntry) {
|
||||
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||
self.entries[index] = entry
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func removeEntry(_ id: String) {
|
||||
self.entries.removeAll { $0.id == id }
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func refreshSkillBins(force: Bool = false) async {
|
||||
guard self.autoAllowSkills else {
|
||||
self.skillBins = []
|
||||
return
|
||||
}
|
||||
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||
self.skillBins = bins.sorted()
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import Testing
|
||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||
try self.makeExec(at: clawdbotPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||
|
||||
#expect(cmd.count >= 3)
|
||||
@@ -75,7 +76,7 @@ import Testing
|
||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||
try self.makeExec(at: pnpmPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||
}
|
||||
@@ -93,7 +94,8 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "health",
|
||||
extraArgs: ["--json", "--timeout", "5"],
|
||||
defaults: defaults)
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||
@@ -114,7 +116,11 @@ import Testing
|
||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "status",
|
||||
extraArgs: ["--json"],
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
#expect(cmd.contains("clawd@example.com"))
|
||||
|
||||
@@ -75,7 +75,7 @@ struct MacNodeRuntimeTests {
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
||||
.allowOnce
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
struct SystemRunAllowlistTests {
|
||||
@Test func matchUsesResolvedPath() {
|
||||
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
|
||||
let resolution = SystemRunCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["rg"],
|
||||
resolution: resolution,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
|
||||
@Test func matchUsesBasenameForSimplePattern() {
|
||||
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
|
||||
let resolution = SystemRunCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["rg"],
|
||||
resolution: resolution,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
|
||||
@Test func matchUsesLegacyArgvKey() {
|
||||
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
|
||||
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["echo", "hi"],
|
||||
resolution: nil,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import Testing
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||
#expect(settings.mode == .remote)
|
||||
#expect(settings.target == "alice@example.com")
|
||||
}
|
||||
|
||||
@@ -24,19 +24,22 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
public var needsScreenRecording: Bool?
|
||||
public var agentId: String?
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
needsScreenRecording: Bool? = nil)
|
||||
needsScreenRecording: Bool? = nil,
|
||||
agentId: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
self.needsScreenRecording = needsScreenRecording
|
||||
self.agentId = agentId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Brave Search API setup for web_search"
|
||||
read_when:
|
||||
- You want to use Brave Search for web_search
|
||||
- You need a BRAVE_API_KEY or plan details
|
||||
---
|
||||
|
||||
# Brave Search API
|
||||
|
||||
Clawdbot uses Brave Search as the default provider for `web_search`.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||
3) Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
@@ -175,7 +175,7 @@ Notes:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread id explicitly.
|
||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
|
||||
```
|
||||
|
||||
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
||||
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
|
||||
|
||||
To allow all groups with always-respond:
|
||||
```json5
|
||||
|
||||
@@ -8,9 +8,11 @@ read_when:
|
||||
# `clawdbot memory`
|
||||
|
||||
Memory search tools (semantic memory status/index/search).
|
||||
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
|
||||
|
||||
Related:
|
||||
- Memory concept: [Memory](/concepts/memory)
|
||||
- Plugins: [Plugins](/plugins)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -18,6 +20,11 @@ Related:
|
||||
clawdbot memory status
|
||||
clawdbot memory status --deep
|
||||
clawdbot memory status --deep --index
|
||||
clawdbot memory status --deep --index --verbose
|
||||
clawdbot memory index
|
||||
clawdbot memory search "release checklist"
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||
+61
-4
@@ -9,6 +9,9 @@ read_when:
|
||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
||||
source of truth; the model only "remembers" what gets written to disk.
|
||||
|
||||
Memory search tools are provided by the active memory plugin (default:
|
||||
`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`.
|
||||
|
||||
## Memory files (Markdown)
|
||||
|
||||
The default workspace layout uses two memory layers:
|
||||
@@ -111,8 +114,16 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
|
||||
Batch indexing (OpenAI only):
|
||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||
|
||||
Why OpenAI batch is fast + cheap:
|
||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
|
||||
- See the OpenAI Batch API docs and pricing for details:
|
||||
- https://platform.openai.com/docs/api-reference/batch
|
||||
- https://platform.openai.com/pricing
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
@@ -123,7 +134,7 @@ agents: {
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
remote: {
|
||||
batch: { enabled: false }
|
||||
batch: { enabled: true, concurrency: 2 }
|
||||
},
|
||||
sync: { watch: true }
|
||||
}
|
||||
@@ -149,9 +160,55 @@ Local mode:
|
||||
### What gets indexed (and when)
|
||||
|
||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
- Model changes: the index stores the embedding **model + provider + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
|
||||
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||
|
||||
### Hybrid search (BM25 + vector)
|
||||
|
||||
When enabled, Clawdbot combines:
|
||||
- **Vector similarity** (semantic match, wording can differ)
|
||||
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
|
||||
|
||||
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
query: {
|
||||
hybrid: {
|
||||
enabled: true,
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
candidateMultiplier: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding cache
|
||||
|
||||
Clawdbot can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
cache: {
|
||||
enabled: true,
|
||||
maxEntries: 50000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session memory search (experimental)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
|
||||
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||
|
||||
## Session pruning
|
||||
@@ -113,3 +114,11 @@ Send these as standalone messages so they register.
|
||||
## Tips
|
||||
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
||||
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
|
||||
|
||||
## Session origin metadata
|
||||
Each session entry records where it came from (best-effort) in `origin`:
|
||||
- `label`: human label (resolved from conversation label + group subject/channel)
|
||||
- `provider`: normalized channel id (including extensions)
|
||||
- `from`/`to`: raw routing ids from the inbound envelope
|
||||
- `accountId`: provider account id (when multi-account)
|
||||
- `threadId`: thread/topic id when the channel supports it
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
summary: "Perplexity Sonar setup for web_search"
|
||||
read_when:
|
||||
- You want to use Perplexity Sonar for web search
|
||||
- You need PERPLEXITY_API_KEY or OpenRouter setup
|
||||
---
|
||||
|
||||
# Perplexity Sonar
|
||||
|
||||
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
|
||||
through Perplexity’s direct API or via OpenRouter.
|
||||
|
||||
## API options
|
||||
|
||||
### Perplexity (direct)
|
||||
|
||||
- Base URL: https://api.perplexity.ai
|
||||
- Environment variable: `PERPLEXITY_API_KEY`
|
||||
|
||||
### OpenRouter (alternative)
|
||||
|
||||
- Base URL: https://openrouter.ai/api/v1
|
||||
- Environment variable: `OPENROUTER_API_KEY`
|
||||
- Supports prepaid/crypto credits.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Switching from Brave
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
|
||||
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
|
||||
to disambiguate.
|
||||
|
||||
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
|
||||
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
|
||||
|
||||
## Models
|
||||
|
||||
- `perplexity/sonar` — fast Q&A with web search
|
||||
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
|
||||
- `perplexity/sonar-reasoning-pro` — deep research
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
@@ -36,6 +36,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
## Available plugins (official)
|
||||
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
@@ -137,6 +138,24 @@ Fields:
|
||||
|
||||
Config changes **require a gateway restart**.
|
||||
|
||||
## Plugin slots (exclusive categories)
|
||||
|
||||
Some plugin categories are **exclusive** (only one active at a time). Use
|
||||
`plugins.slots` to select which plugin owns the slot:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memory-core" // or "none" to disable memory plugins
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If multiple plugins declare `kind: "memory"`, only the selected one loads. Others
|
||||
are disabled with diagnostics.
|
||||
|
||||
## Control UI (schema + labels)
|
||||
|
||||
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
summary: "Plan: one clean plugin SDK + runtime for all messaging connectors"
|
||||
read_when:
|
||||
- Defining or refactoring the plugin architecture
|
||||
- Migrating channel connectors to the plugin SDK/runtime
|
||||
---
|
||||
# Plugin SDK + Runtime Refactor Plan
|
||||
|
||||
Goal: every messaging connector is a plugin (bundled or external) using one stable API.
|
||||
No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime.
|
||||
|
||||
## Why now
|
||||
- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers.
|
||||
- This makes upgrades brittle and blocks a clean external plugin surface.
|
||||
|
||||
## Target architecture (two layers)
|
||||
|
||||
### 1) Plugin SDK (compile-time, stable, publishable)
|
||||
Scope: types, helpers, and config utilities. No runtime state, no side effects.
|
||||
|
||||
Contents (examples):
|
||||
- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`.
|
||||
- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`,
|
||||
`applyAccountNameToChannelSection`.
|
||||
- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`.
|
||||
- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types.
|
||||
- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`.
|
||||
- Docs link helper: `formatDocsLink`.
|
||||
|
||||
Delivery:
|
||||
- Publish as `@clawdbot/plugin-sdk` (or export from core under `clawdbot/plugin-sdk`).
|
||||
- Semver with explicit stability guarantees.
|
||||
|
||||
### 2) Plugin Runtime (execution surface, injected)
|
||||
Scope: everything that touches core runtime behavior.
|
||||
Accessed via `ClawdbotPluginApi.runtime` so plugins never import `src/**`.
|
||||
|
||||
Proposed surface (minimal but complete):
|
||||
```ts
|
||||
export type PluginRuntime = {
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText(text: string, limit: number): string[];
|
||||
resolveTextChunkLimit(cfg: ClawdbotConfig, channel: string, accountId?: string): number;
|
||||
hasControlCommand(text: string, cfg: ClawdbotConfig): boolean;
|
||||
};
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) =>
|
||||
void | Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}): Promise<void>;
|
||||
createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows
|
||||
};
|
||||
routing: {
|
||||
resolveAgentRoute(params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}): { sessionKey: string; accountId: string };
|
||||
};
|
||||
pairing: {
|
||||
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
|
||||
readAllowFromStore(channel: string): Promise<string[]>;
|
||||
upsertPairingRequest(params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}): Promise<{ code: string; created: boolean }>;
|
||||
};
|
||||
media: {
|
||||
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer(
|
||||
buffer: Uint8Array,
|
||||
contentType: string | undefined,
|
||||
direction: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
): Promise<{ path: string; contentType?: string }>;
|
||||
};
|
||||
mentions: {
|
||||
buildMentionRegexes(cfg: ClawdbotConfig, agentId?: string): RegExp[];
|
||||
matchesMentionPatterns(text: string, regexes: RegExp[]): boolean;
|
||||
};
|
||||
groups: {
|
||||
resolveGroupPolicy(cfg: ClawdbotConfig, channel: string, accountId: string, groupId: string): {
|
||||
allowlistEnabled: boolean;
|
||||
allowed: boolean;
|
||||
groupConfig?: unknown;
|
||||
defaultConfig?: unknown;
|
||||
};
|
||||
resolveRequireMention(
|
||||
cfg: ClawdbotConfig,
|
||||
channel: string,
|
||||
accountId: string,
|
||||
groupId: string,
|
||||
override?: boolean,
|
||||
): boolean;
|
||||
};
|
||||
debounce: {
|
||||
createInboundDebouncer<T>(opts: {
|
||||
debounceMs: number;
|
||||
buildKey: (v: T) => string | null;
|
||||
shouldDebounce: (v: T) => boolean;
|
||||
onFlush: (entries: T[]) => Promise<void>;
|
||||
onError?: (err: unknown) => void;
|
||||
}): { push: (v: T) => void; flush: () => Promise<void> };
|
||||
resolveInboundDebounceMs(cfg: ClawdbotConfig, channel: string): number;
|
||||
};
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}): boolean;
|
||||
};
|
||||
};
|
||||
logging: {
|
||||
shouldLogVerbose(): boolean;
|
||||
getChildLogger(name: string): PluginLogger;
|
||||
};
|
||||
state: {
|
||||
resolveStateDir(cfg: ClawdbotConfig): string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Runtime is the only way to access core behavior.
|
||||
- SDK is intentionally small and stable.
|
||||
- Each runtime method maps to an existing core implementation (no duplication).
|
||||
|
||||
## Migration plan (phased, safe)
|
||||
|
||||
### Phase 0: scaffolding
|
||||
- Introduce `@clawdbot/plugin-sdk`.
|
||||
- Add `api.runtime` to `ClawdbotPluginApi` with the surface above.
|
||||
- Maintain existing imports during a transition window (deprecation warnings).
|
||||
|
||||
### Phase 1: bridge cleanup (low risk)
|
||||
- Replace per-extension `core-bridge.ts` with `api.runtime`.
|
||||
- Migrate BlueBubbles, Zalo, Zalo Personal first (already close).
|
||||
- Remove duplicated bridge code.
|
||||
|
||||
### Phase 2: light direct-import plugins
|
||||
- Migrate Matrix to SDK + runtime.
|
||||
- Validate onboarding, directory, group mention logic.
|
||||
|
||||
### Phase 3: heavy direct-import plugins
|
||||
- Migrate MS Teams (largest set of runtime helpers).
|
||||
- Ensure reply/typing semantics match current behavior.
|
||||
|
||||
### Phase 4: iMessage pluginization
|
||||
- Move iMessage into `extensions/imessage`.
|
||||
- Replace direct core calls with `api.runtime`.
|
||||
- Keep config keys, CLI behavior, and docs intact.
|
||||
|
||||
### Phase 5: enforcement
|
||||
- Add lint rule / CI check: no `extensions/**` imports from `src/**`.
|
||||
- Add plugin SDK/version compatibility checks (runtime + SDK semver).
|
||||
|
||||
## Compatibility and versioning
|
||||
- SDK: semver, published, documented changes.
|
||||
- Runtime: versioned per core release. Add `api.runtime.version`.
|
||||
- Plugins declare a required runtime range (e.g., `clawdbotRuntime: ">=2026.2.0"`).
|
||||
|
||||
## Testing strategy
|
||||
- Adapter-level unit tests (runtime functions exercised with real core implementation).
|
||||
- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating).
|
||||
- A single end-to-end plugin sample used in CI (install + run + smoke).
|
||||
|
||||
## Open questions
|
||||
- Where to host SDK types: separate package or core export?
|
||||
- Runtime type distribution: in SDK (types only) or in core?
|
||||
- How to expose docs links for bundled vs external plugins?
|
||||
- Do we allow limited direct core imports for in-repo plugins during transition?
|
||||
|
||||
## Success criteria
|
||||
- All channel connectors are plugins using SDK + runtime.
|
||||
- No `extensions/**` imports from `src/**`.
|
||||
- New connector templates depend only on SDK + runtime.
|
||||
- External plugins can be developed and updated without core source access.
|
||||
|
||||
Related docs: [Plugins](/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
|
||||
read_when:
|
||||
- Configuring exec approvals or allowlists
|
||||
- Implementing exec approval UX in the macOS app
|
||||
- Reviewing sandbox escape prompts and implications
|
||||
---
|
||||
|
||||
# Exec approvals (macOS app)
|
||||
|
||||
Exec approvals are the **macOS companion app** guardrail for running host
|
||||
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
|
||||
approval layer: the agent asks, the app decides, and the command runs (or not).
|
||||
This is **in addition** to tool policy and elevated gating; all of those checks
|
||||
must pass before a command can run.
|
||||
|
||||
If you are **not** running the macOS companion app, exec approvals are
|
||||
unavailable and `system.run` requests will be rejected with a message that a
|
||||
companion app is required.
|
||||
|
||||
## Settings
|
||||
|
||||
In the macOS app, each agent has an **Exec approvals** setting:
|
||||
|
||||
- **Deny**: block all host exec requests from the agent.
|
||||
- **Always ask**: show a confirmation dialog for each host exec request.
|
||||
- **Always allow**: run host exec requests without prompting.
|
||||
|
||||
Optional toggles:
|
||||
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
|
||||
treated as allowlisted (see below).
|
||||
|
||||
## Allowlist (per agent)
|
||||
|
||||
The allowlist is **per agent**. If multiple agents exist, you can switch which
|
||||
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
|
||||
|
||||
Examples:
|
||||
- `~/Projects/**/bin/bird`
|
||||
- `~/.local/bin/*`
|
||||
- `/opt/homebrew/bin/rg`
|
||||
|
||||
Each allowlist entry tracks:
|
||||
- **last used** (timestamp)
|
||||
- **last used command**
|
||||
- **last used path** (resolved absolute path)
|
||||
- **last seen metadata** (hash/version/mtime when available)
|
||||
|
||||
## How matching works
|
||||
|
||||
1) Parse the command to determine the executable (first token).
|
||||
2) Resolve the executable to an absolute path using `PATH`.
|
||||
3) Match against denylist (if present) → **deny**.
|
||||
4) Match against allowlist → **allow**.
|
||||
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
|
||||
|
||||
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
|
||||
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
|
||||
- the resolved path matches, and
|
||||
- the binary hash/version matches the last approved record (if tracked).
|
||||
|
||||
If the binary changes (new hash/version), the command falls back to **Ask** so
|
||||
the user can re-approve.
|
||||
|
||||
## Approval flow
|
||||
|
||||
When the policy is **Always ask** (or when a binary has changed), the macOS app
|
||||
shows a confirmation dialog. The dialog should include:
|
||||
- command + args
|
||||
- cwd
|
||||
- environment overrides (diff)
|
||||
- policy + rule that matched (if any)
|
||||
|
||||
Actions:
|
||||
- **Allow once** → run now
|
||||
- **Always allow** → add/update allowlist entry + run
|
||||
- **Deny** → block
|
||||
|
||||
When approved, the command runs **in the background** and the agent receives
|
||||
system events as it starts and completes.
|
||||
|
||||
## System events
|
||||
|
||||
The agent receives system messages for observability and recovery:
|
||||
|
||||
- `exec.started` — command accepted and launched
|
||||
- `exec.finished` — command completed (exit code + output)
|
||||
- `exec.denied` — command blocked (policy or denylist)
|
||||
|
||||
These are **system messages**; no extra agent tool call is required to resume.
|
||||
|
||||
## Implications
|
||||
|
||||
- **Always allow** is powerful: the agent can run any host command without a
|
||||
prompt. Prefer allowlisting trusted CLIs instead.
|
||||
- **Ask** keeps you in the loop while still allowing fast approvals.
|
||||
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
|
||||
|
||||
## Storage
|
||||
|
||||
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
|
||||
is a good fit). The Markdown docs describe behavior; they are not the storage
|
||||
mechanism.
|
||||
|
||||
Related:
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Elevated mode](/tools/elevated)
|
||||
- [Skills](/tools/skills)
|
||||
@@ -26,6 +26,11 @@ Note: `elevated` is ignored when sandboxing is off (exec already runs on the hos
|
||||
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
|
||||
## Exec approvals (macOS app)
|
||||
|
||||
Sandboxed agents can require per-request approval before `exec` runs on the host.
|
||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
|
||||
@@ -177,6 +177,7 @@ Notes:
|
||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
|
||||
|
||||
### `process`
|
||||
Manage background exec sessions.
|
||||
|
||||
+101
-6
@@ -1,15 +1,16 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API)"
|
||||
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
- You want to use Perplexity Sonar for web search
|
||||
---
|
||||
|
||||
# Web tools
|
||||
|
||||
Clawdbot ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Brave Search API queries (fast, structured results).
|
||||
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (via OpenRouter).
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -17,13 +18,56 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
|
||||
## How it works
|
||||
|
||||
- `web_search` calls Brave’s Search API and returns structured results
|
||||
(title, URL, snippet). No browser is involved.
|
||||
- `web_search` calls your configured provider and returns results.
|
||||
- **Brave** (default): returns structured results (title, URL, snippet).
|
||||
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
|----------|------|------|---------|
|
||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires OpenRouter credits | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||
|
||||
Set the provider in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave" // or "perplexity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example: switch to Perplexity Sonar (direct API):
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
@@ -42,14 +86,65 @@ current limits and pricing.
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## Using Perplexity (direct or via OpenRouter)
|
||||
|
||||
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
|
||||
answers with citations. You can use them via OpenRouter (no credit card required - supports
|
||||
crypto/prepaid).
|
||||
|
||||
### Getting an OpenRouter API key
|
||||
|
||||
1) Create an account at https://openrouter.ai/
|
||||
2) Add credits (supports crypto, prepaid, or credit card)
|
||||
3) Generate an API key in your account settings
|
||||
|
||||
### Setting up Perplexity search
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
|
||||
apiKey: "sk-or-v1-...",
|
||||
// Base URL (defaults to OpenRouter)
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
// Model (defaults to perplexity/sonar-pro)
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env`.
|
||||
|
||||
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
|
||||
Clawdbot defaults to the direct Perplexity endpoint (`https://api.perplexity.ai`).
|
||||
|
||||
### Available Perplexity models
|
||||
|
||||
| Model | Description | Best for |
|
||||
|-------|-------------|----------|
|
||||
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
|
||||
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
||||
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
||||
|
||||
## web_search
|
||||
|
||||
Search the web with Brave’s API.
|
||||
Search the web using your configured provider.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelToolSend,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { handleMatrixAction } from "./tool-actions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelToolSend,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../../src/channels/plugins/config-helpers.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
type ChannelPlugin,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelDirectoryEntry } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelGroupContext } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../../src/routing/session-key.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
|
||||
@@ -3,8 +3,7 @@ import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runCommandWithTimeout } from "../../../../src/process/exec.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AllowlistMatch } from "clawdbot/plugin-sdk";
|
||||
|
||||
function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
@@ -10,11 +12,9 @@ function normalizeMatrixUser(raw?: string | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export type MatrixAllowListMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart";
|
||||
};
|
||||
export type MatrixAllowListMatch = AllowlistMatch<
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart"
|
||||
>;
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
||||
|
||||
import { danger, logVerbose } from "../../../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../../../src/runtime.js";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
|
||||
@@ -2,31 +2,38 @@ import type { MatrixEvent, Room } from "matrix-js-sdk";
|
||||
import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../../../../src/agents/identity.js";
|
||||
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../../src/auto-reply/chunk.js";
|
||||
import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js";
|
||||
import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js";
|
||||
import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js";
|
||||
import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
chunkMarkdownText,
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
dispatchReplyFromConfig,
|
||||
enqueueSystemEvent,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
getChildLogger,
|
||||
hasControlCommand,
|
||||
loadConfig,
|
||||
logVerbose,
|
||||
mergeAllowlist,
|
||||
matchesMentionPatterns,
|
||||
} from "../../../../../src/auto-reply/reply/mentions.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../../src/channels/command-gating.js";
|
||||
import { loadConfig } from "../../../../../src/config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
|
||||
import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js";
|
||||
import { getChildLogger } from "../../../../../src/logging.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveStorePath,
|
||||
resolveTextChunkLimit,
|
||||
shouldHandleTextCommands,
|
||||
shouldLogVerbose,
|
||||
summarizeMapping,
|
||||
updateLastRoute,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../../../../src/pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../../../../../src/runtime.js";
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||
import { setActiveMatrixClient } from "../active-client.js";
|
||||
import {
|
||||
@@ -46,7 +53,6 @@ import {
|
||||
resolveMatrixAllowListMatches,
|
||||
normalizeAllowListLower,
|
||||
} from "./allowlist.js";
|
||||
import { mergeAllowlist, summarizeMapping } from "../../../../../src/channels/allowlists/resolve-utils.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
@@ -326,9 +332,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
|
||||
allowMatch.matchSource ?? "none"
|
||||
}`;
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
@@ -369,14 +373,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
|
||||
if (isRoom && roomConfigInfo.config?.users?.length) {
|
||||
const userAllowed = resolveMatrixAllowListMatches({
|
||||
const userMatch = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeAllowListLower(roomConfigInfo.config.users),
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userAllowed) {
|
||||
if (!userMatch.allowed) {
|
||||
logVerbose(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta})`,
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +499,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
@@ -525,10 +531,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logger.warn(
|
||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||
"failed updating session meta",
|
||||
);
|
||||
});
|
||||
|
||||
if (isDirectMessage) {
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { saveMediaBuffer } from "../../../../../src/media/store.js";
|
||||
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
|
||||
async function fetchMatrixMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { matchesMentionPatterns } from "../../../../../src/auto-reply/reply/mentions.js";
|
||||
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
|
||||
|
||||
export function resolveMentions(params: {
|
||||
content: RoomMessageEventContent;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
|
||||
import { danger, logVerbose } from "../../../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../../../src/runtime.js";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
danger,
|
||||
logVerbose,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "../../../../../src/channels/plugins/channel-config.js";
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MatrixRoomConfigResolved = {
|
||||
allowed: boolean;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
|
||||
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
|
||||
import type { PollInput } from "../../../../src/polls.js";
|
||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||
|
||||
export const M_POLL_START = "m.poll.start" as const;
|
||||
export const M_POLL_RESPONSE = "m.poll.response" as const;
|
||||
|
||||
@@ -18,20 +18,18 @@ vi.mock("matrix-js-sdk", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/config/config.js", () => ({
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/web/media.js", () => ({
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/media/image-ops.js", () => ({
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||
resizeToJpeg: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,13 +5,17 @@ import type {
|
||||
ReactionEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
|
||||
import { mediaKindFromMime } from "../../../../src/media/constants.js";
|
||||
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
|
||||
import type { PollInput } from "../../../../src/polls.js";
|
||||
import { loadWebMedia } from "../../../../src/web/media.js";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
getImageMetadata,
|
||||
isVoiceCompatibleAudio,
|
||||
loadConfig,
|
||||
loadWebMedia,
|
||||
mediaKindFromMime,
|
||||
type PollInput,
|
||||
resolveTextChunkLimit,
|
||||
resizeToJpeg,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
promptChannelAccessConfig,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
||||
|
||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkMarkdownText,
|
||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMatrixRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMatrixRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Matrix runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "../../../src/agents/tools/common.js";
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
||||
const reactionActions = new Set(["react", "reactions"]);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
registerMemoryCli,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
description: "File-backed memory search tools and CLI",
|
||||
kind: "memory",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
if (!memorySearchTool || !memoryGetTool) return null;
|
||||
return [memorySearchTool, memoryGetTool];
|
||||
},
|
||||
{ names: ["memory_search", "memory_get"] },
|
||||
);
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerMemoryCli(program);
|
||||
},
|
||||
{ commands: ["memory"] },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default memoryCorePlugin;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
|
||||
|
||||
@@ -6,19 +6,8 @@ const saveMediaBufferMock = vi.fn(async () => ({
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
const modulePaths = vi.hoisted(() => {
|
||||
const downloadModuleUrl = new URL("./attachments/download.js", import.meta.url);
|
||||
return {
|
||||
mimeModulePath: new URL("../../../../src/media/mime.js", downloadModuleUrl).pathname,
|
||||
storeModulePath: new URL("../../../../src/media/store.js", downloadModuleUrl).pathname,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(modulePaths.mimeModulePath, () => ({
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
detectMime: (...args: unknown[]) => detectMimeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock(modulePaths.storeModulePath, () => ({
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { detectMime } from "../../../../src/media/mime.js";
|
||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { detectMime } from "../../../../src/media/mime.js";
|
||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./channel.js";
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
import { MSTeamsConfigSchema } from "../../../src/config/zod-schema.providers-core.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
MSTeamsConfigSchema,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelDirectoryEntry } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||
import { SILENT_REPLY_TOKEN } from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
type MSTeamsAdapter,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import type { MSTeamsReplyStyle } from "../../../src/config/types.js";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
isSilentReplyText,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
SILENT_REPLY_TOKEN,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { classifyMSTeamsSendError } from "./errors.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/types.js";
|
||||
import { danger } from "../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { danger } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
|
||||
import { formatAgentEnvelope } from "../../../../src/auto-reply/envelope.js";
|
||||
import {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
} from "../../../../src/auto-reply/inbound-debounce.js";
|
||||
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
|
||||
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
createInboundDebouncer,
|
||||
danger,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntry,
|
||||
type HistoryEntry,
|
||||
} from "../../../../src/auto-reply/reply/history.js";
|
||||
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
|
||||
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
recordPendingHistoryEntry,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveInboundDebounceMs,
|
||||
resolveMentionGating,
|
||||
resolveStorePath,
|
||||
dispatchReplyFromConfig,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
hasControlCommand,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../../../src/pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
type HistoryEntry,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsAllowlistMatch,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
resolveMSTeamsRouteConfig,
|
||||
} from "../policy.js";
|
||||
@@ -141,19 +142,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const effectiveAllowFrom = [
|
||||
...allowFrom.map((v) => String(v).toLowerCase()),
|
||||
...storedAllowFrom,
|
||||
];
|
||||
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
|
||||
const senderLower = senderId.toLowerCase();
|
||||
const senderNameLower = senderName.toLowerCase();
|
||||
const allowed =
|
||||
effectiveAllowFrom.includes("*") ||
|
||||
effectiveAllowFrom.includes(senderLower) ||
|
||||
effectiveAllowFrom.includes(senderNameLower);
|
||||
|
||||
if (!allowed) {
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const request = await upsertChannelPairingRequest({
|
||||
channel: "msteams",
|
||||
@@ -170,6 +166,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
log.debug("dropping dm (not allowlisted)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -213,6 +210,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
||||
log.debug("dropping group message (not in team/channel allowlist)", {
|
||||
conversationId,
|
||||
teamKey: channelGate.teamKey ?? "none",
|
||||
channelKey: channelGate.channelKey ?? "none",
|
||||
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
||||
channelMatchSource: channelGate.channelMatchSource ?? "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -223,16 +224,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
return;
|
||||
}
|
||||
if (effectiveGroupAllowFrom.length > 0) {
|
||||
const allowed = isMSTeamsGroupAllowed({
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
groupPolicy,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
if (!allowed) {
|
||||
if (!allowMatch.allowed) {
|
||||
log.debug("dropping group message (not in groupAllowFrom)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -456,6 +458,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logVerbose(`msteams: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
||||
import { mergeAllowlist, summarizeMapping } from "../../../src/channels/allowlists/resolve-utils.js";
|
||||
import type { ClawdbotConfig } from "../../../src/config/types.js";
|
||||
import { getChildLogger } from "../../../src/logging.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import {
|
||||
getChildLogger,
|
||||
mergeAllowlist,
|
||||
resolveTextChunkLimit,
|
||||
summarizeMapping,
|
||||
type ClawdbotConfig,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||
import type { DmPolicy } from "../../../src/config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
|
||||
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
promptChannelAccessConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import { chunkMarkdownText, type ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MSTeamsConfig } from "../../../src/config/types.js";
|
||||
import type { MSTeamsConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
@@ -31,6 +31,8 @@ describe("msteams policy", () => {
|
||||
expect(res.channelConfig?.requireMention).toBe(true);
|
||||
expect(res.allowlistConfigured).toBe(true);
|
||||
expect(res.allowed).toBe(true);
|
||||
expect(res.channelMatchKey).toBe("chan456");
|
||||
expect(res.channelMatchSource).toBe("direct");
|
||||
});
|
||||
|
||||
it("returns undefined configs when teamId is missing", () => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
GroupPolicy,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
MSTeamsTeamConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MSTeamsResolvedRouteConfig = {
|
||||
teamConfig?: MSTeamsTeamConfig;
|
||||
@@ -13,6 +20,8 @@ export type MSTeamsResolvedRouteConfig = {
|
||||
allowed: boolean;
|
||||
teamKey?: string;
|
||||
channelKey?: string;
|
||||
channelMatchKey?: string;
|
||||
channelMatchSource?: "direct" | "wildcard";
|
||||
};
|
||||
|
||||
export function resolveMSTeamsRouteConfig(params: {
|
||||
@@ -27,54 +36,53 @@ export function resolveMSTeamsRouteConfig(params: {
|
||||
const conversationId = params.conversationId?.trim();
|
||||
const channelName = params.channelName?.trim();
|
||||
const teams = params.cfg?.teams ?? {};
|
||||
const teamKeys = Object.keys(teams);
|
||||
const allowlistConfigured = teamKeys.length > 0;
|
||||
|
||||
const normalize = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
let teamKey: string | undefined;
|
||||
if (teamId && teams[teamId]) teamKey = teamId;
|
||||
if (!teamKey && teamName) {
|
||||
const slug = normalize(teamName);
|
||||
if (slug) {
|
||||
teamKey = teamKeys.find((key) => normalize(key) === slug);
|
||||
}
|
||||
}
|
||||
if (!teamKey && teams["*"]) teamKey = "*";
|
||||
|
||||
const teamConfig = teamKey ? teams[teamKey] : undefined;
|
||||
const allowlistConfigured = Object.keys(teams).length > 0;
|
||||
const teamCandidates = buildChannelKeyCandidates(
|
||||
teamId,
|
||||
teamName,
|
||||
teamName ? normalizeChannelSlug(teamName) : undefined,
|
||||
);
|
||||
const teamMatch = resolveChannelEntryMatchWithFallback({
|
||||
entries: teams,
|
||||
keys: teamCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
const teamConfig = teamMatch.entry;
|
||||
const channels = teamConfig?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
const channelAllowlistConfigured = Object.keys(channels).length > 0;
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
conversationId,
|
||||
channelName,
|
||||
channelName ? normalizeChannelSlug(channelName) : undefined,
|
||||
);
|
||||
const channelMatch = resolveChannelEntryMatchWithFallback({
|
||||
entries: channels,
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
const channelConfig = channelMatch.entry;
|
||||
|
||||
let channelKey: string | undefined;
|
||||
if (conversationId && channels[conversationId]) channelKey = conversationId;
|
||||
if (!channelKey && channelName) {
|
||||
const slug = normalize(channelName);
|
||||
if (slug) {
|
||||
channelKey = channelKeys.find((key) => normalize(key) === slug);
|
||||
}
|
||||
}
|
||||
if (!channelKey && channels["*"]) channelKey = "*";
|
||||
const channelConfig = channelKey ? channels[channelKey] : undefined;
|
||||
const channelAllowlistConfigured = channelKeys.length > 0;
|
||||
|
||||
const allowed = !allowlistConfigured
|
||||
? true
|
||||
: Boolean(teamConfig) && (!channelAllowlistConfigured || Boolean(channelConfig));
|
||||
const allowed = resolveNestedAllowlistDecision({
|
||||
outerConfigured: allowlistConfigured,
|
||||
outerMatched: Boolean(teamConfig),
|
||||
innerConfigured: channelAllowlistConfigured,
|
||||
innerMatched: Boolean(channelConfig),
|
||||
});
|
||||
|
||||
return {
|
||||
teamConfig,
|
||||
channelConfig,
|
||||
allowlistConfigured,
|
||||
allowed,
|
||||
teamKey,
|
||||
channelKey,
|
||||
teamKey: teamMatch.matchKey ?? teamMatch.key,
|
||||
channelKey: channelMatch.matchKey ?? channelMatch.key,
|
||||
channelMatchKey: channelMatch.matchKey,
|
||||
channelMatchSource:
|
||||
channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard"
|
||||
? channelMatch.matchSource
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +91,31 @@ export type MSTeamsReplyPolicy = {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
};
|
||||
|
||||
export type MSTeamsAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
|
||||
|
||||
export function resolveMSTeamsAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): MSTeamsAllowlistMatch {
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => String(entry).trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowFrom.length === 0) return { allowed: false };
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const senderId = params.senderId.toLowerCase();
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
const senderName = params.senderName?.toLowerCase();
|
||||
if (senderName && allowFrom.includes(senderName)) {
|
||||
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveMSTeamsReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
globalConfig?: MSTeamsConfig;
|
||||
@@ -119,12 +152,5 @@ export function isMSTeamsGroupAllowed(params: {
|
||||
const { groupPolicy } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => String(entry).trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (allowFrom.length === 0) return false;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const senderId = params.senderId.toLowerCase();
|
||||
const senderName = params.senderName?.toLowerCase();
|
||||
return allowFrom.includes(senderId) || (senderName ? allowFrom.includes(senderName) : false);
|
||||
return resolveMSTeamsAllowlistMatch(params).allowed;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MSTeamsConfig } from "../../../src/config/types.js";
|
||||
import type { MSTeamsConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
const hostMockState = vi.hoisted(() => ({
|
||||
tokenError: null as Error | null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MSTeamsConfig } from "../../../src/config/types.js";
|
||||
import type { MSTeamsConfig } from "clawdbot/plugin-sdk";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../../src/agents/identity.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../../../src/config/types.js";
|
||||
import { danger } from "../../../src/globals.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import {
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
type ClawdbotConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/types.js";
|
||||
import type { getChildLogger as getChildLoggerFn } from "../../../src/logging.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { getChildLogger as getChildLoggerFn } from "clawdbot/plugin-sdk";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClawdbotConfig } from "../../../src/config/types.js";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../../../src/config/paths.js";
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MSTeamsStorePathOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MSTeamsConfig } from "../../../src/config/types.js";
|
||||
import type { MSTeamsConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MSTeamsCredentials = {
|
||||
appId: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../../src/channels/plugins/types.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { listEnabledZaloAccounts } from "./accounts.js";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChannelAccountSnapshot } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelDock, ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin } from "clawdbot/plugin-sdk";
|
||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
||||
import { zaloMessageActions } from "./actions.js";
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
finalizeInboundContext,
|
||||
isControlCommandMessage,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveStorePath,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
@@ -552,6 +554,17 @@ async function processMessageWithPipeline(params: {
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./shared/onboarding.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import type { WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../../src/channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk";
|
||||
|
||||
type ZaloAccountStatus = {
|
||||
accountId?: unknown;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
import type { ChannelAccountSnapshot, ChannelDirectoryEntry, ChannelPlugin } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import { formatPairingApproveHint } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
finalizeInboundContext,
|
||||
isControlCommandMessage,
|
||||
mergeAllowlist,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveStorePath,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { mergeAllowlist, summarizeMapping } from "../../../src/channels/allowlists/resolve-utils.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
summarizeMapping,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type {
|
||||
@@ -299,6 +302,17 @@ async function processMessage(
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
const storePath = resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { promptChannelAccessConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"dist/media-understanding/**",
|
||||
"dist/process/**",
|
||||
"dist/plugins/**",
|
||||
"dist/plugin-sdk/**",
|
||||
"dist/security/**",
|
||||
"dist/sessions/**",
|
||||
"dist/providers/**",
|
||||
|
||||
@@ -216,6 +216,19 @@ else
|
||||
echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2
|
||||
fi
|
||||
|
||||
echo "📦 Copying Textual resources"
|
||||
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
|
||||
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle"
|
||||
if [ ! -d "$TEXTUAL_BUNDLE" ]; then
|
||||
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
|
||||
fi
|
||||
if [ -d "$TEXTUAL_BUNDLE" ]; then
|
||||
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
|
||||
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
|
||||
else
|
||||
echo "WARN: Textual resource bundle not found in $TEXTUAL_BUNDLE_DIR (continuing)" >&2
|
||||
fi
|
||||
|
||||
echo "⏹ Stopping any running Clawdbot"
|
||||
killall -q Clawdbot 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("nodes run", () => {
|
||||
it("passes invoke and command timeouts", async () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1" }] };
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./tools/memory-tool.js";
|
||||
import { createMessageTool } from "./tools/message-tool.js";
|
||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
||||
@@ -49,14 +48,6 @@ export function createClawdbotTools(options?: {
|
||||
sandboxRoot: options?.sandboxRoot,
|
||||
})
|
||||
: null;
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
config: options?.config,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
});
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
config: options?.config,
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
});
|
||||
const webSearchTool = createWebSearchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
@@ -74,7 +65,10 @@ export function createClawdbotTools(options?: {
|
||||
allowedControlPorts: options?.allowedControlPorts,
|
||||
}),
|
||||
createCanvasTool(),
|
||||
createNodesTool(),
|
||||
createNodesTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
createCronTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
}),
|
||||
@@ -116,7 +110,6 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
|
||||
...(webSearchTool ? [webSearchTool] : []),
|
||||
...(webFetchTool ? [webFetchTool] : []),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("memory search config", () => {
|
||||
expect(resolved?.remote?.batch).toEqual({
|
||||
enabled: true,
|
||||
wait: true,
|
||||
concurrency: 2,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMinutes: 60,
|
||||
});
|
||||
@@ -133,6 +134,7 @@ describe("memory search config", () => {
|
||||
batch: {
|
||||
enabled: true,
|
||||
wait: true,
|
||||
concurrency: 2,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMinutes: 60,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
batch?: {
|
||||
enabled: boolean;
|
||||
wait: boolean;
|
||||
concurrency: number;
|
||||
pollIntervalMs: number;
|
||||
timeoutMinutes: number;
|
||||
};
|
||||
@@ -52,6 +53,16 @@ export type ResolvedMemorySearchConfig = {
|
||||
query: {
|
||||
maxResults: number;
|
||||
minScore: number;
|
||||
hybrid: {
|
||||
enabled: boolean;
|
||||
vectorWeight: number;
|
||||
textWeight: number;
|
||||
candidateMultiplier: number;
|
||||
};
|
||||
};
|
||||
cache: {
|
||||
enabled: boolean;
|
||||
maxEntries?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,6 +72,11 @@ const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||
const DEFAULT_MAX_RESULTS = 6;
|
||||
const DEFAULT_MIN_SCORE = 0.35;
|
||||
const DEFAULT_HYBRID_ENABLED = true;
|
||||
const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
|
||||
const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
|
||||
const DEFAULT_HYBRID_CANDIDATE_MULTIPLIER = 4;
|
||||
const DEFAULT_CACHE_ENABLED = true;
|
||||
const DEFAULT_SOURCES: Array<"memory" | "sessions"> = ["memory"];
|
||||
|
||||
function normalizeSources(
|
||||
@@ -99,14 +115,14 @@ function mergeConfig(
|
||||
const batch = {
|
||||
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
|
||||
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
|
||||
concurrency: Math.max(
|
||||
1,
|
||||
overrides?.remote?.batch?.concurrency ?? defaults?.remote?.batch?.concurrency ?? 2,
|
||||
),
|
||||
pollIntervalMs:
|
||||
overrides?.remote?.batch?.pollIntervalMs ??
|
||||
defaults?.remote?.batch?.pollIntervalMs ??
|
||||
5000,
|
||||
overrides?.remote?.batch?.pollIntervalMs ?? defaults?.remote?.batch?.pollIntervalMs ?? 5000,
|
||||
timeoutMinutes:
|
||||
overrides?.remote?.batch?.timeoutMinutes ??
|
||||
defaults?.remote?.batch?.timeoutMinutes ??
|
||||
60,
|
||||
overrides?.remote?.batch?.timeoutMinutes ?? defaults?.remote?.batch?.timeoutMinutes ?? 60,
|
||||
};
|
||||
const remote = includeRemote
|
||||
? {
|
||||
@@ -151,9 +167,37 @@ function mergeConfig(
|
||||
maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
|
||||
minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE,
|
||||
};
|
||||
const hybrid = {
|
||||
enabled:
|
||||
overrides?.query?.hybrid?.enabled ??
|
||||
defaults?.query?.hybrid?.enabled ??
|
||||
DEFAULT_HYBRID_ENABLED,
|
||||
vectorWeight:
|
||||
overrides?.query?.hybrid?.vectorWeight ??
|
||||
defaults?.query?.hybrid?.vectorWeight ??
|
||||
DEFAULT_HYBRID_VECTOR_WEIGHT,
|
||||
textWeight:
|
||||
overrides?.query?.hybrid?.textWeight ??
|
||||
defaults?.query?.hybrid?.textWeight ??
|
||||
DEFAULT_HYBRID_TEXT_WEIGHT,
|
||||
candidateMultiplier:
|
||||
overrides?.query?.hybrid?.candidateMultiplier ??
|
||||
defaults?.query?.hybrid?.candidateMultiplier ??
|
||||
DEFAULT_HYBRID_CANDIDATE_MULTIPLIER,
|
||||
};
|
||||
const cache = {
|
||||
enabled: overrides?.cache?.enabled ?? defaults?.cache?.enabled ?? DEFAULT_CACHE_ENABLED,
|
||||
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
|
||||
};
|
||||
|
||||
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
|
||||
const minScore = Math.max(0, Math.min(1, query.minScore));
|
||||
const vectorWeight = Math.max(0, Math.min(1, hybrid.vectorWeight));
|
||||
const textWeight = Math.max(0, Math.min(1, hybrid.textWeight));
|
||||
const sum = vectorWeight + textWeight;
|
||||
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
||||
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
||||
const candidateMultiplier = Math.max(1, Math.min(20, Math.floor(hybrid.candidateMultiplier)));
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
@@ -168,7 +212,23 @@ function mergeConfig(
|
||||
store,
|
||||
chunking: { tokens: Math.max(1, chunking.tokens), overlap },
|
||||
sync,
|
||||
query: { ...query, minScore },
|
||||
query: {
|
||||
...query,
|
||||
minScore,
|
||||
hybrid: {
|
||||
enabled: Boolean(hybrid.enabled),
|
||||
vectorWeight: normalizedVectorWeight,
|
||||
textWeight: normalizedTextWeight,
|
||||
candidateMultiplier,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
enabled: Boolean(cache.enabled),
|
||||
maxEntries:
|
||||
typeof cache.maxEntries === "number" && Number.isFinite(cache.maxEntries)
|
||||
? Math.max(1, Math.floor(cache.maxEntries))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
import { hasModelFallbackCandidates, runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||
return {
|
||||
@@ -310,3 +310,73 @@ describe("runWithModelFallback", () => {
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasModelFallbackCandidates", () => {
|
||||
it("returns false when only the primary candidate is available", () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the configured primary differs from the requested model", () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit empty fallbacksOverride", () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when fallbacksOverride provides extra candidates", () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: ["openai/gpt-4.1"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,16 @@ function resolveFallbackCandidates(params: {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function hasModelFallbackCandidates(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
}): boolean {
|
||||
return resolveFallbackCandidates(params).length > 1;
|
||||
}
|
||||
|
||||
export async function runWithModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
|
||||
+14
-5
@@ -78,6 +78,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
||||
? buildAssistantErrorMessage(model)
|
||||
: buildAssistantMessage(model),
|
||||
});
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
@@ -113,6 +114,9 @@ const makeOpenAiConfig = (modelIds: string[]) =>
|
||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||
ensureClawdbotModelsJson(cfg, agentDir);
|
||||
|
||||
const testSessionKey = "agent:test:embedded-ordering";
|
||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||
|
||||
const textFromContent = (content: unknown) => {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content) && content[0]?.type === "text") {
|
||||
@@ -179,7 +183,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -188,6 +192,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
@@ -219,7 +224,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -228,11 +233,12 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -241,6 +247,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
@@ -306,7 +313,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -315,6 +322,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
@@ -339,7 +347,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -348,6 +356,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
|
||||
+10
-3
@@ -78,6 +78,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
||||
? buildAssistantErrorMessage(model)
|
||||
: buildAssistantMessage(model),
|
||||
});
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
@@ -113,6 +114,9 @@ const makeOpenAiConfig = (modelIds: string[]) =>
|
||||
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||
ensureClawdbotModelsJson(cfg, agentDir);
|
||||
|
||||
const testSessionKey = "agent:test:embedded-models";
|
||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||
|
||||
const textFromContent = (content: unknown) => {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content) && content[0]?.type === "text") {
|
||||
@@ -169,7 +173,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:dev:test",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -178,6 +182,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "definitely-not-a-model",
|
||||
timeoutMs: 1,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
}),
|
||||
).rejects.toThrow(/Unknown model:/);
|
||||
|
||||
@@ -193,7 +198,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -202,6 +207,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
@@ -224,7 +230,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionKey: testSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
@@ -233,6 +239,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
model: "mock-error",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
expect(result.payloads[0]?.isError).toBe(true);
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as helpers from "./pi-embedded-helpers.js";
|
||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||
|
||||
type SanitizeSessionHistory = typeof import("./pi-embedded-runner/google.js").sanitizeSessionHistory;
|
||||
let sanitizeSessionHistory: SanitizeSessionHistory;
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./pi-embedded-helpers.js", async () => {
|
||||
@@ -26,7 +28,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
|
||||
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||
// Default mock implementation
|
||||
@@ -34,6 +36,8 @@ describe("sanitizeSessionHistory", () => {
|
||||
if (!msgs) return [];
|
||||
return [...msgs, { role: "system", content: "downgraded" }];
|
||||
});
|
||||
vi.resetModules();
|
||||
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
||||
});
|
||||
|
||||
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import { resolveAgentModelFallbacksOverride, resolveSessionAgentId } from "../agent-scope.js";
|
||||
import {
|
||||
markAuthProfileFailure,
|
||||
markAuthProfileGood,
|
||||
@@ -16,12 +17,13 @@ import {
|
||||
resolveContextWindowInfo,
|
||||
} from "../context-window-guard.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||
import { FailoverError, coerceToFailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../model-auth.js";
|
||||
import { hasModelFallbackCandidates } from "../model-fallback.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isFailoverAssistantError,
|
||||
isFailoverErrorMessage,
|
||||
isRateLimitAssistantError,
|
||||
isTimeoutErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
@@ -88,6 +89,20 @@ export async function runEmbeddedPiAgent(
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const sessionAgentId = resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const fallbacksOverride = resolveAgentModelFallbacksOverride(
|
||||
params.config ?? {},
|
||||
sessionAgentId,
|
||||
);
|
||||
const hasModelFallbacks = hasModelFallbackCandidates({
|
||||
cfg: params.config,
|
||||
provider,
|
||||
model: modelId,
|
||||
fallbacksOverride,
|
||||
});
|
||||
|
||||
const ctxInfo = resolveContextWindowInfo({
|
||||
cfg: params.config,
|
||||
@@ -290,7 +305,14 @@ export async function runEmbeddedPiAgent(
|
||||
},
|
||||
};
|
||||
}
|
||||
const promptFailoverReason = classifyFailoverReason(errorText);
|
||||
const promptFailoverError =
|
||||
coerceToFailoverError(promptError, {
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: lastProfileId,
|
||||
}) ?? null;
|
||||
const promptFailoverReason =
|
||||
promptFailoverError?.reason ?? classifyFailoverReason(errorText);
|
||||
if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) {
|
||||
await markAuthProfileFailure({
|
||||
store: authStore,
|
||||
@@ -301,7 +323,7 @@ export async function runEmbeddedPiAgent(
|
||||
});
|
||||
}
|
||||
if (
|
||||
isFailoverErrorMessage(errorText) &&
|
||||
promptFailoverReason &&
|
||||
promptFailoverReason !== "timeout" &&
|
||||
(await advanceAuthProfile())
|
||||
) {
|
||||
@@ -318,6 +340,16 @@ export async function runEmbeddedPiAgent(
|
||||
thinkLevel = fallbackThinking;
|
||||
continue;
|
||||
}
|
||||
if (promptFailoverReason && hasModelFallbacks) {
|
||||
if (promptFailoverError) throw promptFailoverError;
|
||||
throw new FailoverError(errorText, {
|
||||
reason: promptFailoverReason,
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: lastProfileId,
|
||||
status: resolveFailoverStatus(promptFailoverReason),
|
||||
});
|
||||
}
|
||||
throw promptError;
|
||||
}
|
||||
|
||||
@@ -333,8 +365,6 @@ export async function runEmbeddedPiAgent(
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||
@@ -372,7 +402,7 @@ export async function runEmbeddedPiAgent(
|
||||
const rotated = await advanceAuthProfile();
|
||||
if (rotated) continue;
|
||||
|
||||
if (fallbackConfigured) {
|
||||
if (hasModelFallbacks) {
|
||||
// Prefer formatted error message (user-friendly) over raw errorMessage
|
||||
const message =
|
||||
(lastAssistant
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user