mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 18:49:31 +08:00
Compare commits
114 Commits
pr-62129
...
codex/acti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd842b0574 | ||
|
|
433348672d | ||
|
|
46ab4ffe01 | ||
|
|
b958bccecf | ||
|
|
c13989d08e | ||
|
|
208c0ef783 | ||
|
|
64bc1a8e4c | ||
|
|
dd7999047b | ||
|
|
7ffb2ceaac | ||
|
|
5e23a26098 | ||
|
|
19f3740910 | ||
|
|
b224c12ae1 | ||
|
|
32bcb93527 | ||
|
|
cbb2697215 | ||
|
|
dae966a9b6 | ||
|
|
b8540b46b5 | ||
|
|
8f64587a1e | ||
|
|
8f0698d148 | ||
|
|
8abf8a1bee | ||
|
|
cfe71e2e44 | ||
|
|
ecc9a65f34 | ||
|
|
28fc5d9b5e | ||
|
|
9bcef781e7 | ||
|
|
83bdba2bae | ||
|
|
3e85f9c4ff | ||
|
|
a4bb2698dd | ||
|
|
bfff74fb11 | ||
|
|
dffa88f396 | ||
|
|
ba68537d9d | ||
|
|
5b090561fb | ||
|
|
eb9ce9482c | ||
|
|
f665da8dbc | ||
|
|
abf81ff1ed | ||
|
|
179ccb952c | ||
|
|
182d41d678 | ||
|
|
493e1c246e | ||
|
|
e51a00ffc7 | ||
|
|
ad6bfc44d5 | ||
|
|
0995ee0134 | ||
|
|
b78202d44e | ||
|
|
e418a6d0cc | ||
|
|
6484b41eb9 | ||
|
|
a00b01f5ed | ||
|
|
b5d2bd6f41 | ||
|
|
4e69a9b329 | ||
|
|
cde12e63e7 | ||
|
|
f312d6c106 | ||
|
|
e7538b4499 | ||
|
|
02bd9e8c10 | ||
|
|
35eb70f1f5 | ||
|
|
986536ff6b | ||
|
|
f6544a0a3b | ||
|
|
daeff2fa89 | ||
|
|
76bde3d42b | ||
|
|
816a3eae8a | ||
|
|
5aa4fd3216 | ||
|
|
7d18b145f8 | ||
|
|
cdf18c16b4 | ||
|
|
3182588ad4 | ||
|
|
82535771cd | ||
|
|
f9f38a48e6 | ||
|
|
9a106f7e3c | ||
|
|
e8b446b985 | ||
|
|
f93b217834 | ||
|
|
63e6bb026c | ||
|
|
4f421fa0f1 | ||
|
|
18fb171179 | ||
|
|
bffb83acf8 | ||
|
|
cfbe7ac227 | ||
|
|
e5aae5e056 | ||
|
|
744d176744 | ||
|
|
4a0b8c6248 | ||
|
|
f02ba9a3ed | ||
|
|
6380c872bc | ||
|
|
a383878e97 | ||
|
|
93ab2ac69d | ||
|
|
ceb2311a1b | ||
|
|
86f35a9bc0 | ||
|
|
23ab290a71 | ||
|
|
9edf9804b1 | ||
|
|
d78512b09d | ||
|
|
4108901932 | ||
|
|
d855f5f505 | ||
|
|
12331f0463 | ||
|
|
14ec1ac50f | ||
|
|
adb7b0d5d6 | ||
|
|
e617aa6d1e | ||
|
|
7c478473fe | ||
|
|
16cebe5669 | ||
|
|
049acf23cb | ||
|
|
df881d5c18 | ||
|
|
caecd3c1fe | ||
|
|
c6b5731c5d | ||
|
|
b2dc25cd12 | ||
|
|
037340d287 | ||
|
|
6058eacaec | ||
|
|
1a3f141215 | ||
|
|
cebfa70277 | ||
|
|
d40dc8f025 | ||
|
|
d56fe040b4 | ||
|
|
9e61209780 | ||
|
|
d4eb3e12c9 | ||
|
|
0828db93e9 | ||
|
|
c1fc2ed0e8 | ||
|
|
f0c9978030 | ||
|
|
67a3af7f8d | ||
|
|
e46e32b98c | ||
|
|
dac72889e5 | ||
|
|
23edd9921e | ||
|
|
904017814b | ||
|
|
76bc0ae32f | ||
|
|
2de8b91448 | ||
|
|
e8c0f25598 | ||
|
|
5880ec17b1 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
|
||||
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
|
||||
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
|
||||
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
|
||||
@@ -27,6 +28,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
|
||||
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
|
||||
- Providers/inferrs: add string-content compatibility for stricter OpenAI-compatible chat backends, document `inferrs` setup with a full config example, and add troubleshooting guidance for local backends that pass direct probes but fail on full agent-runtime prompts.
|
||||
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
|
||||
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
|
||||
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
|
||||
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -35,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
|
||||
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
|
||||
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
|
||||
- Plugins/ClawHub: verify downloaded plugin archives against version metadata SHA-256, fail closed when archive integrity metadata is missing or malformed, and tighten fallback ZIP verification so plugin installs cannot proceed on mismatched or incomplete ClawHub package metadata. (#60517) Thanks @mappel-nv.
|
||||
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
|
||||
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
|
||||
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
|
||||
@@ -47,8 +53,15 @@ Docs: https://docs.openclaw.ai
|
||||
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
|
||||
- Host exec/env sanitization: block dangerous `JAVA_OPTS`, `RUSTFLAGS`, and `CARGO_HOME` inputs at the host-exec boundary so attacker-controlled env overrides can no longer inject JVM agents, compiler flags, or Cargo state pivots into host-run processes. (#62291) Thanks @pgondhi987.
|
||||
- Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987.
|
||||
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
|
||||
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
|
||||
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
|
||||
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
|
||||
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
|
||||
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
|
||||
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
|
||||
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
|
||||
@@ -66,6 +79,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
|
||||
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
|
||||
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
|
||||
- Browser/node invoke: block persistent browser profile create, reset, and delete mutations through `browser.proxy` on both gateway-forwarded `node.invoke` and the node-host proxy path, even when no profile allowlist is configured. (#60489)
|
||||
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
|
||||
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
|
||||
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
|
||||
@@ -77,6 +91,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
|
||||
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana.
|
||||
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
|
||||
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
|
||||
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) Thanks @100yenadmin.
|
||||
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
|
||||
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
|
||||
- Security/fetch-guard: stop rejecting operator-configured proxy hostnames against the target-scoped hostname allowlist in SSRF-guarded fetches, restoring proxy-based media downloads for Telegram and other channels. (#62312) Thanks @ademczuk.
|
||||
- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman.
|
||||
|
||||
## 2026.4.5
|
||||
|
||||
@@ -215,6 +235,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
|
||||
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
|
||||
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
|
||||
- Browser/security: re-run SSRF safety checks after interaction-driven navigations and before snapshot reads so click, submit, keyboard, and current-page snapshot flows fail closed on disallowed destinations. (#62023) Thanks @eleqtrizit.
|
||||
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
|
||||
- Agents/tool results: keep large `read` outputs visible longer, preserve the latest `read` output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh `read` with a compacted stub. Thanks @vincentkoc.
|
||||
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
|
||||
@@ -812,6 +833,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
|
||||
- Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.
|
||||
- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.
|
||||
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayConnectionIssue: Equatable {
|
||||
case none
|
||||
@@ -29,6 +30,37 @@ enum GatewayConnectionIssue: Equatable {
|
||||
return false
|
||||
}
|
||||
|
||||
static func detect(problem: GatewayConnectionProblem?) -> Self {
|
||||
guard let problem else { return .none }
|
||||
if problem.needsPairingApproval {
|
||||
return .pairingRequired(requestId: problem.requestId)
|
||||
}
|
||||
if problem.needsCredentialUpdate {
|
||||
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
|
||||
}
|
||||
switch problem.kind {
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
return .unauthorized
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .network
|
||||
case .unknown:
|
||||
return .unknown(problem.message)
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
static func detect(from statusText: String) -> Self {
|
||||
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .none }
|
||||
|
||||
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GatewayProblemBanner: View {
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.problem.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.ownerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.problem.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let requestId = self.problem.requestId {
|
||||
Text("Request ID: \(requestId)")
|
||||
.font(.system(.caption, design: .monospaced).weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle, action: onPrimaryAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return "person.crop.circle.badge.clock"
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return "wifi.exclamationmark"
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
return "lock.shield"
|
||||
default:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return .orange
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .yellow
|
||||
default:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerLabel: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Fix on gateway"
|
||||
case .iphone:
|
||||
return "Fix on iPhone"
|
||||
case .both:
|
||||
return "Check both"
|
||||
case .network:
|
||||
return "Check network"
|
||||
case .unknown:
|
||||
return "Needs attention"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayProblemDetailsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
|
||||
@State private var copyFeedback: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.problem.title)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.problem.message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.ownerSummary)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if let requestId = self.problem.requestId {
|
||||
Section("Request") {
|
||||
Text(verbatim: requestId)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy request ID") {
|
||||
UIPasteboard.general.string = requestId
|
||||
self.copyFeedback = "Copied request ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let actionCommand = self.problem.actionCommand {
|
||||
Section("Gateway command") {
|
||||
Text(verbatim: actionCommand)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy command") {
|
||||
UIPasteboard.general.string = actionCommand
|
||||
self.copyFeedback = "Copied command"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let docsURL = self.problem.docsURL {
|
||||
Section("Help") {
|
||||
Link(destination: docsURL) {
|
||||
Label("Open docs", systemImage: "book")
|
||||
}
|
||||
Text(verbatim: docsURL.absoluteString)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if let technicalDetails = self.problem.technicalDetails {
|
||||
Section("Technical details") {
|
||||
Text(verbatim: technicalDetails)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if let copyFeedback {
|
||||
Section {
|
||||
Text(copyFeedback)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connection problem")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle) {
|
||||
self.dismiss()
|
||||
onPrimaryAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerSummary: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Primary fix: gateway"
|
||||
case .iphone:
|
||||
return "Primary fix: this iPhone"
|
||||
case .both:
|
||||
return "Primary fix: check both this iPhone and the gateway"
|
||||
case .network:
|
||||
return "Primary fix: network or remote access"
|
||||
case .unknown:
|
||||
return "Primary fix: review details and retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ struct GatewayQuickSetupSheet: View {
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var connecting: Bool = false
|
||||
@State private var connectError: String?
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -15,6 +16,14 @@ struct GatewayQuickSetupSheet: View {
|
||||
Text("Connect to a Gateway?")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
|
||||
if let candidate = self.bestCandidate {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: candidate.name)
|
||||
@@ -27,7 +36,7 @@ struct GatewayQuickSetupSheet: View {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
|
||||
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||
}
|
||||
@@ -104,6 +113,11 @@ struct GatewayQuickSetupSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(problem: gatewayProblem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
|
||||
@@ -120,6 +120,10 @@ final class NodeAppModel {
|
||||
// multiple pending requests and cause the onboarding UI to "flip-flop".
|
||||
var gatewayPairingPaused: Bool = false
|
||||
var gatewayPairingRequestId: String?
|
||||
private(set) var lastGatewayProblem: GatewayConnectionProblem?
|
||||
var gatewayDisplayStatusText: String {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
@@ -1815,6 +1819,7 @@ extension NodeAppModel {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@@ -1848,6 +1853,7 @@ private extension NodeAppModel {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
@@ -1866,6 +1872,38 @@ private extension NodeAppModel {
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
func clearGatewayConnectionProblem() {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
|
||||
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
if problem.pauseReconnect {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
if problem.needsPairingApproval {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = problem.requestId
|
||||
} else {
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
}
|
||||
|
||||
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
guard let lastGatewayProblem else { return false }
|
||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||
previousProblem: lastGatewayProblem,
|
||||
overDisconnectReason: reason)
|
||||
}
|
||||
|
||||
func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
@@ -2162,6 +2200,7 @@ private extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
self.gatewayConnected = true
|
||||
@@ -2218,7 +2257,13 @@ private extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
self.gatewayStatusText = lastGatewayProblem.statusText
|
||||
} else {
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
}
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
@@ -2257,50 +2302,25 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
let problem = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
return nextProblem
|
||||
}
|
||||
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
|
||||
|
||||
// If auth is missing/rejected, pause reconnect churn until the user intervenes.
|
||||
// Reconnect loops only spam the same failing handshake and make onboarding noisy.
|
||||
let lower = error.localizedDescription.lowercased()
|
||||
if lower.contains("unauthorized") || lower.contains("gateway token missing") {
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// If pairing is required, stop reconnect churn. The user must approve the request
|
||||
// on the gateway before another connect attempt will succeed, and retry loops can
|
||||
// generate multiple pending requests.
|
||||
if lower.contains("not_paired") || lower.contains("pairing required") {
|
||||
let requestId: String? = {
|
||||
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
|
||||
// Keep this resilient since other layers may wrap the text.
|
||||
let text = error.localizedDescription
|
||||
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
|
||||
guard let end = text[start...].firstIndex(of: ")") else { return nil }
|
||||
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw.isEmpty ? nil : raw
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). "
|
||||
+ "Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
if problem?.needsPairingApproval == true {
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
// we don't generate multiple pending requests while waiting for approval.
|
||||
pausedForPairingApproval = true
|
||||
@@ -2311,6 +2331,10 @@ private extension NodeAppModel {
|
||||
break
|
||||
}
|
||||
|
||||
if problem?.pauseReconnect == true {
|
||||
continue
|
||||
}
|
||||
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
@@ -2322,6 +2346,7 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
|
||||
@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayStatusText)",
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
|
||||
@@ -69,6 +69,7 @@ struct OnboardingWizardView: View {
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var lastPairingAutoResumeAttemptAt: Date?
|
||||
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
@@ -86,6 +87,10 @@ struct OnboardingWizardView: View {
|
||||
self.step == .intro || self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
private var currentProblem: GatewayConnectionProblem? {
|
||||
self.appModel.lastGatewayProblem
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
@@ -216,6 +221,16 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
@@ -250,39 +265,11 @@ struct OnboardingWizardView: View {
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
let next = GatewayConnectionIssue.detect(from: newValue)
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, next.needsPairing {
|
||||
// Keep the requestId sticky even if the status line omits it after we pause.
|
||||
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !next.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = next
|
||||
}
|
||||
|
||||
if let requestId = next.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
|
||||
if next.needsAuthToken {
|
||||
self.appModel.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing {
|
||||
self.step = .auth
|
||||
}
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.connectMessage = newValue
|
||||
self.statusLine = newValue
|
||||
}
|
||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
@@ -509,7 +496,7 @@ struct OnboardingWizardView: View {
|
||||
Section {
|
||||
LabeledContent("Mode", value: selectedMode.title)
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
|
||||
LabeledContent("Progress", value: self.statusLine)
|
||||
} header: {
|
||||
Text("Status")
|
||||
@@ -612,7 +599,17 @@ struct OnboardingWizardView: View {
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
if self.issue.needsAuthToken {
|
||||
if let problem = self.currentProblem {
|
||||
GatewayProblemBanner(
|
||||
problem: problem,
|
||||
primaryActionTitle: "Retry connection",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
} else if self.issue.needsAuthToken {
|
||||
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -635,14 +632,15 @@ struct OnboardingWizardView: View {
|
||||
Text("Pairing Approval")
|
||||
} footer: {
|
||||
let requestLine: String = {
|
||||
if let id = self.issue.requestId, !id.isEmpty {
|
||||
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
|
||||
return "Request ID: \(id)"
|
||||
}
|
||||
return "Request ID: check `openclaw devices list`."
|
||||
}()
|
||||
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
|
||||
Text(
|
||||
"Approve this device on the gateway.\n"
|
||||
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
|
||||
+ "1) `\(commandLine)`\n"
|
||||
+ "2) `/pair approve` in your OpenClaw chat\n"
|
||||
+ "\(requestLine)\n"
|
||||
+ "OpenClaw will also retry automatically when you return to this app.")
|
||||
@@ -824,6 +822,45 @@ struct OnboardingWizardView: View {
|
||||
self.resumeAfterPairingApprovalInBackground()
|
||||
}
|
||||
|
||||
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
|
||||
let next = GatewayConnectionIssue.detect(problem: problem)
|
||||
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
|
||||
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, fallback.needsPairing {
|
||||
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !fallback.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = fallback
|
||||
}
|
||||
|
||||
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
|
||||
self.step = .auth
|
||||
}
|
||||
|
||||
if let problem {
|
||||
self.connectMessage = problem.message
|
||||
self.statusLine = problem.message
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedStatus.isEmpty {
|
||||
self.connectMessage = trimmedStatus
|
||||
self.statusLine = trimmedStatus
|
||||
}
|
||||
}
|
||||
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
|
||||
@@ -98,6 +98,9 @@ struct RootCanvas: View {
|
||||
},
|
||||
openSettings: {
|
||||
self.presentedSheet = .settings
|
||||
},
|
||||
retryGatewayConnection: {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
@@ -229,7 +232,7 @@ struct RootCanvas: View {
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
@@ -454,6 +457,7 @@ private struct CanvasContent: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
var systemColorScheme: ColorScheme
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
@@ -462,6 +466,7 @@ private struct CanvasContent: View {
|
||||
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
|
||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
||||
@@ -488,6 +493,8 @@ private struct CanvasContent: View {
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
@@ -504,13 +511,35 @@ private struct CanvasContent: View {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings",
|
||||
onPrimaryAction: {
|
||||
if gatewayProblem.retryable {
|
||||
self.retryGatewayConnection()
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(
|
||||
command: voiceWakeToastText,
|
||||
brighten: self.brightenButtons)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 58)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
@@ -518,6 +547,16 @@ private struct CanvasContent: View {
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
|
||||
if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
|
||||
@@ -9,6 +9,7 @@ struct RootTabs: View {
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
@@ -32,6 +33,8 @@ struct RootTabs: View {
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
@@ -39,11 +42,29 @@ struct RootTabs: View {
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.selectedTab = 2
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(command: voiceWakeToastText)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 58)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
@@ -74,6 +95,16 @@ struct RootTabs: View {
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.selectedTab = 2
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
|
||||
@@ -53,6 +53,7 @@ struct SettingsTab: View {
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
@State private var showResetOnboardingAlert: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var activeFeatureHelp: FeatureHelp?
|
||||
@State private var suppressCredentialPersist: Bool = false
|
||||
|
||||
@@ -63,6 +64,20 @@ struct SettingsTab: View {
|
||||
Form {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
!self.isGatewayConnected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Retry connection",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryGatewayConnectionFromProblem() }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
|
||||
if !self.isGatewayConnected {
|
||||
Text(
|
||||
"1. Open a chat with your OpenClaw agent and send /pair\n"
|
||||
@@ -123,7 +138,7 @@ struct SettingsTab: View {
|
||||
if self.appModel.gatewayServerName == nil {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
@@ -402,6 +417,16 @@ struct SettingsTab: View {
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryGatewayConnectionFromProblem() }
|
||||
})
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
@@ -593,6 +618,9 @@ struct SettingsTab: View {
|
||||
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
|
||||
return server
|
||||
}
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.statusText
|
||||
}
|
||||
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Not connected" : trimmed
|
||||
}
|
||||
@@ -642,7 +670,7 @@ struct SettingsTab: View {
|
||||
|
||||
private func gatewayDebugText() -> String {
|
||||
var lines: [String] = [
|
||||
"gateway: \(self.appModel.gatewayStatusText)",
|
||||
"gateway: \(self.appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(self.gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(self.appModel.gatewayServerName ?? "—")")
|
||||
@@ -889,6 +917,9 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private var setupStatusLine: String? {
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.message
|
||||
}
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
@@ -987,6 +1018,14 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func retryGatewayConnectionFromProblem() async {
|
||||
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
|
||||
await self.connectManual()
|
||||
return
|
||||
}
|
||||
await self.connectLastKnown()
|
||||
}
|
||||
|
||||
private func resetOnboarding() {
|
||||
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
||||
self.appModel.disconnectGateway()
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayStatusBuilder {
|
||||
@MainActor
|
||||
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
|
||||
if appModel.gatewayServerName != nil { return .connected }
|
||||
self.build(
|
||||
gatewayServerName: appModel.gatewayServerName,
|
||||
lastGatewayProblem: appModel.lastGatewayProblem,
|
||||
gatewayStatusText: appModel.gatewayStatusText)
|
||||
}
|
||||
|
||||
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
static func build(
|
||||
gatewayServerName: String?,
|
||||
lastGatewayProblem: GatewayConnectionProblem?,
|
||||
gatewayStatusText: String) -> StatusPill.GatewayState
|
||||
{
|
||||
if gatewayServerName != nil { return .connected }
|
||||
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
|
||||
|
||||
let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
|
||||
@@ -16,6 +16,31 @@ enum StatusActivityBuilder {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||
switch gatewayProblem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return StatusPill.Activity(
|
||||
title: "Approval pending",
|
||||
systemImage: "person.crop.circle.badge.clock",
|
||||
tint: .orange)
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return StatusPill.Activity(
|
||||
title: "Check network",
|
||||
systemImage: "wifi.exclamationmark",
|
||||
tint: .orange)
|
||||
default:
|
||||
if gatewayProblem.pauseReconnect {
|
||||
return StatusPill.Activity(
|
||||
title: "Action required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
|
||||
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct GatewayStatusBuilderTests {
|
||||
@Test func pausedProblemKeepsErrorStatus() {
|
||||
let state = GatewayStatusBuilder.build(
|
||||
gatewayServerName: nil,
|
||||
lastGatewayProblem: GatewayConnectionProblem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: "Pairing required",
|
||||
message: "Approve this device before reconnecting.",
|
||||
requestId: "req-123",
|
||||
retryable: false,
|
||||
pauseReconnect: true),
|
||||
gatewayStatusText: "Reconnecting…")
|
||||
|
||||
#expect(state == .error)
|
||||
}
|
||||
|
||||
@Test func transientProblemAllowsConnectingStatus() {
|
||||
let state = GatewayStatusBuilder.build(
|
||||
gatewayServerName: nil,
|
||||
lastGatewayProblem: GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
retryable: true,
|
||||
pauseReconnect: false),
|
||||
gatewayStatusText: "Reconnecting…")
|
||||
|
||||
#expect(state == .connecting)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
"JAVA_OPTS",
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
@@ -144,6 +145,8 @@ enum HostEnvSecurityPolicy {
|
||||
"HGRCPATH",
|
||||
"PYTHONUSERBASE",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTFLAGS",
|
||||
"CARGO_HOME",
|
||||
"VIRTUAL_ENV",
|
||||
"LUA_PATH",
|
||||
"LUA_CPATH",
|
||||
|
||||
@@ -624,11 +624,31 @@ public actor GatewayChannelActor {
|
||||
let detailCode = details?["code"]?.value as? String
|
||||
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
||||
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
|
||||
let requestId = details?["requestId"]?.value as? String
|
||||
let reason = details?["reason"]?.value as? String
|
||||
let owner = details?["owner"]?.value as? String
|
||||
let title = details?["title"]?.value as? String
|
||||
let userMessage = details?["userMessage"]?.value as? String
|
||||
let actionLabel = details?["actionLabel"]?.value as? String
|
||||
let actionCommand = details?["actionCommand"]?.value as? String
|
||||
let docsURLString = details?["docsUrl"]?.value as? String
|
||||
let retryableOverride = details?["retryable"]?.value as? Bool
|
||||
let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool
|
||||
throw GatewayConnectAuthError(
|
||||
message: msg,
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
recommendedNextStepRaw: recommendedNextStep,
|
||||
requestId: requestId,
|
||||
detailsReason: reason,
|
||||
ownerRaw: owner,
|
||||
titleOverride: title,
|
||||
userMessageOverride: userMessage,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURLString: docsURLString,
|
||||
retryableOverride: retryableOverride,
|
||||
pauseReconnectOverride: pauseReconnectOverride)
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
import Foundation
|
||||
|
||||
public struct GatewayConnectionProblem: Equatable, Sendable {
|
||||
public enum Kind: String, Equatable, Sendable {
|
||||
case gatewayAuthTokenMissing
|
||||
case gatewayAuthTokenMismatch
|
||||
case gatewayAuthTokenNotConfigured
|
||||
case gatewayAuthPasswordMissing
|
||||
case gatewayAuthPasswordMismatch
|
||||
case gatewayAuthPasswordNotConfigured
|
||||
case bootstrapTokenInvalid
|
||||
case deviceTokenMismatch
|
||||
case pairingRequired
|
||||
case pairingRoleUpgradeRequired
|
||||
case pairingScopeUpgradeRequired
|
||||
case pairingMetadataUpgradeRequired
|
||||
case deviceIdentityRequired
|
||||
case deviceSignatureExpired
|
||||
case deviceNonceRequired
|
||||
case deviceNonceMismatch
|
||||
case deviceSignatureInvalid
|
||||
case devicePublicKeyInvalid
|
||||
case deviceIdMismatch
|
||||
case tailscaleIdentityMissing
|
||||
case tailscaleProxyMissing
|
||||
case tailscaleWhoisFailed
|
||||
case tailscaleIdentityMismatch
|
||||
case authRateLimited
|
||||
case timeout
|
||||
case connectionRefused
|
||||
case reachabilityFailed
|
||||
case websocketCancelled
|
||||
case unknown
|
||||
}
|
||||
|
||||
public enum Owner: String, Equatable, Sendable {
|
||||
case gateway
|
||||
case iphone
|
||||
case both
|
||||
case network
|
||||
case unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let owner: Owner
|
||||
public let title: String
|
||||
public let message: String
|
||||
public let actionLabel: String?
|
||||
public let actionCommand: String?
|
||||
public let docsURL: URL?
|
||||
public let requestId: String?
|
||||
public let retryable: Bool
|
||||
public let pauseReconnect: Bool
|
||||
public let technicalDetails: String?
|
||||
|
||||
public init(
|
||||
kind: Kind,
|
||||
owner: Owner,
|
||||
title: String,
|
||||
message: String,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURL: URL? = nil,
|
||||
requestId: String? = nil,
|
||||
retryable: Bool,
|
||||
pauseReconnect: Bool,
|
||||
technicalDetails: String? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.owner = owner
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionLabel = Self.trimmedOrNil(actionLabel)
|
||||
self.actionCommand = Self.trimmedOrNil(actionCommand)
|
||||
self.docsURL = docsURL
|
||||
self.requestId = Self.trimmedOrNil(requestId)
|
||||
self.retryable = retryable
|
||||
self.pauseReconnect = pauseReconnect
|
||||
self.technicalDetails = Self.trimmedOrNil(technicalDetails)
|
||||
}
|
||||
|
||||
public var needsPairingApproval: Bool {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var needsCredentialUpdate: Bool {
|
||||
switch self.kind {
|
||||
case .gatewayAuthTokenMissing,
|
||||
.gatewayAuthTokenMismatch,
|
||||
.gatewayAuthTokenNotConfigured,
|
||||
.gatewayAuthPasswordMissing,
|
||||
.gatewayAuthPasswordMismatch,
|
||||
.gatewayAuthPasswordNotConfigured,
|
||||
.bootstrapTokenInvalid,
|
||||
.deviceTokenMismatch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var statusText: String {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
if let requestId {
|
||||
return "\(self.title) (request ID: \(requestId))"
|
||||
}
|
||||
return self.title
|
||||
default:
|
||||
return self.title
|
||||
}
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayConnectionProblemMapper {
|
||||
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
|
||||
guard let nextProblem = self.rawMap(error) else {
|
||||
return nil
|
||||
}
|
||||
guard let previousProblem else {
|
||||
return nextProblem
|
||||
}
|
||||
if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) {
|
||||
return previousProblem
|
||||
}
|
||||
return nextProblem
|
||||
}
|
||||
|
||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
|
||||
if nextProblem.kind == .websocketCancelled {
|
||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
|
||||
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
if normalized.contains("cancelled") || normalized.contains("canceled") {
|
||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func rawMap(_ error: Error) -> GatewayConnectionProblem? {
|
||||
if let authError = error as? GatewayConnectAuthError {
|
||||
return self.map(authError)
|
||||
}
|
||||
if let responseError = error as? GatewayResponseError {
|
||||
return self.map(responseError)
|
||||
}
|
||||
return self.mapTransportError(error)
|
||||
}
|
||||
|
||||
private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
|
||||
let pairingCommand = self.approvalCommand(requestId: authError.requestId)
|
||||
|
||||
switch authError.detail {
|
||||
case .authTokenMissing:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenMissing,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway token required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway requires an auth token, but this iPhone did not send one.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTokenMismatch:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway token is out of date",
|
||||
message: authError.userMessageOverride
|
||||
?? "The token on this iPhone does not match the gateway token.",
|
||||
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
|
||||
authError: authError)
|
||||
case .authTokenNotConfigured:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenNotConfigured,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Gateway token is not configured",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordMissing:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordMissing,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway password required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway requires a password, but this iPhone did not send one.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordMismatch:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway password is out of date",
|
||||
message: authError.userMessageOverride
|
||||
?? "The saved password on this iPhone does not match the gateway password.",
|
||||
actionLabel: authError.actionLabel ?? "Update password",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordNotConfigured:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordNotConfigured,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Gateway password is not configured",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authBootstrapTokenInvalid:
|
||||
return self.problem(
|
||||
kind: .bootstrapTokenInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Setup code expired",
|
||||
message: authError.userMessageOverride
|
||||
?? "The setup QR or bootstrap token is no longer valid.",
|
||||
actionLabel: authError.actionLabel ?? "Scan QR again",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authDeviceTokenMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceTokenMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway rejected the stored device token for this role.",
|
||||
actionLabel: authError.actionLabel ?? "Repair pairing",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .pairingRequired:
|
||||
return self.pairingProblem(for: authError)
|
||||
case .controlUiDeviceIdentityRequired, .deviceIdentityRequired:
|
||||
return self.problem(
|
||||
kind: .deviceIdentityRequired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure device identity is required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
|
||||
actionLabel: authError.actionLabel ?? "Retry from the app",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthSignatureExpired:
|
||||
return self.problem(
|
||||
kind: .deviceSignatureExpired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake expired",
|
||||
message: authError.userMessageOverride ?? "The device signature is too old to use.",
|
||||
actionLabel: authError.actionLabel ?? "Check iPhone time",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthNonceRequired:
|
||||
return self.problem(
|
||||
kind: .deviceNonceRequired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake is incomplete",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthNonceMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceNonceMismatch,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake did not match",
|
||||
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthSignatureInvalid, .deviceAuthInvalid:
|
||||
return self.problem(
|
||||
kind: .deviceSignatureInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify the identity this iPhone presented.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthPublicKeyInvalid:
|
||||
return self.problem(
|
||||
kind: .devicePublicKeyInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify the public key this iPhone presented.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthDeviceIdMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceIdMismatch,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway rejected the device identity because the device ID did not match.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleIdentityMissing:
|
||||
return self.problem(
|
||||
kind: .tailscaleIdentityMissing,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "This connection expected Tailscale identity headers, but they were not available.",
|
||||
actionLabel: authError.actionLabel ?? "Turn on Tailscale",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleProxyMissing:
|
||||
return self.problem(
|
||||
kind: .tailscaleProxyMissing,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway expected a Tailscale auth proxy, but it was not configured.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleWhoisFailed:
|
||||
return self.problem(
|
||||
kind: .tailscaleWhoisFailed,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify this Tailscale client identity.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleIdentityMismatch:
|
||||
return self.problem(
|
||||
kind: .tailscaleIdentityMismatch,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The forwarded Tailscale identity did not match the verified identity.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authRateLimited:
|
||||
return self.problem(
|
||||
kind: .authRateLimited,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Too many failed attempts",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
|
||||
actionLabel: authError.actionLabel ?? "Wait and retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authRequired, .authUnauthorized, .none:
|
||||
return self.problem(
|
||||
kind: .unknown,
|
||||
owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown,
|
||||
title: authError.titleOverride ?? "Gateway rejected the connection",
|
||||
message: authError.userMessageOverride ?? authError.message,
|
||||
actionLabel: authError.actionLabel,
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: nil),
|
||||
requestId: authError.requestId,
|
||||
retryable: authError.retryableOverride ?? false,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable,
|
||||
authError: authError)
|
||||
}
|
||||
}
|
||||
|
||||
private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? {
|
||||
let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" {
|
||||
let authError = GatewayConnectAuthError(
|
||||
message: responseError.message,
|
||||
detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStepRaw: nil,
|
||||
requestId: self.stringValue(responseError.details["requestId"]?.value),
|
||||
detailsReason: responseError.detailsReason,
|
||||
ownerRaw: nil,
|
||||
titleOverride: nil,
|
||||
userMessageOverride: nil,
|
||||
actionLabel: nil,
|
||||
actionCommand: nil,
|
||||
docsURLString: nil,
|
||||
retryableOverride: nil,
|
||||
pauseReconnectOverride: nil)
|
||||
return self.map(authError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
|
||||
let nsError = error as NSError
|
||||
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
|
||||
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if lower.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
let urlErrorCode = URLError.Code(rawValue: nsError.code)
|
||||
if nsError.domain == URLError.errorDomain {
|
||||
switch urlErrorCode {
|
||||
case .timedOut:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cannotConnectToHost:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .connectionRefused,
|
||||
owner: .network,
|
||||
title: "Gateway refused the connection",
|
||||
message: "The gateway host was reachable, but it refused the connection.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
title: "Gateway is not reachable",
|
||||
message: "OpenClaw could not reach the gateway over the current network.",
|
||||
actionLabel: "Check network",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cancelled:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .websocketCancelled,
|
||||
owner: .network,
|
||||
title: "Connection interrupted",
|
||||
message: "The connection to the gateway was interrupted before setup completed.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lower.contains("timed out") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("connection refused") || lower.contains("refused") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .connectionRefused,
|
||||
owner: .network,
|
||||
title: "Gateway refused the connection",
|
||||
message: "The gateway host was reachable, but it refused the connection.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
title: "Gateway is not reachable",
|
||||
message: "OpenClaw could not reach the gateway over the current network.",
|
||||
actionLabel: "Check network",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("cancelled") || lower.contains("canceled") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .websocketCancelled,
|
||||
owner: .network,
|
||||
title: "Connection interrupted",
|
||||
message: "The connection to the gateway was interrupted before setup completed.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
|
||||
let requestId = authError.requestId
|
||||
let pairingCommand = self.approvalCommand(requestId: requestId)
|
||||
|
||||
switch authError.detailsReason {
|
||||
case "role-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingRoleUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Additional approval required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case "scope-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingScopeUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Additional permissions required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case "metadata-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingMetadataUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Device approval needs refresh",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
default:
|
||||
return self.problem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "This iPhone is not approved yet",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway received the connection request, but this device must be approved first.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
}
|
||||
}
|
||||
|
||||
private static func problem(
|
||||
kind: GatewayConnectionProblem.Kind,
|
||||
owner: GatewayConnectionProblem.Owner,
|
||||
title: String,
|
||||
message: String,
|
||||
actionLabel: String?,
|
||||
actionCommand: String?,
|
||||
docsURL: URL?,
|
||||
requestId: String?,
|
||||
retryable: Bool,
|
||||
pauseReconnect: Bool,
|
||||
authError: GatewayConnectAuthError)
|
||||
-> GatewayConnectionProblem
|
||||
{
|
||||
GatewayConnectionProblem(
|
||||
kind: kind,
|
||||
owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner,
|
||||
title: title,
|
||||
message: message,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURL: docsURL,
|
||||
requestId: requestId,
|
||||
retryable: authError.retryableOverride ?? retryable,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect,
|
||||
technicalDetails: self.technicalDetails(for: authError))
|
||||
}
|
||||
|
||||
private static func approvalCommand(requestId: String?) -> String {
|
||||
if let requestId = self.nonEmpty(requestId) {
|
||||
return "openclaw devices approve \(requestId)"
|
||||
}
|
||||
return "openclaw devices list"
|
||||
}
|
||||
|
||||
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
|
||||
var parts: [String] = []
|
||||
if let detail = self.nonEmpty(authError.detailCodeRaw) {
|
||||
parts.append(detail)
|
||||
}
|
||||
if let reason = self.nonEmpty(authError.detailsReason) {
|
||||
parts.append("reason=\(reason)")
|
||||
}
|
||||
if let requestId = self.nonEmpty(authError.requestId) {
|
||||
parts.append("requestId=\(requestId)")
|
||||
}
|
||||
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
|
||||
parts.append("next=\(nextStep)")
|
||||
}
|
||||
if authError.canRetryWithDeviceToken {
|
||||
parts.append("deviceTokenRetry=true")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
|
||||
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
|
||||
return url
|
||||
}
|
||||
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
|
||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "gateway":
|
||||
return .gateway
|
||||
case "iphone", "ios", "device":
|
||||
return .iphone
|
||||
case "both":
|
||||
return .both
|
||||
case "network":
|
||||
return .network
|
||||
case "unknown", "":
|
||||
return .unknown
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func stringValue(_ value: Any?) -> String? {
|
||||
self.nonEmpty(value as? String)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,32 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||
public let detailCodeRaw: String?
|
||||
public let recommendedNextStepRaw: String?
|
||||
public let canRetryWithDeviceToken: Bool
|
||||
public let requestId: String?
|
||||
public let detailsReason: String?
|
||||
public let ownerRaw: String?
|
||||
public let titleOverride: String?
|
||||
public let userMessageOverride: String?
|
||||
public let actionLabel: String?
|
||||
public let actionCommand: String?
|
||||
public let docsURLString: String?
|
||||
public let retryableOverride: Bool?
|
||||
public let pauseReconnectOverride: Bool?
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCodeRaw: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStepRaw: String? = nil)
|
||||
recommendedNextStepRaw: String? = nil,
|
||||
requestId: String? = nil,
|
||||
detailsReason: String? = nil,
|
||||
ownerRaw: String? = nil,
|
||||
titleOverride: String? = nil,
|
||||
userMessageOverride: String? = nil,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURLString: String? = nil,
|
||||
retryableOverride: Bool? = nil,
|
||||
pauseReconnectOverride: Bool? = nil)
|
||||
{
|
||||
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -59,19 +79,54 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||
self.canRetryWithDeviceToken = canRetryWithDeviceToken
|
||||
self.recommendedNextStepRaw =
|
||||
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
|
||||
self.requestId = Self.trimmedOrNil(requestId)
|
||||
self.detailsReason = Self.trimmedOrNil(detailsReason)
|
||||
self.ownerRaw = Self.trimmedOrNil(ownerRaw)
|
||||
self.titleOverride = Self.trimmedOrNil(titleOverride)
|
||||
self.userMessageOverride = Self.trimmedOrNil(userMessageOverride)
|
||||
self.actionLabel = Self.trimmedOrNil(actionLabel)
|
||||
self.actionCommand = Self.trimmedOrNil(actionCommand)
|
||||
self.docsURLString = Self.trimmedOrNil(docsURLString)
|
||||
self.retryableOverride = retryableOverride
|
||||
self.pauseReconnectOverride = pauseReconnectOverride
|
||||
}
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCode: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStep: String? = nil)
|
||||
recommendedNextStep: String? = nil,
|
||||
requestId: String? = nil,
|
||||
detailsReason: String? = nil,
|
||||
ownerRaw: String? = nil,
|
||||
titleOverride: String? = nil,
|
||||
userMessageOverride: String? = nil,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURLString: String? = nil,
|
||||
retryableOverride: Bool? = nil,
|
||||
pauseReconnectOverride: Bool? = nil)
|
||||
{
|
||||
self.init(
|
||||
message: message,
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
recommendedNextStepRaw: recommendedNextStep,
|
||||
requestId: requestId,
|
||||
detailsReason: detailsReason,
|
||||
ownerRaw: ownerRaw,
|
||||
titleOverride: titleOverride,
|
||||
userMessageOverride: userMessageOverride,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURLString: docsURLString,
|
||||
retryableOverride: retryableOverride,
|
||||
pauseReconnectOverride: pauseReconnectOverride)
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
public var detailCode: String? { self.detailCodeRaw }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@@ -11,4 +12,81 @@ import Testing
|
||||
#expect(error.isNonRecoverable)
|
||||
#expect(error.detail == .authBootstrapTokenInvalid)
|
||||
}
|
||||
|
||||
@Test func connectAuthErrorPreservesStructuredMetadata() {
|
||||
let error = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStep: "review_auth_configuration",
|
||||
requestId: "req-123",
|
||||
detailsReason: "scope-upgrade",
|
||||
ownerRaw: "gateway",
|
||||
titleOverride: "Additional permissions required",
|
||||
userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.",
|
||||
actionLabel: "Approve on gateway",
|
||||
actionCommand: "openclaw devices approve req-123",
|
||||
docsURLString: "https://docs.openclaw.ai/gateway/pairing",
|
||||
retryableOverride: false,
|
||||
pauseReconnectOverride: true)
|
||||
|
||||
#expect(error.requestId == "req-123")
|
||||
#expect(error.detailsReason == "scope-upgrade")
|
||||
#expect(error.ownerRaw == "gateway")
|
||||
#expect(error.titleOverride == "Additional permissions required")
|
||||
#expect(error.actionCommand == "openclaw devices approve req-123")
|
||||
#expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing")
|
||||
#expect(error.pauseReconnectOverride == true)
|
||||
}
|
||||
|
||||
@Test func pairingProblemUsesStructuredRequestMetadata() {
|
||||
let error = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123",
|
||||
detailsReason: "scope-upgrade")
|
||||
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
|
||||
#expect(problem?.kind == .pairingScopeUpgradeRequired)
|
||||
#expect(problem?.requestId == "req-123")
|
||||
#expect(problem?.pauseReconnect == true)
|
||||
#expect(problem?.actionCommand == "openclaw devices approve req-123")
|
||||
}
|
||||
|
||||
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
|
||||
let pairing = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123")
|
||||
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
|
||||
let cancelled = NSError(
|
||||
domain: URLError.errorDomain,
|
||||
code: URLError.cancelled.rawValue,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"])
|
||||
|
||||
let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem)
|
||||
|
||||
#expect(preserved?.kind == .pairingRequired)
|
||||
#expect(preserved?.requestId == "req-123")
|
||||
}
|
||||
|
||||
@Test func unmappedTransportErrorClearsStaleStructuredProblem() {
|
||||
let pairing = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123")
|
||||
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
|
||||
let unknownTransport = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: -1202,
|
||||
userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"])
|
||||
|
||||
let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem)
|
||||
|
||||
#expect(mapped == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
af24bd5a2a86e8bb481302211b35c440e82636585c46f57050648c0290b1d4ee config-baseline.json
|
||||
73bda77ebf7d70609c57f394655332536eb5ff55516a6b7db06243bd4e8e44a5 config-baseline.core.json
|
||||
f7b342080a730da84d1ac84a888e9506d24ee7ce7ec6ec6c0cc4f1897fabcde3 config-baseline.json
|
||||
c3dd9fb8a0059dba411c4d88a6b84ca28af1e0b1925c669058ef9f38c6d2718b config-baseline.core.json
|
||||
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
|
||||
d42cee3dea4668bdb7daf6ff5e6f87f326fdef56a8c3716d73079b92cab6e7b2 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
5e28885aeddb1c2e73040c88b503d584bbcd871c6941fd1ebf7f22ceac3477a6 plugin-sdk-api-baseline.json
|
||||
c8bbc54b51588b6b9aecabb3fcf02ecb69867c8ac527b65d5ec3bc5c6288057a plugin-sdk-api-baseline.jsonl
|
||||
2efa99907731355b31a1b95a6baa9cf5bf8d25c67931837857c9bb9dd39fad95 plugin-sdk-api-baseline.json
|
||||
6c99467113b5d6a015cbd424f2eb5c7e21a6c665b3e8d0372e0e09a2218ef13e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -354,13 +354,5 @@
|
||||
{
|
||||
"source": "Testing",
|
||||
"target": "测试"
|
||||
},
|
||||
{
|
||||
"source": "Capability CLI Alias",
|
||||
"target": "Capability CLI 别名"
|
||||
},
|
||||
{
|
||||
"source": "Inference CLI",
|
||||
"target": "推理 CLI"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -880,7 +880,8 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
|
||||
|
||||
## Exec approvals
|
||||
|
||||
Matrix can act as an exec approval client for a Matrix account.
|
||||
Matrix can act as a native approval client for a Matrix account. The native
|
||||
DM/channel routing knobs still live under exec approval config:
|
||||
|
||||
- `channels.matrix.execApprovals.enabled`
|
||||
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
|
||||
@@ -888,13 +889,14 @@ Matrix can act as an exec approval client for a Matrix account.
|
||||
- `channels.matrix.execApprovals.agentFilter`
|
||||
- `channels.matrix.execApprovals.sessionFilter`
|
||||
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved. Exec approvals use `execApprovals.approvers` first and can fall back to `channels.matrix.dm.allowFrom`. Plugin approvals authorize through `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the approval fallback policy.
|
||||
|
||||
Native Matrix routing is exec-only today:
|
||||
Matrix native routing now supports both approval kinds:
|
||||
|
||||
- `channels.matrix.execApprovals.*` controls native DM/channel routing for exec approvals only.
|
||||
- Plugin approvals still use shared same-chat `/approve` plus any configured `approvals.plugin` forwarding.
|
||||
- Matrix can still reuse `channels.matrix.dm.allowFrom` for plugin-approval authorization when it can infer approvers safely, but it does not expose a separate native plugin-approval DM/channel fanout path.
|
||||
- `channels.matrix.execApprovals.*` controls the native DM/channel fanout mode for Matrix approval prompts.
|
||||
- Exec approvals use the exec approver set from `execApprovals.approvers` or `channels.matrix.dm.allowFrom`.
|
||||
- Plugin approvals use the Matrix DM allowlist from `channels.matrix.dm.allowFrom`.
|
||||
- Matrix reaction shortcuts and message updates apply to both exec and plugin approvals.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
@@ -910,9 +912,9 @@ Matrix approval prompts seed reaction shortcuts on the primary approval message:
|
||||
|
||||
Approvers can react on that message or use the fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
|
||||
|
||||
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
|
||||
Only resolved approvers can approve or deny. For exec approvals, channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
|
||||
|
||||
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface is transport only for exec approvals: room/DM routing and message send/update/delete behavior.
|
||||
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface handles room/DM routing, reactions, and message send/update/delete behavior for both exec and plugin approvals.
|
||||
|
||||
Per-account override:
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
summary: "Compatibility alias page for `openclaw capability`; use the dedicated `openclaw infer` docs"
|
||||
read_when:
|
||||
- Adding or modifying `openclaw infer` commands
|
||||
- Updating compatibility aliases for infer-first automation
|
||||
title: "Capability CLI Alias"
|
||||
---
|
||||
|
||||
# Capability CLI Alias
|
||||
|
||||
`openclaw capability` is a compatibility alias for `openclaw infer`.
|
||||
|
||||
Use the dedicated infer page for the current command surface, examples, and JSON output contract:
|
||||
|
||||
- [Inference CLI](/cli/infer)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
openclaw capability model run --prompt "Reply with exactly: smoke-ok" --json
|
||||
```
|
||||
|
||||
`openclaw capability ...` and `openclaw infer ...` accept the same subcommands today, but docs, scripts, and future examples should target `infer`.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Infer-first CLI for multimodal discuss, generate, convert, transcribe, and edit workflows"
|
||||
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
|
||||
read_when:
|
||||
- Adding or modifying `openclaw infer` commands
|
||||
- Designing stable headless capability automation
|
||||
@@ -8,36 +8,46 @@ title: "Inference CLI"
|
||||
|
||||
# Inference CLI
|
||||
|
||||
`openclaw infer` is the canonical headless surface for provider-backed multimodal workflows.
|
||||
|
||||
`openclaw capability` remains supported as a fallback alias for compatibility.
|
||||
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
|
||||
|
||||
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
|
||||
|
||||
## What infer is for
|
||||
## Turn infer into a skill
|
||||
|
||||
Think about `infer` as the CLI for three broad jobs:
|
||||
Copy and paste this to an agent:
|
||||
|
||||
- Discuss: ask a model, inspect media, transcribe audio, search or fetch web content.
|
||||
- Generate: create images, video, speech, and embeddings.
|
||||
- Edit or transform: mutate an existing artifact when the capability supports it.
|
||||
```text
|
||||
Read https://docs.openclaw.ai/cli/infer, then create a skill that routes my common workflows to `openclaw infer`.
|
||||
Focus on model runs, image generation, video generation, audio transcription, TTS, web search, and embeddings.
|
||||
```
|
||||
|
||||
Today that maps to the current infer surface like this:
|
||||
A good infer-based skill should:
|
||||
|
||||
| Modality | Discuss / inspect | Generate / convert | Edit / transform |
|
||||
| ---------- | --------------------------------------- | ------------------ | ---------------- |
|
||||
| Text | `model run` | - | - |
|
||||
| Image | `image describe`, `image describe-many` | `image generate` | `image edit` |
|
||||
| Audio | `audio transcribe` | `tts convert` | - |
|
||||
| Video | `video describe` | `video generate` | - |
|
||||
| Web | `web search`, `web fetch` | - | - |
|
||||
| Embeddings | - | `embedding create` | - |
|
||||
- map common user intents to the correct infer subcommand
|
||||
- include a few canonical infer examples for the workflows it covers
|
||||
- prefer `openclaw infer ...` in examples and suggestions
|
||||
- avoid re-documenting the entire infer surface inside the skill body
|
||||
|
||||
Current note:
|
||||
Typical infer-focused skill coverage:
|
||||
|
||||
- `infer` already feels like a multimodal discuss and generate surface.
|
||||
- First-class edit support is currently image-focused on this CLI.
|
||||
- Audio and video editing are not exposed as dedicated `infer` commands yet, so docs should not imply they exist.
|
||||
- `openclaw infer model run`
|
||||
- `openclaw infer image generate`
|
||||
- `openclaw infer audio transcribe`
|
||||
- `openclaw infer tts convert`
|
||||
- `openclaw infer web search`
|
||||
- `openclaw infer embedding create`
|
||||
|
||||
## Why use infer
|
||||
|
||||
`openclaw infer` provides one consistent CLI for provider-backed inference tasks inside OpenClaw.
|
||||
|
||||
Benefits:
|
||||
|
||||
- Use the providers and models already configured in OpenClaw instead of wiring up one-off wrappers for each backend.
|
||||
- Keep model, image, audio transcription, TTS, video, web, and embedding workflows under one command tree.
|
||||
- Use a stable `--json` output shape for scripts, automation, and agent-driven workflows.
|
||||
- Prefer a first-party OpenClaw surface when the task is fundamentally "run inference."
|
||||
- Use the normal local path without requiring the gateway for most infer commands.
|
||||
|
||||
## Command tree
|
||||
|
||||
@@ -90,213 +100,139 @@ Current note:
|
||||
providers
|
||||
```
|
||||
|
||||
## Transport
|
||||
## Common tasks
|
||||
|
||||
Supported transport flags:
|
||||
This table maps common inference tasks to the corresponding infer command.
|
||||
|
||||
- `--local`
|
||||
- `--gateway`
|
||||
| Task | Command | Notes |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
|
||||
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
|
||||
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be `<provider/model>` |
|
||||
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
|
||||
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
|
||||
| Generate a video | `openclaw infer video generate --prompt "..." --json` | |
|
||||
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
|
||||
| Search the web | `openclaw infer web search --query "..." --json` | |
|
||||
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
|
||||
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
|
||||
|
||||
Default transport is implicit auto at the command-family level:
|
||||
## Behavior
|
||||
|
||||
- `openclaw infer ...` is the primary CLI surface for these workflows.
|
||||
- Use `--json` when the output will be consumed by another command or script.
|
||||
- Use `--provider` or `--model provider/model` when a specific backend is required.
|
||||
- For `image describe`, `audio transcribe`, and `video describe`, `--model` must use the form `<provider/model>`.
|
||||
- Stateless execution commands default to local.
|
||||
- Gateway-managed state commands default to gateway.
|
||||
- The normal local path does not require the gateway to be running.
|
||||
|
||||
Examples:
|
||||
## Model
|
||||
|
||||
```bash
|
||||
openclaw infer model run --prompt "hello" --json
|
||||
openclaw infer image generate --prompt "friendly lobster" --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
|
||||
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
|
||||
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
|
||||
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
|
||||
openclaw infer tts status --json
|
||||
openclaw infer embedding create --text "hello world" --json
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
These are the primary headless workflows:
|
||||
Use `model` for provider-backed text inference and model/provider inspection.
|
||||
|
||||
```bash
|
||||
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
|
||||
openclaw infer image generate --prompt "friendly lobster illustration" --output ./lobster.png --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
|
||||
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
|
||||
openclaw infer video generate --prompt "cinematic sunset over the ocean" --output ./sunset.mp4 --json
|
||||
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
|
||||
openclaw infer embedding create --text "friendly lobster" --json
|
||||
```
|
||||
|
||||
If you want the shortest mental model:
|
||||
|
||||
- discuss with `model run`
|
||||
- inspect media with `image describe`, `video describe`, and `audio transcribe`
|
||||
- generate media with `image generate`, `video generate`, and `tts convert`
|
||||
- edit existing images with `image edit`
|
||||
|
||||
Use `--model <provider/model>` when you want to pin execution to a specific provider path.
|
||||
|
||||
Maintainers can smoke this CLI surface end-to-end with `pnpm test:live:infer`.
|
||||
|
||||
For discovery and automation bootstrap:
|
||||
|
||||
```bash
|
||||
openclaw infer list --json
|
||||
openclaw infer inspect --name image.generate --json
|
||||
openclaw infer model run --prompt "Summarize this changelog entry" --provider openai --json
|
||||
openclaw infer model providers --json
|
||||
openclaw infer image providers --json
|
||||
openclaw infer audio providers --json
|
||||
openclaw infer tts providers --json
|
||||
openclaw infer video providers --json
|
||||
openclaw infer web providers --json
|
||||
openclaw infer embedding providers --json
|
||||
```
|
||||
|
||||
## Command families
|
||||
|
||||
### `model`
|
||||
|
||||
Use `model run` for one-shot text discussion through the agent runtime.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
|
||||
openclaw infer model run --prompt "Summarize this file" --model openai/gpt-5.4 --json
|
||||
openclaw infer model list --json
|
||||
openclaw infer model inspect --model openai/gpt-5.4 --json
|
||||
openclaw infer model providers --json
|
||||
openclaw infer model auth status --json
|
||||
openclaw infer model inspect --name gpt-5.4 --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `model run` supports `--local` and `--gateway`.
|
||||
- `--model <provider/model>` follows the same provider/model override shape used elsewhere in OpenClaw.
|
||||
- Output includes normalized `provider`, `model`, and `outputs`.
|
||||
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
|
||||
- `model auth login`, `model auth logout`, and `model auth status` manage saved provider auth state.
|
||||
|
||||
### `image`
|
||||
## Image
|
||||
|
||||
Use `image generate` and `image edit` for raster creation and editing. Use `describe` for image discussion and analysis of local files.
|
||||
|
||||
Common commands:
|
||||
Use `image` for generation, edit, and description.
|
||||
|
||||
```bash
|
||||
openclaw infer image generate --prompt "friendly lobster illustration" --output ./lobster.png --json
|
||||
openclaw infer image generate --prompt "poster art" --model openai/gpt-image-1 --size 1024x1024 --json
|
||||
openclaw infer image edit --file ./input.png --prompt "remove the background" --output ./edited.png --json
|
||||
openclaw infer image generate --prompt "friendly lobster illustration" --json
|
||||
openclaw infer image generate --prompt "cinematic product photo of headphones" --json
|
||||
openclaw infer image describe --file ./photo.jpg --json
|
||||
openclaw infer image describe-many --file ./a.jpg --file ./b.jpg --json
|
||||
openclaw infer image providers --json
|
||||
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `generate` supports `--count`, `--size`, `--aspect-ratio`, `--resolution`, and `--output`.
|
||||
- Saved output paths follow the returned bytes, not just the requested extension.
|
||||
- Use `image edit` when starting from existing input files.
|
||||
- For `image describe`, `--model` must be `<provider/model>`.
|
||||
|
||||
### `audio`
|
||||
## Audio
|
||||
|
||||
Use `audio transcribe` for speech-to-text discussion of local audio files.
|
||||
|
||||
Common commands:
|
||||
Use `audio` for file transcription.
|
||||
|
||||
```bash
|
||||
openclaw infer audio transcribe --file ./memo.m4a --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model openai/gpt-4o-transcribe --json
|
||||
openclaw infer audio providers --json
|
||||
openclaw infer audio transcribe --file ./team-sync.m4a --language en --prompt "Focus on names and action items" --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--language` and `--prompt` are request-scoped hints.
|
||||
- `--model <provider/model>` is the safest way to force a provider-backed transcription path.
|
||||
- When a local transcription path returns empty output, `infer audio transcribe` retries on provider-backed auto-detect before failing.
|
||||
- `infer` does not expose first-class audio editing commands today. Audio output generation lives under `tts convert`.
|
||||
- `audio transcribe` is for file transcription, not realtime session management.
|
||||
- `--model` must be `<provider/model>`.
|
||||
|
||||
### `tts`
|
||||
## TTS
|
||||
|
||||
Use `tts convert` for speech generation from text, and the other commands to inspect or mutate TTS state.
|
||||
|
||||
Common commands:
|
||||
Use `tts` for speech synthesis and TTS provider state.
|
||||
|
||||
```bash
|
||||
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
|
||||
openclaw infer tts convert --text "hello from openclaw" --model openai/gpt-4o-mini-tts --voice alloy --json
|
||||
openclaw infer tts voices --provider openai --json
|
||||
openclaw infer tts convert --text "Your build is complete" --output ./build-complete.mp3 --json
|
||||
openclaw infer tts providers --json
|
||||
openclaw infer tts status --json
|
||||
openclaw infer tts set-provider --provider openai --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `convert`, `providers`, `enable`, `disable`, and `set-provider` support `--local` and `--gateway`.
|
||||
- `status` is gateway-only because it reflects gateway-managed prefs state.
|
||||
- `--output` writes the synthesized media to disk and still returns JSON metadata when `--json` is set.
|
||||
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
|
||||
- Use `tts providers`, `tts voices`, and `tts set-provider` to inspect and configure TTS behavior.
|
||||
|
||||
### `video`
|
||||
## Video
|
||||
|
||||
Use `video generate` for creation and `video describe` for local discussion and analysis.
|
||||
|
||||
Common commands:
|
||||
Use `video` for generation and description.
|
||||
|
||||
```bash
|
||||
openclaw infer video generate --prompt "cinematic sunset over the ocean" --output ./sunset.mp4 --json
|
||||
openclaw infer video generate --prompt "city timelapse" --model openai/sora-2 --json
|
||||
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
|
||||
openclaw infer video generate --prompt "slow drone shot over a forest lake" --json
|
||||
openclaw infer video describe --file ./clip.mp4 --json
|
||||
openclaw infer video providers --json
|
||||
openclaw infer video describe --file ./clip.mp4 --model openai/gpt-4.1-mini --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Generated video jobs may take materially longer than text, image, audio, or embedding commands.
|
||||
- `providers` exposes both generation providers and local description providers.
|
||||
- `infer` does not expose first-class video editing commands today.
|
||||
- `--model` must be `<provider/model>` for `video describe`.
|
||||
|
||||
### `web`
|
||||
## Web
|
||||
|
||||
Use `web search` for provider-backed search and `web fetch` for direct URL retrieval.
|
||||
|
||||
Common commands:
|
||||
Use `web` for search and fetch workflows.
|
||||
|
||||
```bash
|
||||
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
|
||||
openclaw infer web search --query "OpenClaw docs" --provider brave --json
|
||||
openclaw infer web fetch --url https://docs.openclaw.ai/ --json
|
||||
openclaw infer web search --query "OpenClaw docs" --json
|
||||
openclaw infer web search --query "OpenClaw infer web providers" --json
|
||||
openclaw infer web fetch --url https://docs.openclaw.ai/cli/infer --json
|
||||
openclaw infer web providers --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `search` supports `--provider` and `--limit`.
|
||||
- `fetch` supports `--provider` and `--format`.
|
||||
- `providers` returns search and fetch provider lists separately.
|
||||
- Use `web providers` to inspect available, configured, and selected providers.
|
||||
|
||||
### `embedding`
|
||||
## Embedding
|
||||
|
||||
Use `embedding create` for one or more input strings and `providers` for discovery.
|
||||
|
||||
Common commands:
|
||||
Use `embedding` for vector creation and embedding provider inspection.
|
||||
|
||||
```bash
|
||||
openclaw infer embedding create --text "friendly lobster" --json
|
||||
openclaw infer embedding create --text "friendly lobster" --text "friendly crab" --json
|
||||
openclaw infer embedding create --text "friendly lobster" --provider openai --model openai/text-embedding-3-small --json
|
||||
openclaw infer embedding create --text "customer support ticket: delayed shipment" --model openai/text-embedding-3-large --json
|
||||
openclaw infer embedding providers --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Repeat `--text` to embed multiple strings in one call.
|
||||
- Output returns one embedding object per input string.
|
||||
|
||||
## JSON output
|
||||
|
||||
Capability commands normalize JSON output under a shared envelope:
|
||||
Infer commands normalize JSON output under a shared envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -321,8 +257,24 @@ Top-level fields are stable:
|
||||
- `outputs`
|
||||
- `error`
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
```bash
|
||||
# Bad
|
||||
openclaw infer media image generate --prompt "friendly lobster"
|
||||
|
||||
# Good
|
||||
openclaw infer image generate --prompt "friendly lobster"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Bad
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model whisper-1 --json
|
||||
|
||||
# Good
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
|
||||
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
|
||||
- `openclaw capability ...` is still accepted as a compatibility alias, but `openclaw infer ...` is the canonical surface for docs, scripts, and examples.
|
||||
- `openclaw capability ...` is an alias for `openclaw infer ...`.
|
||||
|
||||
504
docs/concepts/active-memory.md
Normal file
504
docs/concepts/active-memory.md
Normal file
@@ -0,0 +1,504 @@
|
||||
---
|
||||
title: "Active Memory"
|
||||
summary: "A plugin-owned blocking memory subagent that injects relevant memory into interactive chat sessions"
|
||||
read_when:
|
||||
- You want to understand what active memory is for
|
||||
- You want to turn active memory on for a conversational agent
|
||||
- You want to tune active memory behavior without enabling it everywhere
|
||||
---
|
||||
|
||||
# Active Memory
|
||||
|
||||
Active memory is an optional plugin-owned blocking memory subagent that runs
|
||||
before the main reply for eligible conversational sessions.
|
||||
|
||||
It exists because most memory systems are capable but reactive. They rely on
|
||||
the main agent to decide when to search memory, or on the user to say things
|
||||
like "remember this" or "search memory." By then, the moment where memory would
|
||||
have made the reply feel natural has already passed.
|
||||
|
||||
Active memory gives the system one bounded chance to surface relevant memory
|
||||
before the main reply is generated.
|
||||
|
||||
## Paste This Into Your Agent
|
||||
|
||||
Paste this into your agent if you want it to enable Active Memory with a
|
||||
self-contained, safe-default setup:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This turns the plugin on for the `main` agent, keeps it limited to direct-message
|
||||
style sessions by default, lets it inherit the current session model first, and
|
||||
still allows the built-in remote fallback if no explicit or inherited model is
|
||||
available.
|
||||
|
||||
After that, restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
To inspect it live in a conversation:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
## Turn active memory on
|
||||
|
||||
The safest setup is:
|
||||
|
||||
1. enable the plugin
|
||||
2. target one conversational agent
|
||||
3. keep logging on only while tuning
|
||||
|
||||
Start with this in `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
What this means:
|
||||
|
||||
- `plugins.entries.active-memory.enabled: true` turns the plugin on
|
||||
- `config.agents: ["main"]` opts only the `main` agent into active memory
|
||||
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
|
||||
- if `config.model` is unset, active memory inherits the current session model first
|
||||
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
|
||||
- active memory still runs only on eligible interactive persistent chat sessions
|
||||
|
||||
## How to see it
|
||||
|
||||
Active memory injects hidden system context for the model. It does not expose
|
||||
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
|
||||
|
||||
If you want to see what active memory is doing in a live session, turn verbose
|
||||
mode on for that session:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
With verbose enabled, OpenClaw can show:
|
||||
|
||||
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
|
||||
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
|
||||
|
||||
Those lines are derived from the same active memory pass that feeds the hidden
|
||||
system context, but they are formatted for humans instead of exposing raw prompt
|
||||
markup.
|
||||
|
||||
By default, the blocking memory subagent transcript is temporary and deleted
|
||||
after the run completes.
|
||||
|
||||
Example flow:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
what wings should i order?
|
||||
```
|
||||
|
||||
Expected visible reply shape:
|
||||
|
||||
```text
|
||||
...normal assistant reply...
|
||||
|
||||
🧩 Active Memory: ok 842ms recent 34 chars
|
||||
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
|
||||
```
|
||||
|
||||
## When it runs
|
||||
|
||||
Active memory uses two gates:
|
||||
|
||||
1. **Config opt-in**
|
||||
The plugin must be enabled, and the current agent id must appear in
|
||||
`plugins.entries.active-memory.config.agents`.
|
||||
2. **Strict runtime eligibility**
|
||||
Even when enabled and targeted, active memory only runs for eligible
|
||||
interactive persistent chat sessions.
|
||||
|
||||
The actual rule is:
|
||||
|
||||
```text
|
||||
plugin enabled
|
||||
+
|
||||
agent id targeted
|
||||
+
|
||||
allowed chat type
|
||||
+
|
||||
eligible interactive persistent chat session
|
||||
=
|
||||
active memory runs
|
||||
```
|
||||
|
||||
If any of those fail, active memory does not run.
|
||||
|
||||
## Session types
|
||||
|
||||
`config.allowedChatTypes` controls which kinds of conversations may run Active
|
||||
Memory at all.
|
||||
|
||||
The default is:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
That means Active Memory runs by default in direct-message style sessions, but
|
||||
not in group or channel sessions unless you opt them in explicitly.
|
||||
|
||||
Examples:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group", "channel"]
|
||||
```
|
||||
|
||||
## Where it runs
|
||||
|
||||
Active memory is a conversational enrichment feature, not a platform-wide
|
||||
inference feature.
|
||||
|
||||
| Surface | Runs active memory? |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Headless one-shot runs | No |
|
||||
| Heartbeat/background runs | No |
|
||||
| Generic internal `agent-command` paths | No |
|
||||
| Subagent/internal helper execution | No |
|
||||
|
||||
## Why use it
|
||||
|
||||
Use active memory when:
|
||||
|
||||
- the session is persistent and user-facing
|
||||
- the agent has meaningful long-term memory to search
|
||||
- continuity and personalization matter more than raw prompt determinism
|
||||
|
||||
It works especially well for:
|
||||
|
||||
- stable preferences
|
||||
- recurring habits
|
||||
- long-term user context that should surface naturally
|
||||
|
||||
It is a poor fit for:
|
||||
|
||||
- automation
|
||||
- internal workers
|
||||
- one-shot API tasks
|
||||
- places where hidden personalization would be surprising
|
||||
|
||||
## How it works
|
||||
|
||||
The runtime shape is:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["User Message"] --> Q["Build Memory Query"]
|
||||
Q --> R["Active Memory Blocking Memory Subagent"]
|
||||
R -->|NONE or empty| M["Main Reply"]
|
||||
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
|
||||
I --> M["Main Reply"]
|
||||
```
|
||||
|
||||
The blocking memory subagent can use only:
|
||||
|
||||
- `memory_search`
|
||||
- `memory_get`
|
||||
|
||||
If the connection is weak, it should return `NONE`.
|
||||
|
||||
## Query modes
|
||||
|
||||
`config.queryMode` controls how much conversation the blocking memory subagent sees.
|
||||
|
||||
## Model fallback policy
|
||||
|
||||
If `config.model` is unset, Active Memory tries to resolve a model in this order:
|
||||
|
||||
```text
|
||||
explicit plugin model
|
||||
-> current session model
|
||||
-> agent primary model
|
||||
-> optional built-in remote fallback
|
||||
```
|
||||
|
||||
`config.modelFallbackPolicy` controls the last step.
|
||||
|
||||
Default:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "default-remote"
|
||||
```
|
||||
|
||||
Other option:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "resolved-only"
|
||||
```
|
||||
|
||||
Use `resolved-only` if you want Active Memory to skip recall instead of falling
|
||||
back to the built-in remote default when no explicit or inherited model is
|
||||
available.
|
||||
|
||||
### `message`
|
||||
|
||||
Only the latest user message is sent.
|
||||
|
||||
```text
|
||||
Latest user message only
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want the fastest behavior
|
||||
- you want the strongest bias toward stable preference recall
|
||||
- follow-up turns do not need conversational context
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `3000` to `5000` ms
|
||||
|
||||
### `recent`
|
||||
|
||||
The latest user message plus a small recent conversational tail is sent.
|
||||
|
||||
```text
|
||||
Recent conversation tail:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
|
||||
Latest user message:
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want a better balance of speed and conversational grounding
|
||||
- follow-up questions often depend on the last few turns
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `15000` ms
|
||||
|
||||
### `full`
|
||||
|
||||
The full conversation is sent to the blocking memory subagent.
|
||||
|
||||
```text
|
||||
Full conversation context:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- the strongest recall quality matters more than latency
|
||||
- the conversation contains important setup far back in the thread
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- increase it substantially compared with `message` or `recent`
|
||||
- start around `15000` ms or higher depending on thread size
|
||||
|
||||
In general, timeout should increase with context size:
|
||||
|
||||
```text
|
||||
message < recent < full
|
||||
```
|
||||
|
||||
## Transcript persistence
|
||||
|
||||
Active memory blocking memory subagent runs create a real `session.jsonl`
|
||||
transcript during the blocking memory subagent call.
|
||||
|
||||
By default, that transcript is temporary:
|
||||
|
||||
- it is written to a temp directory
|
||||
- it is used only for the blocking memory subagent run
|
||||
- it is deleted immediately after the run finishes
|
||||
|
||||
If you want to keep those blocking memory subagent transcripts on disk for debugging or
|
||||
inspection, turn persistence on explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "active-memory",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, active memory stores transcripts in a separate directory under the
|
||||
target agent's sessions folder, not in the main user conversation transcript
|
||||
path.
|
||||
|
||||
The default layout is conceptually:
|
||||
|
||||
```text
|
||||
agents/<agent>/sessions/active-memory/<blocking-memory-subagent-session-id>.jsonl
|
||||
```
|
||||
|
||||
You can change the relative subdirectory with `config.transcriptDir`.
|
||||
|
||||
Use this carefully:
|
||||
|
||||
- blocking memory subagent transcripts can accumulate quickly on busy sessions
|
||||
- `full` query mode can duplicate a lot of conversation context
|
||||
- these transcripts contain hidden prompt context and recalled memories
|
||||
|
||||
## Configuration
|
||||
|
||||
All active memory configuration lives under:
|
||||
|
||||
```text
|
||||
plugins.entries.active-memory
|
||||
```
|
||||
|
||||
The most important fields are:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `boolean` | Enables the plugin itself |
|
||||
| `config.agents` | `string[]` | Agent ids that may use active memory |
|
||||
| `config.model` | `string` | Optional blocking memory subagent model ref; when unset, active memory uses the current session model |
|
||||
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory subagent sees |
|
||||
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory subagent |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.logging` | `boolean` | Emits active memory logs while tuning |
|
||||
| `config.persistTranscripts` | `boolean` | Keeps blocking memory subagent transcripts on disk instead of deleting temp files |
|
||||
| `config.transcriptDir` | `string` | Relative blocking memory subagent transcript directory under the agent sessions folder |
|
||||
|
||||
Useful tuning fields:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| ----------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
|
||||
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
|
||||
| `config.recentUserChars` | `number` | Max chars per recent user turn |
|
||||
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
|
||||
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
|
||||
|
||||
## Recommended setup
|
||||
|
||||
Start with `recent`.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If you want to inspect live behavior while tuning, use `/verbose on` in the
|
||||
session instead of looking for a separate active-memory debug command.
|
||||
|
||||
Then move to:
|
||||
|
||||
- `message` if you want lower latency
|
||||
- `full` if you decide extra context is worth the slower blocking memory subagent
|
||||
|
||||
## Debugging
|
||||
|
||||
If active memory is not showing up where you expect:
|
||||
|
||||
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
|
||||
2. Confirm the current agent id is listed in `config.agents`.
|
||||
3. Confirm you are testing through an interactive persistent chat session.
|
||||
4. Turn on `config.logging: true` and watch the gateway logs.
|
||||
5. Verify memory search itself works with `openclaw memory status --deep`.
|
||||
|
||||
If memory hits are noisy, tighten:
|
||||
|
||||
- `maxSummaryChars`
|
||||
|
||||
If active memory is too slow:
|
||||
|
||||
- lower `queryMode`
|
||||
- lower `timeoutMs`
|
||||
- reduce recent turn counts
|
||||
- reduce per-turn char caps
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Memory Search](/concepts/memory-search)
|
||||
- [Memory configuration reference](/reference/memory-config)
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
@@ -41,6 +41,71 @@ Before compacting, OpenClaw automatically reminds the agent to save important
|
||||
notes to [memory](/concepts/memory) files. This prevents context loss.
|
||||
</Info>
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "openrouter/anthropic/claude-sonnet-4-6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "ollama/llama3.1:8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When unset, compaction uses the agent’s primary model.
|
||||
|
||||
## Pluggable compaction providers
|
||||
|
||||
Plugins can register a custom compaction provider via `registerCompactionProvider()` on the plugin API. When a provider is registered and configured, OpenClaw delegates summarization to it instead of the built-in LLM pipeline.
|
||||
|
||||
To use a registered provider, set the provider id in your config:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"provider": "my-provider"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Setting a `provider` automatically forces `mode: "safeguard"`. Providers receive the same compaction instructions and identifier-preservation policy as the built-in path, and OpenClaw still preserves recent-turn and split-turn suffix context after provider output. If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
When a session nears or exceeds the model’s context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
You’ll see:
|
||||
|
||||
- `🧹 Auto-compaction complete` in verbose mode
|
||||
- `/status` showing `🧹 Compactions: <count>`
|
||||
|
||||
Before compaction, OpenClaw can run a **silent memory flush** turn to store
|
||||
durable notes to disk. See [Memory](/concepts/memory) for details and config.
|
||||
|
||||
## Manual compaction
|
||||
|
||||
Type `/compact` in any chat to force a compaction. Add instructions to guide
|
||||
|
||||
@@ -138,5 +138,6 @@ earlier conversations. This is opt-in via
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions
|
||||
- [Memory](/concepts/memory) -- file layout, backends, tools
|
||||
- [Memory configuration reference](/reference/memory-config) -- all config knobs
|
||||
|
||||
@@ -1452,7 +1452,6 @@
|
||||
"cli/agent",
|
||||
"cli/agents",
|
||||
"cli/hooks",
|
||||
"cli/infer",
|
||||
"cli/memory",
|
||||
"cli/message",
|
||||
"cli/models",
|
||||
|
||||
@@ -1160,6 +1160,7 @@ Periodic heartbeat runs.
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
provider: "my-provider", // id of a registered compaction provider plugin (optional)
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
@@ -1180,6 +1181,7 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
|
||||
@@ -381,16 +381,18 @@ implemented in `src/gateway/server-methods/*.ts`.
|
||||
|
||||
#### Approval families
|
||||
|
||||
- `exec.approval.request` and `exec.approval.resolve` cover one-shot exec
|
||||
approval requests.
|
||||
- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and
|
||||
`exec.approval.resolve` cover one-shot exec approval requests plus pending
|
||||
approval lookup/replay.
|
||||
- `exec.approval.waitDecision` waits on one pending exec approval and returns
|
||||
the final decision (or `null` on timeout).
|
||||
- `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval
|
||||
policy snapshots.
|
||||
- `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec
|
||||
approval policy via node relay commands.
|
||||
- `plugin.approval.request`, `plugin.approval.waitDecision`, and
|
||||
`plugin.approval.resolve` cover plugin-defined approval flows.
|
||||
- `plugin.approval.request`, `plugin.approval.list`,
|
||||
`plugin.approval.waitDecision`, and `plugin.approval.resolve` cover
|
||||
plugin-defined approval flows.
|
||||
|
||||
#### Other major families
|
||||
|
||||
|
||||
@@ -2254,7 +2254,7 @@ for usage/billing and raise limits as needed.
|
||||
Quickest setup:
|
||||
|
||||
1. Install Ollama from `https://ollama.com/download`
|
||||
2. Pull a local model such as `ollama pull glm-4.7-flash`
|
||||
2. Pull a local model such as `ollama pull gemma4`
|
||||
3. If you want cloud models too, run `ollama signin`
|
||||
4. Run `openclaw onboard` and choose `Ollama`
|
||||
5. Pick `Local` or `Cloud + Local`
|
||||
|
||||
@@ -568,32 +568,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
|
||||
- `pnpm test:live:media music --quiet`
|
||||
|
||||
## Infer live harness
|
||||
|
||||
- Command: `pnpm test:live:infer`
|
||||
- Live file: `src/cli/capability-cli.live.test.ts`
|
||||
- Purpose:
|
||||
- Runs the `openclaw infer` CLI surface end-to-end instead of only provider runtimes
|
||||
- Reuses `scripts/test-live.mjs` so quiet mode, heartbeat output, and temp live-home behavior stay consistent
|
||||
- Auto-loads missing provider env vars from `~/.profile`
|
||||
- Narrows suites to providers with usable auth by default
|
||||
- Current suite coverage:
|
||||
- discovery: `infer list`, `infer inspect`
|
||||
- discuss: `model run`, `image describe`, `audio transcribe`, `video describe`
|
||||
- generate or convert: `image generate`, `tts convert`, `video generate`, `embedding create`
|
||||
- edit: `image edit`
|
||||
- web: `web providers`, `web search`, `web fetch`
|
||||
- Media discuss note:
|
||||
- `image describe` and `video describe` run as best-effort probes by default because some maintainer setups have generation auth but no separate media-discussion provider path.
|
||||
- Set `OPENCLAW_LIVE_INFER_REQUIRE_MEDIA_DISCUSS=1` to make those probes hard requirements.
|
||||
- Current non-goals:
|
||||
- `infer` does not expose dedicated audio-edit or video-edit commands yet, so the harness does not pretend those lanes exist
|
||||
- Examples:
|
||||
- `pnpm test:live:infer`
|
||||
- `pnpm test:live:infer image video --providers openai,google`
|
||||
- `pnpm test:live:infer audio --audio-providers deepgram,openai --all-providers`
|
||||
- `pnpm test:live:infer web --quiet`
|
||||
|
||||
## Docker runners (optional "works in Linux" checks)
|
||||
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
@@ -1134,6 +1134,9 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/channel-config-schema`,
|
||||
`openclaw/plugin-sdk/telegram-command-config`,
|
||||
`openclaw/plugin-sdk/channel-policy`,
|
||||
`openclaw/plugin-sdk/approval-gateway-runtime`,
|
||||
`openclaw/plugin-sdk/approval-handler-adapter-runtime`,
|
||||
`openclaw/plugin-sdk/approval-handler-runtime`,
|
||||
`openclaw/plugin-sdk/approval-runtime`,
|
||||
`openclaw/plugin-sdk/config-runtime`,
|
||||
`openclaw/plugin-sdk/infra-runtime`,
|
||||
@@ -1152,9 +1155,9 @@ authoring plugins:
|
||||
assistant-visible-text stripping, markdown render/chunking helpers, redaction
|
||||
helpers, directive-tag helpers, and safe-text utilities.
|
||||
- Approval-specific channel seams should prefer one `approvalCapability`
|
||||
contract on the plugin. Core then reads approval auth, delivery, render, and
|
||||
native-routing behavior through that one capability instead of mixing
|
||||
approval behavior into unrelated plugin fields.
|
||||
contract on the plugin. Core then reads approval auth, delivery, render,
|
||||
native-routing, and lazy native-handler behavior through that one capability
|
||||
instead of mixing approval behavior into unrelated plugin fields.
|
||||
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
|
||||
compatibility shim for older plugins. New code should import the narrower
|
||||
generic primitives instead, and repo code should not add new imports of the
|
||||
|
||||
@@ -60,22 +60,34 @@ Most channel plugins do not need approval-specific code.
|
||||
|
||||
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
|
||||
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
|
||||
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
|
||||
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
|
||||
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
|
||||
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
|
||||
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
|
||||
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
|
||||
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
|
||||
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
|
||||
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
|
||||
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
|
||||
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
|
||||
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
|
||||
- `availability` — whether the account is configured and whether a request should be handled
|
||||
- `presentation` — map the shared approval view model into pending/resolved/expired native payloads or final actions
|
||||
- `transport` — prepare targets plus send/update/delete native approval messages
|
||||
- `interactions` — optional bind/unbind/clear-action hooks for native buttons or reactions
|
||||
- `observe` — optional delivery diagnostics hooks
|
||||
- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through `openclaw/plugin-sdk/channel-runtime-context`. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue.
|
||||
- Reach for the lower-level `createChannelApprovalHandler` or `createChannelNativeApprovalRuntime` only when the capability-driven seam is not expressive enough yet.
|
||||
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.
|
||||
- Core now owns approval reroute notices too. Channel plugins should not send their own "approval went to DMs / another channel" follow-up messages from `createChannelNativeApprovalRuntime`; instead, expose accurate origin + approver-DM routing through the shared approval capability helpers and let core aggregate actual deliveries before posting any notice back to the initiating chat.
|
||||
- Preserve the delivered approval id kind end-to-end. Native clients should not
|
||||
guess or rewrite exec vs plugin approval routing from channel-local state.
|
||||
- Different approval kinds can intentionally expose different native surfaces.
|
||||
Current bundled examples:
|
||||
- Slack keeps native approval routing available for both exec and plugin ids.
|
||||
- Matrix keeps native DM/channel routing for exec approvals only and leaves
|
||||
plugin approvals on the shared same-chat `/approve` path.
|
||||
- Matrix keeps the same native DM/channel routing and reaction UX for exec
|
||||
and plugin approvals, while still letting auth differ by approval kind.
|
||||
- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin.
|
||||
|
||||
For hot channel entrypoints, prefer the narrower runtime subpaths when you only
|
||||
@@ -84,8 +96,12 @@ need one part of that family:
|
||||
- `openclaw/plugin-sdk/approval-auth-runtime`
|
||||
- `openclaw/plugin-sdk/approval-client-runtime`
|
||||
- `openclaw/plugin-sdk/approval-delivery-runtime`
|
||||
- `openclaw/plugin-sdk/approval-gateway-runtime`
|
||||
- `openclaw/plugin-sdk/approval-handler-adapter-runtime`
|
||||
- `openclaw/plugin-sdk/approval-handler-runtime`
|
||||
- `openclaw/plugin-sdk/approval-native-runtime`
|
||||
- `openclaw/plugin-sdk/approval-reply-runtime`
|
||||
- `openclaw/plugin-sdk/channel-runtime-context`
|
||||
|
||||
Likewise, prefer `openclaw/plugin-sdk/setup-runtime`,
|
||||
`openclaw/plugin-sdk/setup-adapter-runtime`,
|
||||
|
||||
@@ -67,6 +67,32 @@ Current bundled provider examples:
|
||||
## How to migrate
|
||||
|
||||
<Steps>
|
||||
<Step title="Migrate approval-native handlers to capability facts">
|
||||
Approval-capable channel plugins now expose native approval behavior through
|
||||
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.
|
||||
|
||||
Key changes:
|
||||
|
||||
- Replace `approvalCapability.handler.loadRuntime(...)` with
|
||||
`approvalCapability.nativeRuntime`
|
||||
- Move approval-specific auth/delivery off legacy `plugin.auth` /
|
||||
`plugin.approvals` wiring and onto `approvalCapability`
|
||||
- `ChannelPlugin.approvals` has been removed from the public channel-plugin
|
||||
contract; move delivery/native/render fields onto `approvalCapability`
|
||||
- `plugin.auth` remains for channel login/logout flows only; approval auth
|
||||
hooks there are no longer read by core
|
||||
- Register channel-owned runtime objects such as clients, tokens, or Bolt
|
||||
apps through `openclaw/plugin-sdk/channel-runtime-context`
|
||||
- Do not send plugin-owned reroute notices from native approval handlers;
|
||||
core now owns routed-elsewhere notices from actual delivery results
|
||||
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
|
||||
real `createPluginRuntime().channel` surface. Partial stubs are rejected.
|
||||
|
||||
See `/plugins/sdk-channel-plugins` for the current approval capability
|
||||
layout.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Audit Windows wrapper fallback behavior">
|
||||
If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows
|
||||
`.cmd`/`.bat` wrappers now fail closed unless you explicitly pass
|
||||
@@ -201,8 +227,12 @@ Current bundled provider examples:
|
||||
| `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth |
|
||||
| `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers |
|
||||
| `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters |
|
||||
| `plugin-sdk/approval-gateway-runtime` | Approval gateway helpers | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Approval adapter helpers | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Approval handler helpers | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
|
||||
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
|
||||
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
|
||||
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
|
||||
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |
|
||||
|
||||
@@ -151,6 +151,9 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
|
||||
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
|
||||
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |
|
||||
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
|
||||
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers |
|
||||
@@ -172,6 +175,7 @@ explicitly promotes one as public.
|
||||
| --- | --- |
|
||||
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
|
||||
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
|
||||
| `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers |
|
||||
| `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers |
|
||||
|
||||
@@ -57,7 +57,7 @@ openclaw onboard --non-interactive \
|
||||
2. Pull a local model if you want local inference:
|
||||
|
||||
```bash
|
||||
ollama pull glm-4.7-flash
|
||||
ollama pull gemma4
|
||||
# or
|
||||
ollama pull gpt-oss:20b
|
||||
# or
|
||||
@@ -78,12 +78,12 @@ openclaw onboard
|
||||
|
||||
- `Local`: local models only
|
||||
- `Cloud + Local`: local models plus cloud models
|
||||
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
|
||||
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` do **not** require a local `ollama pull`
|
||||
|
||||
OpenClaw currently suggests:
|
||||
|
||||
- local default: `glm-4.7-flash`
|
||||
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
|
||||
- local default: `gemma4`
|
||||
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`
|
||||
|
||||
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
|
||||
|
||||
@@ -99,7 +99,7 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
|
||||
```bash
|
||||
openclaw models list
|
||||
openclaw models set ollama/glm-4.7-flash
|
||||
openclaw models set ollama/gemma4
|
||||
```
|
||||
|
||||
7. Or set the default in config:
|
||||
@@ -108,7 +108,7 @@ openclaw models set ollama/glm-4.7-flash
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ollama/glm-4.7-flash" },
|
||||
model: { primary: "ollama/gemma4" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -229,7 +229,7 @@ Once configured, all your Ollama models are available:
|
||||
|
||||
## Cloud models
|
||||
|
||||
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models.
|
||||
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`) alongside your local models.
|
||||
|
||||
To use cloud models, select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
|
||||
|
||||
@@ -355,7 +355,7 @@ To add models:
|
||||
|
||||
```bash
|
||||
ollama list # See what's installed
|
||||
ollama pull glm-4.7-flash
|
||||
ollama pull gemma4
|
||||
ollama pull gpt-oss:20b
|
||||
ollama pull llama3.3 # Or another model
|
||||
```
|
||||
|
||||
@@ -17,10 +17,22 @@ conceptual overviews, see:
|
||||
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
|
||||
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
|
||||
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
|
||||
- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions
|
||||
|
||||
All memory search settings live under `agents.defaults.memorySearch` in
|
||||
`openclaw.json` unless noted otherwise.
|
||||
|
||||
If you are looking for the **active memory** feature toggle and sidecar config,
|
||||
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
|
||||
|
||||
Active memory uses a two-gate model:
|
||||
|
||||
1. the plugin must be enabled and target the current agent id
|
||||
2. the request must be an eligible interactive persistent chat session
|
||||
|
||||
See [Active Memory](/concepts/active-memory) for the activation model,
|
||||
plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
|
||||
---
|
||||
|
||||
## Provider selection
|
||||
|
||||
@@ -275,6 +275,21 @@ Implementation: `ensurePiCompactionReserveTokens()` in `src/agents/pi-settings.t
|
||||
|
||||
---
|
||||
|
||||
## Pluggable compaction providers
|
||||
|
||||
Plugins can register a compaction provider via `registerCompactionProvider()` on the plugin API. When `agents.defaults.compaction.provider` is set to a registered provider id, the safeguard extension delegates summarization to that provider instead of the built-in `summarizeInStages` pipeline.
|
||||
|
||||
- `provider`: id of a registered compaction provider plugin. Leave unset for default LLM summarization.
|
||||
- Setting a `provider` forces `mode: "safeguard"`.
|
||||
- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path.
|
||||
- The safeguard still preserves recent-turn and split-turn suffix context after provider output.
|
||||
- If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically.
|
||||
- Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation.
|
||||
|
||||
Source: `src/plugins/compaction-provider.ts`, `src/agents/pi-hooks/compaction-safeguard.ts`.
|
||||
|
||||
---
|
||||
|
||||
## User-visible surfaces
|
||||
|
||||
You can observe compaction and session state via:
|
||||
|
||||
@@ -557,8 +557,8 @@ Shared behavior:
|
||||
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
|
||||
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
|
||||
without a second Slack-local fallback layer
|
||||
- Matrix native DM/channel routing is exec-only; Matrix plugin approvals stay on the shared
|
||||
same-chat `/approve` and optional `approvals.plugin` forwarding paths
|
||||
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
|
||||
plugin authorization still comes from `channels.matrix.dm.allowFrom`
|
||||
- the requester does not need to be an approver
|
||||
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
|
||||
- native Discord approval buttons route by approval id kind: `plugin:` ids go
|
||||
|
||||
862
extensions/active-memory/index.test.ts
Normal file
862
extensions/active-memory/index.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sessionStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
};
|
||||
return {
|
||||
sessionStore,
|
||||
updateSessionStore: vi.fn(
|
||||
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
|
||||
updater(sessionStore);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("active-memory plugin", () => {
|
||||
const hooks: Record<string, Function> = {};
|
||||
const runEmbeddedPiAgent = vi.fn();
|
||||
const api: any = {
|
||||
pluginConfig: {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
},
|
||||
config: {},
|
||||
id: "active-memory",
|
||||
name: "Active Memory",
|
||||
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||
runtime: {
|
||||
agent: {
|
||||
runEmbeddedPiAgent,
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
|
||||
loadSessionStore: vi.fn(() => hoisted.sessionStore),
|
||||
saveSessionStore: vi.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
on: vi.fn((hookName: string, handler: Function) => {
|
||||
hooks[hookName] = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
hoisted.sessionStore["agent:main:main"] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
for (const key of Object.keys(hooks)) {
|
||||
delete hooks[key];
|
||||
}
|
||||
runEmbeddedPiAgent.mockResolvedValue({
|
||||
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
|
||||
});
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers a before_prompt_build hook", () => {
|
||||
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not run for agents that are not explicitly targeted", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "support",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:support:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "support",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:support:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run for non-interactive contexts", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "heartbeat",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to direct-style sessions only", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should we order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:group:-100123",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs for group sessions when group chat types are explicitly allowed", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct", "group"],
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should we order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:group:-100123",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
});
|
||||
|
||||
it("injects system context on a successful recall hit", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order?",
|
||||
messages: [
|
||||
{ role: "user", content: "i want something greasy tonight" },
|
||||
{ role: "assistant", content: "let's narrow it down" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"lemon pepper wings",
|
||||
);
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4-mini",
|
||||
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves leading digits in recalled memory bullets", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- 2024 trip to tokyo\n- 2% milk" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"2024 trip to tokyo",
|
||||
);
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
|
||||
});
|
||||
|
||||
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what should i grab on the way?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:direct:12345:thread:99",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
|
||||
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the current session model when no plugin model is configured", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? temp transcript", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
modelProviderId: "qwen",
|
||||
modelId: "glm-5",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "qwen",
|
||||
model: "glm-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable default remote model fallback", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? no fallback", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:resolved-only",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a readable debug summary alongside the status line", async () => {
|
||||
const sessionKey = "agent:main:debug";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order?",
|
||||
messages: [],
|
||||
},
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(hoisted.updateSessionStore).toHaveBeenCalled();
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
|
||||
{
|
||||
pluginId: "active-memory",
|
||||
lines: expect.arrayContaining([
|
||||
expect.stringContaining("🧩 Active Memory: ok"),
|
||||
expect.stringContaining("🔎 Active Memory Debug: lemon pepper wings"),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces stale legacy active-memory lines on a later empty run", async () => {
|
||||
const sessionKey = "agent:main:legacy-active-memory-lines";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginStatusLines: [
|
||||
"Active Memory: ok 13.4s recent 1 mem",
|
||||
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
|
||||
"Other Plugin: keep me",
|
||||
],
|
||||
};
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "NONE" }],
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what's up with you?", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginStatusLines: [
|
||||
"Active Memory: ok 13.4s recent 1 mem",
|
||||
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
|
||||
"Other Plugin: keep me",
|
||||
],
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
|
||||
{
|
||||
pluginId: "active-memory",
|
||||
lines: [expect.stringContaining("🧩 Active Memory: empty")],
|
||||
},
|
||||
]);
|
||||
expect(store[sessionKey]?.pluginStatusLines).toEqual(["Other Plugin: keep me"]);
|
||||
});
|
||||
|
||||
it("returns nothing when the sidecar says none", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "NONE" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache timeout results", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: 250,
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
let lastAbortSignal: AbortSignal | undefined;
|
||||
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
|
||||
lastAbortSignal = params.abortSignal;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const abortHandler = () => reject(new Error("aborted"));
|
||||
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||
setTimeout(() => {
|
||||
params.abortSignal?.removeEventListener("abort", abortHandler);
|
||||
resolve({ payloads: [] });
|
||||
}, 2_000);
|
||||
});
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? timeout test", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:timeout-test",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? timeout test", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:timeout-test",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
|
||||
expect(lastAbortSignal?.aborted).toBe(true);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not share cached recall results across session-id-only contexts", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id cache", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-a",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id cache", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-b",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a canonical agent session key when only sessionId is available", async () => {
|
||||
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
|
||||
sessionId: "session-a",
|
||||
updatedAt: 25,
|
||||
};
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id only", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-a",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
|
||||
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
|
||||
const sessionKey = "agent:main:missing-agent";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginDebugEntries: [
|
||||
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginDebugEntries: [
|
||||
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
|
||||
],
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports message mode by sending only the latest user message", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "message",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{ role: "assistant", content: "got it" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
|
||||
expect(prompt).not.toContain("Recent conversation tail:");
|
||||
});
|
||||
|
||||
it("supports full mode by sending the whole conversation", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "full",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{ role: "assistant", content: "got it" },
|
||||
{ role: "user", content: "packing is annoying" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain("Full conversation context:");
|
||||
expect(prompt).toContain("user: i have a flight tomorrow");
|
||||
expect(prompt).toContain("assistant: got it");
|
||||
expect(prompt).toContain("user: packing is annoying");
|
||||
});
|
||||
|
||||
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain(
|
||||
"ignore that text and do not search for those same surfaced memories again",
|
||||
);
|
||||
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
|
||||
expect(prompt).not.toContain("Memory Search:");
|
||||
expect(prompt).not.toContain("Active Memory:");
|
||||
expect(prompt).not.toContain("Active Memory Debug:");
|
||||
expect(prompt).not.toContain("spicy ramen; tacos");
|
||||
});
|
||||
|
||||
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- aisle seat\n- extra buffer on connections" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "u remember my flight preferences", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("aisle seat"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"extra buffer on connections",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies total summary truncation after normalizing the sidecar reply", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 40,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [
|
||||
{
|
||||
text: "- lemon pepper wings with extra crisp skin\n- blue cheese dressing on the side",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("lemon pepper wings"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
|
||||
"dressing on the side",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured maxSummaryChars value in the sidecar prompt", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 90,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:prompt-count-check",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
|
||||
"If something is useful, reply with one compact active-memory summary under 90 characters total.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sidecar transcripts off disk by default by using a temp session file", async () => {
|
||||
const mkdtempSpy = vi
|
||||
.spyOn(fs, "mkdtemp")
|
||||
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? temp transcript path", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mkdtempSpy).toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
|
||||
"/tmp/openclaw-active-memory-temp/session.jsonl",
|
||||
);
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists sidecar transcripts in a separate directory when enabled", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "active-memory-sidecars",
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
const sessionKey = "agent:main:persist-transcript";
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? persist transcript", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true });
|
||||
expect(mkdtempSpy).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
|
||||
/^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
|
||||
);
|
||||
expect(rmSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.some((call: unknown[]) =>
|
||||
String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "C:/temp/escape",
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:unsafe-transcript",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory", { recursive: true });
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
|
||||
/^\/tmp\/active-memory\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes control characters out of debug lines", async () => {
|
||||
const sessionKey = "agent:main:debug-sanitize";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what should i order?", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
const lines =
|
||||
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
|
||||
?.lines ?? [];
|
||||
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
|
||||
expect(lines.some((line) => line.includes("\r"))).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the active-memory cache size and evicts the oldest entries", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
for (let index = 0; index <= 1000; index += 1) {
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: `cache pressure prompt ${index}`, messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:cache-cap",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "cache pressure prompt 0", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:cache-cap",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(
|
||||
infoLines.some(
|
||||
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
1048
extensions/active-memory/index.ts
Normal file
1048
extensions/active-memory/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
extensions/active-memory/openclaw.plugin.json
Normal file
82
extensions/active-memory/openclaw.plugin.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "active-memory",
|
||||
"name": "Active Memory",
|
||||
"description": "Runs a bounded blocking memory subagent before eligible conversational replies and injects relevant memory into prompt context.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "group", "channel"]
|
||||
}
|
||||
},
|
||||
"timeoutMs": { "type": "integer", "minimum": 250 },
|
||||
"queryMode": {
|
||||
"type": "string",
|
||||
"enum": ["message", "recent", "full"]
|
||||
},
|
||||
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
|
||||
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"agents": {
|
||||
"label": "Target Agents",
|
||||
"help": "Explicit agent ids that may use active memory."
|
||||
},
|
||||
"model": {
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory subagent."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)"
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
"help": "Choose whether the blocking memory subagent sees only the latest user message, a small recent tail, or the full conversation."
|
||||
},
|
||||
"maxSummaryChars": {
|
||||
"label": "Max Summary Characters",
|
||||
"help": "Maximum total characters allowed in the active-memory summary."
|
||||
},
|
||||
"logging": {
|
||||
"label": "Enable Logging",
|
||||
"help": "Emit active memory timing and result logs."
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory subagent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-discovery");
|
||||
|
||||
@@ -69,7 +72,7 @@ function buildCacheKey(params: {
|
||||
}
|
||||
|
||||
function includesTextModalities(modalities?: Array<string>): boolean {
|
||||
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
|
||||
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
|
||||
}
|
||||
|
||||
function isActive(summary: BedrockModelSummary): boolean {
|
||||
@@ -81,7 +84,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
|
||||
const inputs = summary.inputModalities ?? [];
|
||||
const mapped = new Set<"text" | "image">();
|
||||
for (const modality of inputs) {
|
||||
const lower = modality.toLowerCase();
|
||||
const lower = normalizeOptionalLowercaseString(modality);
|
||||
if (lower === "text") {
|
||||
mapped.add("text");
|
||||
}
|
||||
@@ -96,7 +99,9 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
|
||||
}
|
||||
|
||||
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
|
||||
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
|
||||
const haystack = normalizeLowercaseStringOrEmpty(
|
||||
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
|
||||
);
|
||||
return haystack.includes("reasoning") || haystack.includes("thinking");
|
||||
}
|
||||
|
||||
@@ -256,7 +261,9 @@ function resolveInferenceProfiles(
|
||||
const models = profile.models ?? [];
|
||||
const matchesFilter = models.some((m) => {
|
||||
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
|
||||
return provider ? providerFilter.includes(provider.toLowerCase()) : false;
|
||||
return provider
|
||||
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
|
||||
: false;
|
||||
});
|
||||
if (!matchesFilter) {
|
||||
continue;
|
||||
@@ -265,7 +272,9 @@ function resolveInferenceProfiles(
|
||||
|
||||
// Look up the underlying foundation model to inherit its capabilities.
|
||||
const baseModelId = resolveBaseModelId(profile);
|
||||
const baseModel = baseModelId ? foundationModels.get(baseModelId.toLowerCase()) : undefined;
|
||||
const baseModel = baseModelId
|
||||
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
|
||||
: undefined;
|
||||
|
||||
discovered.push({
|
||||
id: profile.inferenceProfileId,
|
||||
@@ -356,8 +365,9 @@ export async function discoverBedrockModels(params: {
|
||||
maxTokens: defaultMaxTokens,
|
||||
});
|
||||
discovered.push(def);
|
||||
seenIds.add(def.id.toLowerCase());
|
||||
foundationModels.set(def.id.toLowerCase(), def);
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
|
||||
seenIds.add(normalizedId);
|
||||
foundationModels.set(normalizedId, def);
|
||||
}
|
||||
|
||||
// Merge inference profiles — inherit capabilities from foundation models.
|
||||
@@ -368,9 +378,10 @@ export async function discoverBedrockModels(params: {
|
||||
foundationModels,
|
||||
);
|
||||
for (const profile of inferenceProfiles) {
|
||||
if (!seenIds.has(profile.id.toLowerCase())) {
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
|
||||
if (!seenIds.has(normalizedId)) {
|
||||
discovered.push(profile);
|
||||
seenIds.add(profile.id.toLowerCase());
|
||||
seenIds.add(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ function resolveAnthropic46ForwardCompatModel(params: {
|
||||
fallbackTemplateIds: readonly string[];
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = params.ctx.modelId.trim();
|
||||
const lower = trimmedModelId.toLowerCase();
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
|
||||
const is46Model =
|
||||
lower === params.dashModelId ||
|
||||
lower === params.dotModelId ||
|
||||
@@ -247,6 +247,16 @@ function resolveAnthropicForwardCompatModel(
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
|
||||
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesAnthropicModernModel(modelId: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
@@ -468,11 +478,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
wrapStreamFn: wrapAnthropicProviderStream,
|
||||
resolveDefaultThinkingLevel: ({ modelId }) =>
|
||||
matchesAnthropicModernModel(modelId) &&
|
||||
(modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
|
||||
modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
|
||||
modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
|
||||
modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID))
|
||||
matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
|
||||
? "adaptive"
|
||||
: undefined,
|
||||
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
|
||||
|
||||
82
extensions/bluebubbles/src/account-resolve.test.ts
Normal file
82
extensions/bluebubbles/src/account-resolve.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
|
||||
describe("resolveBlueBubblesServerAccount", () => {
|
||||
it("respects an explicit private-network opt-out for loopback server URLs", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a legacy per-account opt-in override a channel-level canonical default", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
accountId: "personal",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
accounts: {
|
||||
personal: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
accountId: "personal",
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
allowPrivateNetworkConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses accounts.default config for the default BlueBubbles account", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
accounts: {
|
||||
default: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
allowPrivateNetworkConfig: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
resolveBlueBubblesAccount,
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork,
|
||||
resolveBlueBubblesPrivateNetworkConfigValue,
|
||||
} from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -19,6 +18,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
password: string;
|
||||
accountId: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
allowPrivateNetworkConfig?: boolean;
|
||||
} {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
@@ -49,18 +49,14 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
|
||||
let autoAllowPrivateNetwork = false;
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim();
|
||||
autoAllowPrivateNetwork = Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
autoAllowPrivateNetwork = false;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
password,
|
||||
accountId: account.accountId,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) || autoAllowPrivateNetwork,
|
||||
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl,
|
||||
config: account.config,
|
||||
}),
|
||||
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
@@ -24,17 +25,88 @@ const {
|
||||
} = createAccountListHelpers("bluebubbles");
|
||||
export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesPrivateNetworkAliases(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return config;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
const canonicalValue =
|
||||
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? network.dangerouslyAllowPrivateNetwork
|
||||
: typeof network?.allowPrivateNetwork === "boolean"
|
||||
? network.allowPrivateNetwork
|
||||
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? record.dangerouslyAllowPrivateNetwork
|
||||
: typeof record.allowPrivateNetwork === "boolean"
|
||||
? record.allowPrivateNetwork
|
||||
: undefined;
|
||||
|
||||
if (canonicalValue === undefined) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const {
|
||||
allowPrivateNetwork: _legacyFlatAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
|
||||
...rest
|
||||
} = record;
|
||||
const {
|
||||
allowPrivateNetwork: _legacyNetworkAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
|
||||
...restNetwork
|
||||
} = network ?? {};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
network: {
|
||||
...restNetwork,
|
||||
dangerouslyAllowPrivateNetwork: canonicalValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesAccountsMap(
|
||||
accounts: Record<string, Partial<BlueBubblesAccountConfig>> | undefined,
|
||||
): Record<string, Partial<BlueBubblesAccountConfig>> | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(accounts).map(([accountKey, accountConfig]) => [
|
||||
accountKey,
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accountConfig) as Partial<BlueBubblesAccountConfig>,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeBlueBubblesAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): BlueBubblesAccountConfig {
|
||||
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
|
||||
channelConfig: cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
|
||||
accounts: cfg.channels?.bluebubbles?.accounts as
|
||||
const channelConfig = normalizeBlueBubblesPrivateNetworkAliases(
|
||||
cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
|
||||
) as BlueBubblesAccountConfig | undefined;
|
||||
const accounts = normalizeBlueBubblesAccountsMap(
|
||||
cfg.channels?.bluebubbles?.accounts as
|
||||
| Record<string, Partial<BlueBubblesAccountConfig>>
|
||||
| undefined,
|
||||
);
|
||||
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
|
||||
channelConfig,
|
||||
accounts,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["network"],
|
||||
});
|
||||
return {
|
||||
...merged,
|
||||
@@ -66,6 +138,48 @@ export function resolveBlueBubblesAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesPrivateNetworkConfigValue(
|
||||
config: BlueBubblesAccountConfig | null | undefined,
|
||||
): boolean | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return network.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof network?.allowPrivateNetwork === "boolean") {
|
||||
return network.allowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return record.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.allowPrivateNetwork === "boolean") {
|
||||
return record.allowPrivateNetwork;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesEffectiveAllowPrivateNetwork(params: {
|
||||
baseUrl?: string;
|
||||
config?: BlueBubblesAccountConfig | null;
|
||||
}): boolean {
|
||||
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config);
|
||||
if (configuredValue !== undefined) {
|
||||
return configuredValue;
|
||||
}
|
||||
if (!params.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
|
||||
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
|
||||
return listBlueBubblesAccountIds(cfg)
|
||||
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
|
||||
|
||||
@@ -318,6 +318,28 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("respects an explicit private-network opt-out for loopback serverUrl", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-opt-out" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
@@ -330,6 +352,28 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
|
||||
});
|
||||
|
||||
it("keeps public serverUrl hostname pinning when private-network access is explicitly disabled", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-public-host-opt-out" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "https://bluebubbles.example.com:1234",
|
||||
password: "test",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -101,7 +102,8 @@ export async function downloadBlueBubblesAttachment(
|
||||
if (!guid) {
|
||||
throw new Error("BlueBubbles attachment guid is required");
|
||||
}
|
||||
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
|
||||
const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
|
||||
resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
@@ -109,6 +111,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
});
|
||||
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
const trustedHostname = safeExtractHostname(baseUrl);
|
||||
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
|
||||
try {
|
||||
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
||||
url,
|
||||
@@ -116,7 +119,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
maxBytes,
|
||||
ssrfPolicy: allowPrivateNetwork
|
||||
? { allowPrivateNetwork: true }
|
||||
: trustedHostname
|
||||
: trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
|
||||
? { allowedHostnames: [trustedHostname] }
|
||||
: undefined,
|
||||
fetchImpl: async (input, init) =>
|
||||
|
||||
80
extensions/bluebubbles/src/channel.status.test.ts
Normal file
80
extensions/bluebubbles/src/channel.status.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
const probeBlueBubblesMock = vi.hoisted(() => vi.fn());
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
blueBubblesChannelRuntime: {
|
||||
probeBlueBubbles: probeBlueBubblesMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
|
||||
bundledChannelPlugins: [],
|
||||
bundledChannelSetupPlugins: [],
|
||||
}));
|
||||
|
||||
let bluebubblesPlugin: typeof import("./channel.js").bluebubblesPlugin;
|
||||
|
||||
describe("bluebubblesPlugin.status.probeAccount", () => {
|
||||
beforeAll(async () => {
|
||||
({ bluebubblesPlugin } = await import("./channel.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
probeBlueBubblesMock.mockReset();
|
||||
probeBlueBubblesMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
});
|
||||
|
||||
it("auto-enables private-network probes for loopback server URLs", async () => {
|
||||
await bluebubblesPlugin.status?.probeAccount?.({
|
||||
cfg,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
baseUrl: "http://localhost:1234",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects an explicit private-network opt-out for loopback server URLs", async () => {
|
||||
await bluebubblesPlugin.status?.probeAccount?.({
|
||||
cfg,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
baseUrl: "http://localhost:1234",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
type ResolvedBlueBubblesAccount,
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import {
|
||||
bluebubblesCapabilities,
|
||||
@@ -226,7 +228,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
baseUrl: account.baseUrl,
|
||||
password: account.config.password ?? null,
|
||||
timeoutMs,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl: account.baseUrl,
|
||||
config: account.config,
|
||||
}),
|
||||
}),
|
||||
resolveAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const running = runtime?.running ?? false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -70,7 +71,7 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
|
||||
continue;
|
||||
}
|
||||
// Skip duplicate text (URL might be in both text message and balloon)
|
||||
const normalizedText = text.toLowerCase();
|
||||
const normalizedText = normalizeLowercaseStringOrEmpty(text);
|
||||
if (seenTexts.has(normalizedText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ function rememberPendingOutboundMessageId(entry: {
|
||||
chatId: typeof entry.chatId === "number" ? entry.chatId : undefined,
|
||||
snippetRaw,
|
||||
snippetNorm,
|
||||
isMediaSnippet: snippetRaw.toLowerCase().startsWith("<media:"),
|
||||
isMediaSnippet: normalizeLowercaseStringOrEmpty(snippetRaw).startsWith("<media:"),
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return pendingOutboundMessageIdCounter;
|
||||
@@ -396,7 +396,7 @@ function resolveBlueBubblesAckReaction(params: {
|
||||
normalizeBlueBubblesReactionInput(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
const key = raw.toLowerCase();
|
||||
const key = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!invalidAckReactions.has(key)) {
|
||||
invalidAckReactions.add(key);
|
||||
logVerbose(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import {
|
||||
asRecord,
|
||||
@@ -321,6 +321,10 @@ export async function monitorBlueBubblesProvider(
|
||||
const { account, config, runtime, abortSignal, statusSink } = options;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
||||
const allowPrivateNetwork = resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl: account.baseUrl,
|
||||
config: account.config,
|
||||
});
|
||||
|
||||
// Fetch and cache server info (for macOS version detection in action gating)
|
||||
const serverInfo = await fetchBlueBubblesServerInfo({
|
||||
@@ -328,7 +332,7 @@ export async function monitorBlueBubblesProvider(
|
||||
password: account.config.password,
|
||||
accountId: account.accountId,
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
allowPrivateNetwork,
|
||||
}).catch(() => null);
|
||||
if (serverInfo?.os_version) {
|
||||
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSetupWizardAdapter,
|
||||
@@ -322,6 +323,36 @@ describe("resolveBlueBubblesAccount", () => {
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
|
||||
it("strips stale legacy private-network aliases after canonical normalization", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.config.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
expect("allowPrivateNetwork" in resolved.config).toBe(false);
|
||||
expect(isPrivateNetworkOptInEnabled(resolved.config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { _setFetchGuardForTesting } from "./types.js";
|
||||
import { _setFetchGuardForTesting, normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
|
||||
enabled: true,
|
||||
@@ -27,21 +28,96 @@ export function mockBlueBubblesPrivateApiStatusOnce(
|
||||
mock.mockReturnValueOnce(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesPrivateNetworkAliases(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return config;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
const canonicalValue =
|
||||
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? network.dangerouslyAllowPrivateNetwork
|
||||
: typeof network?.allowPrivateNetwork === "boolean"
|
||||
? network.allowPrivateNetwork
|
||||
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? record.dangerouslyAllowPrivateNetwork
|
||||
: typeof record.allowPrivateNetwork === "boolean"
|
||||
? record.allowPrivateNetwork
|
||||
: undefined;
|
||||
|
||||
if (canonicalValue === undefined) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const {
|
||||
allowPrivateNetwork: _legacyFlatAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
|
||||
...rest
|
||||
} = record;
|
||||
const {
|
||||
allowPrivateNetwork: _legacyNetworkAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
|
||||
...restNetwork
|
||||
} = network ?? {};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
network: {
|
||||
...restNetwork,
|
||||
dangerouslyAllowPrivateNetwork: canonicalValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesAccountsMap(
|
||||
accounts: Record<string, Record<string, unknown> | undefined> | undefined,
|
||||
): Record<string, Record<string, unknown> | undefined> | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(accounts).map(([accountKey, accountConfig]) => [
|
||||
accountKey,
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accountConfig),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccountFromConfig(params: {
|
||||
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
|
||||
accountId?: string;
|
||||
}) {
|
||||
const baseConfig = params.cfg?.channels?.bluebubbles ?? {};
|
||||
const baseConfig =
|
||||
normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {};
|
||||
const accounts = normalizeBlueBubblesAccountsMap(
|
||||
baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined,
|
||||
);
|
||||
const accountId = params.accountId ?? "default";
|
||||
const accountConfig =
|
||||
accountId === "default"
|
||||
? {}
|
||||
: ((baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined)?.[
|
||||
accountId
|
||||
] ?? {});
|
||||
const config = {
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {};
|
||||
const config: Record<string, unknown> = {
|
||||
...baseConfig,
|
||||
...accountConfig,
|
||||
network:
|
||||
typeof baseConfig.network === "object" &&
|
||||
baseConfig.network &&
|
||||
!Array.isArray(baseConfig.network) &&
|
||||
typeof accountConfig.network === "object" &&
|
||||
accountConfig.network &&
|
||||
!Array.isArray(accountConfig.network)
|
||||
? {
|
||||
...(baseConfig.network as Record<string, unknown>),
|
||||
...(accountConfig.network as Record<string, unknown>),
|
||||
}
|
||||
: (accountConfig.network ?? baseConfig.network),
|
||||
};
|
||||
return {
|
||||
accountId,
|
||||
@@ -51,9 +127,57 @@ export function resolveBlueBubblesAccountFromConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBlueBubblesPrivateNetworkConfigValueFromConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): boolean | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return network.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof network?.allowPrivateNetwork === "boolean") {
|
||||
return network.allowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return record.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.allowPrivateNetwork === "boolean") {
|
||||
return record.allowPrivateNetwork;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: {
|
||||
baseUrl?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValueFromConfig(params.config);
|
||||
if (configuredValue !== undefined) {
|
||||
return configuredValue;
|
||||
}
|
||||
if (!params.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
|
||||
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlueBubblesAccountsMockModule() {
|
||||
return {
|
||||
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork: vi.fn(
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
|
||||
),
|
||||
resolveBlueBubblesPrivateNetworkConfigValue: vi.fn(
|
||||
resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
import {
|
||||
browserPluginNodeHostCommands,
|
||||
browserPluginReload,
|
||||
browserSecurityAuditCollectors,
|
||||
registerBrowserPlugin,
|
||||
} from "./plugin-registration.js";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
|
||||
const runtimeApiMocks = vi.hoisted(() => ({
|
||||
@@ -26,8 +32,6 @@ vi.mock("./register.runtime.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import browserPlugin from "./index.js";
|
||||
|
||||
function createApi() {
|
||||
const registerCli = vi.fn();
|
||||
const registerGatewayMethod = vi.fn();
|
||||
@@ -49,19 +53,19 @@ function createApi() {
|
||||
|
||||
describe("browser plugin", () => {
|
||||
it("exposes static browser metadata on the plugin definition", () => {
|
||||
expect(browserPlugin.reload).toEqual({ restartPrefixes: ["browser"] });
|
||||
expect(browserPlugin.nodeHostCommands).toEqual([
|
||||
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
|
||||
expect(browserPluginNodeHostCommands).toEqual([
|
||||
expect.objectContaining({
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
}),
|
||||
]);
|
||||
expect(browserPlugin.securityAuditCollectors).toHaveLength(1);
|
||||
expect(browserSecurityAuditCollectors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("forwards per-session browser options into the tool factory", async () => {
|
||||
const { api, registerTool } = createApi();
|
||||
await browserPlugin.register(api);
|
||||
await registerBrowserPlugin(api);
|
||||
|
||||
const tool = registerTool.mock.calls[0]?.[0];
|
||||
if (typeof tool !== "function") {
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginToolContext,
|
||||
type OpenClawPluginToolFactory,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
collectBrowserSecurityAuditFindings,
|
||||
createBrowserPluginService,
|
||||
createBrowserTool,
|
||||
handleBrowserGatewayRequest,
|
||||
registerBrowserCli,
|
||||
runBrowserProxyCommand,
|
||||
} from "./register.runtime.js";
|
||||
browserPluginNodeHostCommands,
|
||||
browserPluginReload,
|
||||
browserSecurityAuditCollectors,
|
||||
registerBrowserPlugin,
|
||||
} from "./plugin-registration.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "browser",
|
||||
name: "Browser",
|
||||
description: "Default browser tool plugin",
|
||||
reload: { restartPrefixes: ["browser"] },
|
||||
nodeHostCommands: [
|
||||
{
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
handle: runBrowserProxyCommand,
|
||||
},
|
||||
],
|
||||
securityAuditCollectors: [collectBrowserSecurityAuditFindings],
|
||||
register(api) {
|
||||
api.registerTool(((ctx: OpenClawPluginToolContext) =>
|
||||
createBrowserTool({
|
||||
sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl,
|
||||
allowHostControl: ctx.browser?.allowHostControl,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
})) as OpenClawPluginToolFactory);
|
||||
api.registerCli(({ program }) => registerBrowserCli(program), { commands: ["browser"] });
|
||||
api.registerGatewayMethod("browser.request", handleBrowserGatewayRequest, {
|
||||
scope: "operator.write",
|
||||
});
|
||||
api.registerService(createBrowserPluginService());
|
||||
},
|
||||
reload: browserPluginReload,
|
||||
nodeHostCommands: browserPluginNodeHostCommands,
|
||||
securityAuditCollectors: [...browserSecurityAuditCollectors],
|
||||
register: registerBrowserPlugin,
|
||||
});
|
||||
|
||||
39
extensions/browser/plugin-registration.ts
Normal file
39
extensions/browser/plugin-registration.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
collectBrowserSecurityAuditFindings,
|
||||
createBrowserPluginService,
|
||||
createBrowserTool,
|
||||
handleBrowserGatewayRequest,
|
||||
registerBrowserCli,
|
||||
runBrowserProxyCommand,
|
||||
} from "./register.runtime.js";
|
||||
|
||||
export const browserPluginReload = { restartPrefixes: ["browser"] };
|
||||
|
||||
export const browserPluginNodeHostCommands = [
|
||||
{
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
handle: runBrowserProxyCommand,
|
||||
},
|
||||
];
|
||||
|
||||
export const browserSecurityAuditCollectors = [collectBrowserSecurityAuditFindings] as const;
|
||||
|
||||
export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
api.registerTool(((ctx: OpenClawPluginToolContext) =>
|
||||
createBrowserTool({
|
||||
sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl,
|
||||
allowHostControl: ctx.browser?.allowHostControl,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
})) as OpenClawPluginToolFactory);
|
||||
api.registerCli(({ program }) => registerBrowserCli(program), { commands: ["browser"] });
|
||||
api.registerGatewayMethod("browser.request", handleBrowserGatewayRequest, {
|
||||
scope: "operator.write",
|
||||
});
|
||||
api.registerService(createBrowserPluginService());
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import WebSocket from "ws";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
|
||||
@@ -106,7 +107,7 @@ export function getHeadersWithAuth(url: string, headers: Record<string, string>
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hasAuthHeader = Object.keys(mergedHeaders).some(
|
||||
(key) => key.toLowerCase() === "authorization",
|
||||
(key) => normalizeLowercaseStringOrEmpty(key) === "authorization",
|
||||
);
|
||||
if (hasAuthHeader) {
|
||||
return mergedHeaders;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
|
||||
export type BrowserExecutable = {
|
||||
@@ -121,7 +122,7 @@ function execText(
|
||||
}
|
||||
|
||||
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
|
||||
const id = identifier.toLowerCase();
|
||||
const id = normalizeLowercaseStringOrEmpty(identifier);
|
||||
if (id.includes("brave")) {
|
||||
return "brave";
|
||||
}
|
||||
@@ -146,7 +147,7 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
|
||||
}
|
||||
|
||||
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
|
||||
const lower = name.toLowerCase();
|
||||
const lower = normalizeLowercaseStringOrEmpty(name);
|
||||
if (lower.includes("brave")) {
|
||||
return "brave";
|
||||
}
|
||||
@@ -285,7 +286,7 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const exeName = path.posix.basename(resolved).toLowerCase();
|
||||
const exeName = normalizeLowercaseStringOrEmpty(path.posix.basename(resolved));
|
||||
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
|
||||
return null;
|
||||
}
|
||||
@@ -307,7 +308,7 @@ function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
|
||||
if (!exists(exePath)) {
|
||||
return null;
|
||||
}
|
||||
const exeName = path.win32.basename(exePath).toLowerCase();
|
||||
const exeName = normalizeLowercaseStringOrEmpty(path.win32.basename(exePath));
|
||||
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
|
||||
return null;
|
||||
}
|
||||
@@ -464,7 +465,7 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
|
||||
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate)) {
|
||||
const normalizedPath = candidate.toLowerCase();
|
||||
const normalizedPath = normalizeLowercaseStringOrEmpty(candidate);
|
||||
return {
|
||||
kind:
|
||||
normalizedPath.includes("beta") ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
|
||||
|
||||
export type RoleRef = {
|
||||
@@ -56,7 +57,7 @@ function matchInteractiveSnapshotLine(
|
||||
if (roleRaw.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const role = roleRaw.toLowerCase();
|
||||
const role = normalizeLowercaseStringOrEmpty(roleRaw);
|
||||
return {
|
||||
roleRaw,
|
||||
role,
|
||||
@@ -174,7 +175,7 @@ function processLine(
|
||||
return options.interactive ? null : line;
|
||||
}
|
||||
|
||||
const role = roleRaw.toLowerCase();
|
||||
const role = normalizeLowercaseStringOrEmpty(roleRaw);
|
||||
const isInteractive = INTERACTIVE_ROLES.has(role);
|
||||
const isContent = CONTENT_ROLES.has(role);
|
||||
const isStructural = STRUCTURAL_ROLES.has(role);
|
||||
@@ -379,7 +380,7 @@ export function buildRoleSnapshotFromAiSnapshot(
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = roleRaw.toLowerCase();
|
||||
const role = normalizeLowercaseStringOrEmpty(roleRaw);
|
||||
const isStructural = STRUCTURAL_ROLES.has(role);
|
||||
|
||||
if (options.compact && isStructural && !name) {
|
||||
|
||||
@@ -179,6 +179,44 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
|
||||
expect(pageClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks private redirect hops even when Playwright marks hop as non-navigation", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => false,
|
||||
frame: () => mainFrame,
|
||||
resourceType: () => "document",
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
expect(pageGoto).toHaveBeenCalledTimes(1);
|
||||
expect(pageClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves the created tab on ordinary navigation failure", async () => {
|
||||
const { pageGoto, pageClose } = installBrowserMocks();
|
||||
pageGoto.mockRejectedValueOnce(new Error("page.goto: net::ERR_NAME_NOT_RESOLVED"));
|
||||
|
||||
@@ -665,13 +665,29 @@ export async function getPageForTargetId(opts: {
|
||||
}
|
||||
|
||||
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
if (!request.isNavigationRequest()) {
|
||||
let sameMainFrame = false;
|
||||
try {
|
||||
sameMainFrame = request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
// Frame resolution can fail during redirect/renderer churn; fail closed.
|
||||
sameMainFrame = true;
|
||||
}
|
||||
if (!sameMainFrame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return request.frame() === page.mainFrame();
|
||||
if (request.isNavigationRequest()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
// Ignore and fall back to resource-type check below.
|
||||
}
|
||||
|
||||
try {
|
||||
return request.resourceType() === "document";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pageState = vi.hoisted(() => ({
|
||||
page: null as Record<string, unknown> | null,
|
||||
locator: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
assertPageNavigationCompletedSafely: vi.fn(async () => {}),
|
||||
ensurePageState: vi.fn(() => ({})),
|
||||
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!pageState.page) {
|
||||
throw new Error("missing page");
|
||||
}
|
||||
return pageState.page;
|
||||
}),
|
||||
gotoPageWithNavigationGuard: vi.fn(async () => null),
|
||||
refLocator: vi.fn(() => {
|
||||
if (!pageState.locator) {
|
||||
throw new Error("missing locator");
|
||||
}
|
||||
return pageState.locator;
|
||||
}),
|
||||
restoreRoleRefsForTarget: vi.fn(() => {}),
|
||||
storeRoleRefsForTarget: vi.fn(() => {}),
|
||||
}));
|
||||
|
||||
const pageCdpMocks = vi.hoisted(() => ({
|
||||
withPageScopedCdpClient: vi.fn(
|
||||
async ({ fn }: { fn: (send: () => Promise<unknown>) => unknown }) =>
|
||||
await fn(async () => ({ nodes: [] })),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-session.js", () => sessionMocks);
|
||||
vi.mock("./pw-session.page-cdp.js", () => pageCdpMocks);
|
||||
|
||||
const interactions = await import("./pw-tools-core.interactions.js");
|
||||
const snapshots = await import("./pw-tools-core.snapshot.js");
|
||||
|
||||
describe("pw-tools-core browser SSRF guards", () => {
|
||||
beforeEach(() => {
|
||||
pageState.page = null;
|
||||
pageState.locator = null;
|
||||
for (const fn of Object.values(sessionMocks)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
for (const fn of Object.values(pageCdpMocks)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-checks click-triggered navigations with the session safety helper", async () => {
|
||||
pageState.page = { url: vi.fn(() => "https://example.com") };
|
||||
pageState.locator = { click: vi.fn(async () => {}) };
|
||||
|
||||
await interactions.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
ref: "1",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: pageState.page,
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
targetId: "tab-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves helper compatibility when no ssrfPolicy is provided", async () => {
|
||||
pageState.page = { url: vi.fn(() => "https://example.com") };
|
||||
pageState.locator = { click: vi.fn(async () => {}) };
|
||||
|
||||
await interactions.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
ref: "1",
|
||||
// no ssrfPolicy: direct helper callers keep previous compatibility semantics
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-checks batched click-triggered navigations with the session safety helper", async () => {
|
||||
pageState.page = { url: vi.fn(() => "https://example.com") };
|
||||
pageState.locator = { click: vi.fn(async () => {}) };
|
||||
|
||||
await interactions.batchViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
actions: [{ kind: "click", ref: "1" }],
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: pageState.page,
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
targetId: "tab-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-checks current page URL before snapshotting AI content", async () => {
|
||||
const snapshotForAI = vi.fn(async () => ({ full: 'button "Save"' }));
|
||||
pageState.page = {
|
||||
_snapshotForAI: snapshotForAI,
|
||||
url: vi.fn(() => "https://example.com"),
|
||||
};
|
||||
|
||||
await snapshots.snapshotAiViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: pageState.page,
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
targetId: "tab-1",
|
||||
});
|
||||
expect(
|
||||
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(snapshotForAI.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("re-checks current page URL before role snapshots", async () => {
|
||||
const ariaSnapshot = vi.fn(async () => "");
|
||||
pageState.page = {
|
||||
locator: vi.fn(() => ({ ariaSnapshot })),
|
||||
url: vi.fn(() => "https://example.com"),
|
||||
};
|
||||
|
||||
await snapshots.snapshotRoleViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: pageState.page,
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
targetId: "tab-1",
|
||||
});
|
||||
expect(
|
||||
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(ariaSnapshot.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("re-checks current page URL before aria snapshots", async () => {
|
||||
pageState.page = {
|
||||
url: vi.fn(() => "https://example.com"),
|
||||
};
|
||||
|
||||
await snapshots.snapshotAriaViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "tab-1",
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: pageState.page,
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: false },
|
||||
targetId: "tab-1",
|
||||
});
|
||||
expect(
|
||||
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(pageCdpMocks.withPageScopedCdpClient.mock.invocationCallOrder[0]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
|
||||
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
||||
import {
|
||||
assertPageNavigationCompletedSafely,
|
||||
ensurePageState,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
@@ -64,6 +66,24 @@ async function awaitEvalWithAbort<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPostInteractionNavigationSafe(opts: {
|
||||
cdpUrl: string;
|
||||
page: Awaited<ReturnType<typeof getPageForTargetId>>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
targetId?: string;
|
||||
}): Promise<void> {
|
||||
if (!opts.ssrfPolicy) {
|
||||
return;
|
||||
}
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page: opts.page,
|
||||
response: null,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function highlightViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
@@ -88,6 +108,7 @@ export async function clickViaPlaywright(opts: {
|
||||
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
@@ -115,6 +136,12 @@ export async function clickViaPlaywright(opts: {
|
||||
modifiers: opts.modifiers,
|
||||
});
|
||||
}
|
||||
await assertPostInteractionNavigationSafe({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
}
|
||||
@@ -202,6 +229,7 @@ export async function pressKeyViaPlaywright(opts: {
|
||||
targetId?: string;
|
||||
key: string;
|
||||
delayMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<void> {
|
||||
const key = String(opts.key ?? "").trim();
|
||||
if (!key) {
|
||||
@@ -212,6 +240,12 @@ export async function pressKeyViaPlaywright(opts: {
|
||||
await page.keyboard.press(key, {
|
||||
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
|
||||
});
|
||||
await assertPostInteractionNavigationSafe({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function typeViaPlaywright(opts: {
|
||||
@@ -223,6 +257,7 @@ export async function typeViaPlaywright(opts: {
|
||||
submit?: boolean;
|
||||
slowly?: boolean;
|
||||
timeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const text = String(opts.text ?? "");
|
||||
@@ -241,6 +276,12 @@ export async function typeViaPlaywright(opts: {
|
||||
}
|
||||
if (opts.submit) {
|
||||
await locator.press("Enter", { timeout });
|
||||
await assertPostInteractionNavigationSafe({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, label);
|
||||
@@ -713,6 +754,7 @@ async function executeSingleAction(
|
||||
cdpUrl: string,
|
||||
targetId?: string,
|
||||
evaluateEnabled?: boolean,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
depth = 0,
|
||||
): Promise<void> {
|
||||
if (depth > MAX_BATCH_DEPTH) {
|
||||
@@ -733,6 +775,7 @@ async function executeSingleAction(
|
||||
>,
|
||||
delayMs: action.delayMs,
|
||||
timeoutMs: action.timeoutMs,
|
||||
ssrfPolicy,
|
||||
});
|
||||
break;
|
||||
case "type":
|
||||
@@ -745,6 +788,7 @@ async function executeSingleAction(
|
||||
submit: action.submit,
|
||||
slowly: action.slowly,
|
||||
timeoutMs: action.timeoutMs,
|
||||
ssrfPolicy,
|
||||
});
|
||||
break;
|
||||
case "press":
|
||||
@@ -753,6 +797,7 @@ async function executeSingleAction(
|
||||
targetId: effectiveTargetId,
|
||||
key: action.key,
|
||||
delayMs: action.delayMs,
|
||||
ssrfPolicy,
|
||||
});
|
||||
break;
|
||||
case "hover":
|
||||
@@ -852,6 +897,7 @@ async function executeSingleAction(
|
||||
actions: action.actions,
|
||||
stopOnError: action.stopOnError,
|
||||
evaluateEnabled,
|
||||
ssrfPolicy,
|
||||
depth: depth + 1,
|
||||
});
|
||||
break;
|
||||
@@ -866,6 +912,7 @@ export async function batchViaPlaywright(opts: {
|
||||
actions: BrowserActRequest[];
|
||||
stopOnError?: boolean;
|
||||
evaluateEnabled?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
depth?: number;
|
||||
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
|
||||
const depth = opts.depth ?? 0;
|
||||
@@ -878,7 +925,14 @@ export async function batchViaPlaywright(opts: {
|
||||
const results: Array<{ ok: boolean; error?: string }> = [];
|
||||
for (const action of opts.actions) {
|
||||
try {
|
||||
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
|
||||
await executeSingleAction(
|
||||
action,
|
||||
opts.cdpUrl,
|
||||
opts.targetId,
|
||||
opts.evaluateEnabled,
|
||||
opts.ssrfPolicy,
|
||||
depth,
|
||||
);
|
||||
results.push({ ok: true });
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function snapshotAriaViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ nodes: AriaSnapshotNode[] }> {
|
||||
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
|
||||
const page = await getPageForTargetId({
|
||||
@@ -30,6 +31,15 @@ export async function snapshotAriaViaPlaywright(opts: {
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
if (opts.ssrfPolicy) {
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
response: null,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
const res = (await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
@@ -52,12 +62,22 @@ export async function snapshotAiViaPlaywright(opts: {
|
||||
targetId?: string;
|
||||
timeoutMs?: number;
|
||||
maxChars?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
if (opts.ssrfPolicy) {
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
response: null,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
const maybe = page as unknown as WithSnapshotForAI;
|
||||
if (!maybe._snapshotForAI) {
|
||||
@@ -98,6 +118,7 @@ export async function snapshotRoleViaPlaywright(opts: {
|
||||
frameSelector?: string;
|
||||
refsMode?: "role" | "aria";
|
||||
options?: RoleSnapshotOptions;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{
|
||||
snapshot: string;
|
||||
refs: Record<string, { role: string; name?: string; nth?: number }>;
|
||||
@@ -108,6 +129,15 @@ export async function snapshotRoleViaPlaywright(opts: {
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
if (opts.ssrfPolicy) {
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
response: null,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.refsMode === "aria") {
|
||||
if (opts.selector?.trim() || opts.frameSelector?.trim()) {
|
||||
|
||||
@@ -101,6 +101,7 @@ export function registerBrowserAgentActHookRoutes(
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +482,7 @@ export function registerBrowserAgentActRoutes(
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
|
||||
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
|
||||
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
|
||||
const profileName = profileCtx.profile.name;
|
||||
|
||||
@@ -539,6 +540,7 @@ export function registerBrowserAgentActRoutes(
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
doubleClick,
|
||||
ssrfPolicy,
|
||||
};
|
||||
if (ref) {
|
||||
clickRequest.ref = ref;
|
||||
@@ -616,6 +618,7 @@ export function registerBrowserAgentActRoutes(
|
||||
text,
|
||||
submit,
|
||||
slowly,
|
||||
ssrfPolicy,
|
||||
};
|
||||
if (ref) {
|
||||
typeRequest.ref = ref;
|
||||
@@ -656,6 +659,7 @@ export function registerBrowserAgentActRoutes(
|
||||
targetId: tab.targetId,
|
||||
key,
|
||||
delayMs: delayMs ?? undefined,
|
||||
ssrfPolicy,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
@@ -1105,6 +1109,7 @@ export function registerBrowserAgentActRoutes(
|
||||
actions,
|
||||
stopOnError,
|
||||
evaluateEnabled,
|
||||
ssrfPolicy,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, results: result.results });
|
||||
}
|
||||
|
||||
@@ -498,6 +498,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
selector: plan.selectorValue,
|
||||
frameSelector: plan.frameSelectorValue,
|
||||
refsMode: plan.refsMode,
|
||||
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
|
||||
options: {
|
||||
interactive: plan.interactive ?? undefined,
|
||||
compact: plan.compact ?? undefined,
|
||||
@@ -511,6 +512,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
.snapshotAiViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
|
||||
...(typeof plan.resolvedMaxChars === "number"
|
||||
? { maxChars: plan.resolvedMaxChars }
|
||||
: {}),
|
||||
@@ -579,6 +581,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
limit: plan.limit,
|
||||
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
|
||||
});
|
||||
});
|
||||
})()
|
||||
|
||||
@@ -43,6 +43,9 @@ describe("browser control server", () => {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
|
||||
@@ -54,6 +57,9 @@ describe("browser control server", () => {
|
||||
expect(lastCall).toEqual({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +97,9 @@ describe("browser control server", () => {
|
||||
doubleClick: false,
|
||||
button: "left",
|
||||
modifiers: ["Shift"],
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const clickSelector = await realFetch(`${base}/act`, {
|
||||
@@ -105,6 +114,9 @@ describe("browser control server", () => {
|
||||
targetId: "abcd1234",
|
||||
selector: "button.save",
|
||||
doubleClick: false,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
@@ -120,6 +132,9 @@ describe("browser control server", () => {
|
||||
text: "",
|
||||
submit: false,
|
||||
slowly: false,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
@@ -131,6 +146,9 @@ describe("browser control server", () => {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
key: "Enter",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { browserCloseTab } from "./client.js";
|
||||
|
||||
export type TrackedSessionBrowserTab = {
|
||||
@@ -36,7 +39,7 @@ function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?:
|
||||
}
|
||||
|
||||
function isIgnorableCloseError(err: unknown): boolean {
|
||||
const message = String(err).toLowerCase();
|
||||
const message = normalizeLowercaseStringOrEmpty(String(err));
|
||||
return (
|
||||
message.includes("tab not found") ||
|
||||
message.includes("target closed") ||
|
||||
|
||||
@@ -316,9 +316,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
||||
);
|
||||
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -336,9 +334,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
||||
);
|
||||
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -357,9 +353,7 @@ describe("runBrowserProxyCommand", () => {
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
||||
);
|
||||
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -390,27 +384,17 @@ describe("runBrowserProxyCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy proxy behavior when allowProfiles is empty", async () => {
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 200,
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "POST",
|
||||
path: "/profiles/create",
|
||||
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/profiles/create",
|
||||
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
||||
}),
|
||||
);
|
||||
it("rejects persistent profile creation when allowProfiles is empty", async () => {
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "POST",
|
||||
path: "/profiles/create",
|
||||
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,12 +240,10 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
profile: params.profile,
|
||||
}) ?? "";
|
||||
const allowedProfiles = proxyConfig.allowProfiles;
|
||||
if (isPersistentBrowserProfileMutation(method, path)) {
|
||||
throw new Error("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
|
||||
}
|
||||
if (allowedProfiles.length > 0) {
|
||||
if (isPersistentBrowserProfileMutation(method, path)) {
|
||||
throw new Error(
|
||||
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
||||
);
|
||||
}
|
||||
if (path !== "/profiles") {
|
||||
const profileToCheck = requestedProfile || resolved.defaultProfile;
|
||||
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("chutes-models");
|
||||
|
||||
@@ -564,12 +565,13 @@ export async function discoverChutesModels(accessToken?: string): Promise<ModelD
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const lowerId = normalizeLowercaseStringOrEmpty(id);
|
||||
const isReasoning =
|
||||
entry.supported_features?.includes("reasoning") ||
|
||||
id.toLowerCase().includes("r1") ||
|
||||
id.toLowerCase().includes("thinking") ||
|
||||
id.toLowerCase().includes("reason") ||
|
||||
id.toLowerCase().includes("tee");
|
||||
lowerId.includes("r1") ||
|
||||
lowerId.includes("thinking") ||
|
||||
lowerId.includes("reason") ||
|
||||
lowerId.includes("tee");
|
||||
|
||||
const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter(
|
||||
(i): i is "text" | "image" => i === "text" || i === "image",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag the broad Discord API barrel into lightweight plugin loads.
|
||||
// not drag setup-only surfaces into lightweight channel plugin loads.
|
||||
export { discordPlugin } from "./src/channel.js";
|
||||
export { discordSetupPlugin } from "./src/channel.setup.js";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "discordSetupPlugin",
|
||||
},
|
||||
});
|
||||
|
||||
3
extensions/discord/setup-plugin-api.ts
Normal file
3
extensions/discord/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Discord channel plugin surface.
|
||||
export { discordSetupPlugin } from "./src/channel.setup.js";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
type Ctx = Pick<
|
||||
ChannelMessageActionContext,
|
||||
"action" | "params" | "cfg" | "accountId" | "requesterSenderId"
|
||||
"action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "mediaLocalRoots"
|
||||
>;
|
||||
|
||||
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
@@ -336,6 +336,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
const channelId = readStringParam(actionParams, "channelId");
|
||||
const location = readStringParam(actionParams, "location");
|
||||
const entityType = readStringParam(actionParams, "eventType");
|
||||
const image = readStringParam(actionParams, "image", { trim: false });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "eventCreate",
|
||||
@@ -348,8 +349,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
channelId,
|
||||
location,
|
||||
entityType,
|
||||
image,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots: ctx.mediaLocalRoots },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
setChannelPermissionDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
resolveEventCoverImage,
|
||||
} from "../send.js";
|
||||
import { readDiscordParentIdParam } from "./runtime.shared.js";
|
||||
|
||||
@@ -37,6 +38,7 @@ export const discordGuildActionRuntime = {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
createScheduledEventDiscord,
|
||||
resolveEventCoverImage,
|
||||
deleteChannelDiscord,
|
||||
editChannelDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
@@ -95,6 +97,7 @@ export async function handleDiscordGuildAction(
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
options?: { mediaLocalRoots?: readonly string[] },
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
switch (action) {
|
||||
@@ -299,8 +302,14 @@ export async function handleDiscordGuildAction(
|
||||
const description = readStringParam(params, "description");
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const location = readStringParam(params, "location");
|
||||
const imageUrl = readStringParam(params, "image", { trim: false });
|
||||
const entityTypeRaw = readStringParam(params, "entityType");
|
||||
const entityType = entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
|
||||
const image = imageUrl
|
||||
? await discordGuildActionRuntime.resolveEventCoverImage(imageUrl, {
|
||||
localRoots: options?.mediaLocalRoots,
|
||||
})
|
||||
: undefined;
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
@@ -309,6 +318,7 @@ export async function handleDiscordGuildAction(
|
||||
entity_type: entityType,
|
||||
channel_id: channelId,
|
||||
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
||||
image,
|
||||
privacy_level: 2,
|
||||
};
|
||||
const event = accountId
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function handleDiscordAction(
|
||||
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
|
||||
}
|
||||
if (guildActions.has(action)) {
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg);
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
|
||||
}
|
||||
if (moderationActions.has(action)) {
|
||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||
|
||||
45
extensions/discord/src/approval-handler.runtime.test.ts
Normal file
45
extensions/discord/src/approval-handler.runtime.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
|
||||
|
||||
describe("discordApprovalNativeRuntime", () => {
|
||||
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
|
||||
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg: {} as never,
|
||||
accountId: "main",
|
||||
context: {
|
||||
token: "discord-token",
|
||||
config: {} as never,
|
||||
},
|
||||
plannedTarget: {
|
||||
surface: "origin",
|
||||
reason: "preferred",
|
||||
target: {
|
||||
to: "123456789",
|
||||
threadId: "777888999",
|
||||
},
|
||||
},
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "hostname",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1_000,
|
||||
},
|
||||
approvalKind: "exec",
|
||||
view: {} as never,
|
||||
pendingPayload: {} as never,
|
||||
});
|
||||
|
||||
expect(prepared).toEqual({
|
||||
dedupeKey: buildChannelApprovalNativeTargetKey({
|
||||
to: "123456789",
|
||||
threadId: "777888999",
|
||||
}),
|
||||
target: {
|
||||
discordChannelId: "777888999",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
626
extensions/discord/src/approval-handler.runtime.ts
Normal file
626
extensions/discord/src/approval-handler.runtime.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import {
|
||||
Button,
|
||||
Row,
|
||||
Separator,
|
||||
TextDisplay,
|
||||
serializePayload,
|
||||
type MessagePayloadObject,
|
||||
type TopLevelComponents,
|
||||
} from "@buape/carbon";
|
||||
import { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||
import type {
|
||||
ChannelApprovalCapabilityHandlerContext,
|
||||
ExecApprovalExpiredView,
|
||||
ExecApprovalPendingView,
|
||||
ExecApprovalResolvedView,
|
||||
PendingApprovalView,
|
||||
PluginApprovalExpiredView,
|
||||
PluginApprovalPendingView,
|
||||
PluginApprovalResolvedView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
ExecApprovalActionDescriptor,
|
||||
ExecApprovalDecision,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { shouldHandleDiscordApprovalRequest } from "./approval-native.js";
|
||||
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
|
||||
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
|
||||
import { DiscordUiContainer } from "./ui.js";
|
||||
|
||||
type PendingApproval = {
|
||||
discordMessageId: string;
|
||||
discordChannelId: string;
|
||||
};
|
||||
type DiscordPendingDelivery = {
|
||||
body: ReturnType<typeof stripUndefinedFields>;
|
||||
};
|
||||
type PreparedDeliveryTarget = {
|
||||
discordChannelId: string;
|
||||
recipientUserId?: string;
|
||||
};
|
||||
|
||||
export type DiscordApprovalHandlerContext = {
|
||||
token: string;
|
||||
config: DiscordExecApprovalConfig;
|
||||
};
|
||||
|
||||
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
|
||||
accountId: string;
|
||||
context: DiscordApprovalHandlerContext;
|
||||
} | null {
|
||||
const context = params.context as DiscordApprovalHandlerContext | undefined;
|
||||
const accountId = params.accountId?.trim() || "";
|
||||
if (!context?.token || !accountId) {
|
||||
return null;
|
||||
}
|
||||
return { accountId, context };
|
||||
}
|
||||
|
||||
class ExecApprovalContainer extends DiscordUiContainer {
|
||||
constructor(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
commandPreview: string;
|
||||
commandSecondaryPreview?: string | null;
|
||||
metadataLines?: string[];
|
||||
actionRow?: Row<Button>;
|
||||
footer?: string;
|
||||
accentColor?: string;
|
||||
}) {
|
||||
const components: Array<TextDisplay | Separator | Row<Button>> = [
|
||||
new TextDisplay(`## ${params.title}`),
|
||||
];
|
||||
if (params.description) {
|
||||
components.push(new TextDisplay(params.description));
|
||||
}
|
||||
components.push(new Separator({ divider: true, spacing: "small" }));
|
||||
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
|
||||
if (params.commandSecondaryPreview) {
|
||||
components.push(
|
||||
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
|
||||
);
|
||||
}
|
||||
if (params.metadataLines?.length) {
|
||||
components.push(new TextDisplay(params.metadataLines.join("\n")));
|
||||
}
|
||||
if (params.actionRow) {
|
||||
components.push(params.actionRow);
|
||||
}
|
||||
if (params.footer) {
|
||||
components.push(new Separator({ divider: false, spacing: "small" }));
|
||||
components.push(new TextDisplay(`-# ${params.footer}`));
|
||||
}
|
||||
super({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
components,
|
||||
accentColor: params.accentColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ExecApprovalActionButton extends Button {
|
||||
customId: string;
|
||||
label: string;
|
||||
style: ButtonStyle;
|
||||
|
||||
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
|
||||
super();
|
||||
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
|
||||
this.label = params.descriptor.label;
|
||||
this.style =
|
||||
params.descriptor.style === "success"
|
||||
? ButtonStyle.Success
|
||||
: params.descriptor.style === "primary"
|
||||
? ButtonStyle.Primary
|
||||
: params.descriptor.style === "danger"
|
||||
? ButtonStyle.Danger
|
||||
: ButtonStyle.Secondary;
|
||||
}
|
||||
}
|
||||
|
||||
class ExecApprovalActionRow extends Row<Button> {
|
||||
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
|
||||
super(
|
||||
params.actions.map(
|
||||
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
|
||||
return new ExecApprovalActionRow({
|
||||
approvalId: view.approvalId,
|
||||
actions: view.actions,
|
||||
});
|
||||
}
|
||||
|
||||
function buildApprovalMetadataLines(
|
||||
metadata: readonly { label: string; value: string }[],
|
||||
): string[] {
|
||||
return metadata.map((item) => `- ${item.label}: ${item.value}`);
|
||||
}
|
||||
|
||||
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
|
||||
const components: TopLevelComponents[] = [container];
|
||||
return { components };
|
||||
}
|
||||
|
||||
function formatCommandPreview(commandText: string, maxChars: number): string {
|
||||
const commandRaw =
|
||||
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
|
||||
return commandRaw.replace(/`/g, "\u200b`");
|
||||
}
|
||||
|
||||
function formatOptionalCommandPreview(
|
||||
commandText: string | null | undefined,
|
||||
maxChars: number,
|
||||
): string | null {
|
||||
if (!commandText) {
|
||||
return null;
|
||||
}
|
||||
return formatCommandPreview(commandText, maxChars);
|
||||
}
|
||||
|
||||
function resolveCommandPreviews(
|
||||
commandText: string,
|
||||
commandPreview: string | null | undefined,
|
||||
maxChars: number,
|
||||
secondaryMaxChars: number,
|
||||
): { commandPreview: string; commandSecondaryPreview: string | null } {
|
||||
return {
|
||||
commandPreview: formatCommandPreview(commandText, maxChars),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(commandPreview, secondaryMaxChars),
|
||||
};
|
||||
}
|
||||
|
||||
function createExecApprovalRequestContainer(params: {
|
||||
view: ExecApprovalPendingView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
|
||||
params.view.commandText,
|
||||
params.view.commandPreview,
|
||||
1000,
|
||||
500,
|
||||
);
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Exec Approval Required",
|
||||
description: "A command needs your approval.",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
|
||||
accentColor: "#FFA500",
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginApprovalRequestContainer(params: {
|
||||
view: PluginApprovalPendingView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
|
||||
const severity = params.view.severity;
|
||||
const accentColor =
|
||||
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval Required",
|
||||
description: "A plugin action needs your approval.",
|
||||
commandPreview: formatCommandPreview(params.view.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecResolvedContainer(params: {
|
||||
view: ExecApprovalResolvedView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
|
||||
params.view.commandText,
|
||||
params.view.commandPreview,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
const decisionLabel =
|
||||
params.view.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.view.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
const accentColor =
|
||||
params.view.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.view.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: `Exec Approval: ${decisionLabel}`,
|
||||
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
footer: `ID: ${params.view.approvalId}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginResolvedContainer(params: {
|
||||
view: PluginApprovalResolvedView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const decisionLabel =
|
||||
params.view.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.view.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
const accentColor =
|
||||
params.view.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.view.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: `Plugin Approval: ${decisionLabel}`,
|
||||
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
|
||||
commandPreview: formatCommandPreview(params.view.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
footer: `ID: ${params.view.approvalId}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecExpiredContainer(params: {
|
||||
view: ExecApprovalExpiredView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
|
||||
params.view.commandText,
|
||||
params.view.commandPreview,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Exec Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
footer: `ID: ${params.view.approvalId}`,
|
||||
accentColor: "#99AAB5",
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginExpiredContainer(params: {
|
||||
view: PluginApprovalExpiredView;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
commandPreview: formatCommandPreview(params.view.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
footer: `ID: ${params.view.approvalId}`,
|
||||
accentColor: "#99AAB5",
|
||||
});
|
||||
}
|
||||
|
||||
export function buildExecApprovalCustomId(
|
||||
approvalId: string,
|
||||
action: ExecApprovalDecision,
|
||||
): string {
|
||||
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
|
||||
}
|
||||
|
||||
async function updateMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
token: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
container: DiscordUiContainer;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: params.token, accountId: params.accountId },
|
||||
params.cfg,
|
||||
);
|
||||
const payload = buildExecApprovalPayload(params.container);
|
||||
await discordRequest(
|
||||
() =>
|
||||
rest.patch(Routes.channelMessage(params.channelId, params.messageId), {
|
||||
body: stripUndefinedFields(serializePayload(payload)),
|
||||
}),
|
||||
"update-approval",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord approvals: failed to update message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
token: string;
|
||||
cleanupAfterResolve?: boolean;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
container: DiscordUiContainer;
|
||||
}): Promise<void> {
|
||||
if (!params.cleanupAfterResolve) {
|
||||
await updateMessage(params);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: params.token, accountId: params.accountId },
|
||||
params.cfg,
|
||||
);
|
||||
await discordRequest(
|
||||
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
|
||||
"delete-approval",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord approvals: failed to delete message: ${String(err)}`);
|
||||
await updateMessage(params);
|
||||
}
|
||||
}
|
||||
|
||||
export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
||||
DiscordPendingDelivery,
|
||||
PreparedDeliveryTarget,
|
||||
PendingApproval,
|
||||
never
|
||||
>({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
|
||||
availability: {
|
||||
isConfigured: (params) => {
|
||||
const resolved = resolveHandlerContext(params);
|
||||
return resolved
|
||||
? isDiscordExecApprovalClientEnabled({
|
||||
cfg: params.cfg,
|
||||
accountId: resolved.accountId,
|
||||
configOverride: resolved.context.config,
|
||||
})
|
||||
: false;
|
||||
},
|
||||
shouldHandle: (params) => {
|
||||
const resolved = resolveHandlerContext(params);
|
||||
return resolved
|
||||
? shouldHandleDiscordApprovalRequest({
|
||||
cfg: params.cfg,
|
||||
accountId: resolved.accountId,
|
||||
request: params.request,
|
||||
configOverride: resolved.context.config,
|
||||
})
|
||||
: false;
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: ({ cfg, accountId, context, view }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return { body: {} };
|
||||
}
|
||||
const actionRow = createApprovalActionRow(view);
|
||||
const container =
|
||||
view.approvalKind === "plugin"
|
||||
? createPluginApprovalRequestContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolved.accountId,
|
||||
actionRow,
|
||||
})
|
||||
: createExecApprovalRequestContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolved.accountId,
|
||||
actionRow,
|
||||
});
|
||||
return {
|
||||
body: stripUndefinedFields(serializePayload(buildExecApprovalPayload(container))),
|
||||
};
|
||||
},
|
||||
buildResolvedResult: ({ cfg, accountId, context, view }) => {
|
||||
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolvedContext) {
|
||||
return { kind: "delete" } as const;
|
||||
}
|
||||
const container =
|
||||
view.approvalKind === "plugin"
|
||||
? createPluginResolvedContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolvedContext.accountId,
|
||||
})
|
||||
: createExecResolvedContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolvedContext.accountId,
|
||||
});
|
||||
return { kind: "update", payload: container } as const;
|
||||
},
|
||||
buildExpiredResult: ({ cfg, accountId, context, view }) => {
|
||||
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolvedContext) {
|
||||
return { kind: "delete" } as const;
|
||||
}
|
||||
const container =
|
||||
view.approvalKind === "plugin"
|
||||
? createPluginExpiredContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolvedContext.accountId,
|
||||
})
|
||||
: createExecExpiredContainer({
|
||||
view: view,
|
||||
cfg,
|
||||
accountId: resolvedContext.accountId,
|
||||
});
|
||||
return { kind: "update", payload: container } as const;
|
||||
},
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: async ({ cfg, accountId, context, plannedTarget }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
if (plannedTarget.surface === "origin") {
|
||||
const destinationId =
|
||||
typeof plannedTarget.target.threadId === "string" &&
|
||||
plannedTarget.target.threadId.trim().length > 0
|
||||
? plannedTarget.target.threadId.trim()
|
||||
: plannedTarget.target.to;
|
||||
return {
|
||||
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
|
||||
target: {
|
||||
discordChannelId: destinationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: resolved.context.token, accountId: resolved.accountId },
|
||||
cfg,
|
||||
);
|
||||
const userId = plannedTarget.target.to;
|
||||
const dmChannel = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.userChannels(), {
|
||||
body: { recipient_id: userId },
|
||||
}) as Promise<{ id: string }>,
|
||||
"dm-channel",
|
||||
)) as { id: string };
|
||||
if (!dmChannel?.id) {
|
||||
logError(`discord approvals: failed to create DM for user ${userId}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
dedupeKey: dmChannel.id,
|
||||
target: {
|
||||
discordChannelId: dmChannel.id,
|
||||
recipientUserId: userId,
|
||||
},
|
||||
};
|
||||
},
|
||||
deliverPending: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
context,
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
pendingPayload,
|
||||
}) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: resolved.context.token, accountId: resolved.accountId },
|
||||
cfg,
|
||||
);
|
||||
const message = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
|
||||
body: pendingPayload.body,
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
|
||||
)) as { id: string; channel_id: string };
|
||||
if (!message?.id) {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logError("discord approvals: failed to send to channel");
|
||||
} else if (preparedTarget.recipientUserId) {
|
||||
logError(
|
||||
`discord approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
discordMessageId: message.id,
|
||||
discordChannelId: preparedTarget.discordChannelId,
|
||||
};
|
||||
},
|
||||
updateEntry: async ({ cfg, accountId, context, entry, payload, phase }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const container = payload as DiscordUiContainer;
|
||||
await finalizeMessage({
|
||||
cfg,
|
||||
accountId: resolved.accountId,
|
||||
token: resolved.context.token,
|
||||
cleanupAfterResolve:
|
||||
phase === "resolved" ? resolved.context.config.cleanupAfterResolve : false,
|
||||
channelId: entry.discordChannelId,
|
||||
messageId: entry.discordMessageId,
|
||||
container,
|
||||
});
|
||||
},
|
||||
},
|
||||
observe: {
|
||||
onDuplicateSkipped: ({ preparedTarget, request }) => {
|
||||
logDebug(
|
||||
`discord approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
|
||||
);
|
||||
},
|
||||
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logDebug(
|
||||
`discord approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
logDebug(`discord approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`);
|
||||
},
|
||||
onDeliveryError: ({ error, plannedTarget }) => {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logError(`discord approvals: failed to send to channel: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
logError(
|
||||
`discord approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({ to: "123456789" });
|
||||
expect(target).toEqual({ to: "123456789", threadId: undefined });
|
||||
});
|
||||
|
||||
it("falls back to extracting the channel id from the session key", async () => {
|
||||
@@ -242,7 +242,55 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({ to: "987654321" });
|
||||
expect(target).toEqual({ to: "987654321", threadId: undefined });
|
||||
});
|
||||
|
||||
it("preserves explicit turn-source thread ids on origin targets", async () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
sessionKey: "agent:main:discord:channel:123456789:thread:777888999",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:123456789",
|
||||
turnSourceThreadId: "777888999",
|
||||
turnSourceAccountId: "main",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({ to: "123456789", threadId: "777888999" });
|
||||
});
|
||||
|
||||
it("falls back to extracting thread ids from the session key", async () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
sessionKey: "agent:main:discord:channel:987654321:thread:444555666",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({ to: "987654321", threadId: "444555666" });
|
||||
});
|
||||
|
||||
it("rejects origin delivery for requests bound to another Discord account", async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
|
||||
// Legacy export kept for monitor test/support surfaces; native routing now uses
|
||||
// the shared session-conversation fallback helper instead.
|
||||
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
@@ -27,6 +31,14 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function extractDiscordThreadId(sessionKey?: string | null): string | null {
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const match = sessionKey.match(/discord:(?:channel|group):\d+:thread:(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "group" | "dm" | null {
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
@@ -53,6 +65,17 @@ function normalizeDiscordOriginChannelId(value?: string | null): string | null {
|
||||
return /^\d+$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeDiscordThreadId(value?: string | number | null): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return /^\d+$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function shouldHandleDiscordApprovalRequest(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -103,34 +126,63 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
|
||||
configOverride,
|
||||
}),
|
||||
resolveTurnSourceTarget: (request) => {
|
||||
const sessionConversation = resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "discord",
|
||||
});
|
||||
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
|
||||
const threadId =
|
||||
normalizeDiscordThreadId(request.request.turnSourceThreadId) ??
|
||||
normalizeDiscordThreadId(sessionConversation?.threadId) ??
|
||||
undefined;
|
||||
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
|
||||
if (turnSourceChannel !== "discord" || !turnSourceTo || sessionKind === "dm") {
|
||||
return null;
|
||||
}
|
||||
return hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group"
|
||||
? { to: turnSourceTo }
|
||||
? { to: turnSourceTo, threadId }
|
||||
: null;
|
||||
},
|
||||
resolveSessionTarget: (sessionTarget, request) => {
|
||||
const sessionConversation = resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "discord",
|
||||
});
|
||||
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
|
||||
if (sessionKind === "dm") {
|
||||
return null;
|
||||
}
|
||||
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
|
||||
return targetTo ? { to: targetTo } : null;
|
||||
return targetTo
|
||||
? {
|
||||
to: targetTo,
|
||||
threadId:
|
||||
normalizeDiscordThreadId(sessionTarget.threadId) ??
|
||||
normalizeDiscordThreadId(sessionConversation?.threadId) ??
|
||||
undefined,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
targetsMatch: (a, b) => a.to === b.to,
|
||||
targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId,
|
||||
resolveFallbackTarget: (request) => {
|
||||
const sessionConversation = resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "discord",
|
||||
});
|
||||
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
|
||||
if (sessionKind === "dm") {
|
||||
return null;
|
||||
}
|
||||
const legacyChannelId = extractDiscordChannelId(request.request.sessionKey?.trim() || null);
|
||||
return legacyChannelId ? { to: legacyChannelId } : null;
|
||||
const fallbackChannelId = normalizeDiscordOriginChannelId(sessionConversation?.id);
|
||||
return fallbackChannelId
|
||||
? {
|
||||
to: fallbackChannelId,
|
||||
threadId: normalizeDiscordThreadId(sessionConversation?.threadId) ?? undefined,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -175,6 +227,20 @@ export function createDiscordApprovalCapability(configOverride?: DiscordExecAppr
|
||||
resolveOriginTarget: createDiscordOriginTargetResolver(configOverride),
|
||||
resolveApproverDmTargets: createDiscordApproverDmTargetResolver(configOverride),
|
||||
notifyOriginWhenDmOnly: true,
|
||||
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
isConfigured: ({ cfg, accountId }) =>
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
|
||||
shouldHandle: ({ cfg, accountId, request }) =>
|
||||
shouldHandleDiscordApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
configOverride,
|
||||
}),
|
||||
load: async () =>
|
||||
(await import("./approval-handler.runtime.js")).discordApprovalNativeRuntime,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -797,6 +797,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
channelRuntime: ctx.channelRuntime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
historyLimit: account.config.historyLimit,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { parseDiscordTarget } from "./target-parsing.js";
|
||||
|
||||
function normalizeDiscordTarget(
|
||||
@@ -37,7 +40,7 @@ export function resolveDiscordCurrentConversationIdentity(params: {
|
||||
commandTo?: string | null;
|
||||
fallbackTo?: string | null;
|
||||
}): string | undefined {
|
||||
if (normalizeOptionalString(params.chatType)?.toLowerCase() === "direct") {
|
||||
if (normalizeOptionalLowercaseString(params.chatType) === "direct") {
|
||||
const senderTarget = normalizeDiscordTarget(params.from, "user");
|
||||
if (senderTarget?.startsWith("user:")) {
|
||||
return senderTarget;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
|
||||
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
|
||||
@@ -29,7 +30,7 @@ function normalizeHandleKey(raw: string): string | null {
|
||||
if (!handle || /\s/.test(handle)) {
|
||||
return null;
|
||||
}
|
||||
return handle.toLowerCase();
|
||||
return normalizeLowercaseStringOrEmpty(handle);
|
||||
}
|
||||
|
||||
function ensureAccountCache(accountId?: string | null): Map<string, string> {
|
||||
|
||||
@@ -26,7 +26,7 @@ function createOpenGuildConfig(
|
||||
channels: Record<string, { allow: boolean; includeThreadStarter?: boolean }>,
|
||||
extra: Partial<Config> = {},
|
||||
): Config {
|
||||
return {
|
||||
const cfg: Config = {
|
||||
...createMentionRequiredGuildConfig(),
|
||||
...extra,
|
||||
channels: {
|
||||
@@ -41,7 +41,8 @@ function createOpenGuildConfig(
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Config;
|
||||
};
|
||||
return cfg;
|
||||
}
|
||||
|
||||
describe("discord tool result dispatch", () => {
|
||||
|
||||
@@ -70,12 +70,12 @@ describe("discord tool result dispatch", () => {
|
||||
});
|
||||
|
||||
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||
const cfg = {
|
||||
const cfg: Config = {
|
||||
...BASE_CFG,
|
||||
channels: {
|
||||
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
|
||||
},
|
||||
} as Config;
|
||||
};
|
||||
|
||||
const handler = await createDmHandler({ cfg });
|
||||
const client = createDmClient();
|
||||
|
||||
@@ -9,26 +9,10 @@ export const readAllowFromStoreMock: MockFn = vi.fn();
|
||||
export const upsertPairingRequestMock: MockFn = vi.fn();
|
||||
export const loadConfigMock: MockFn = vi.fn();
|
||||
|
||||
export const TOOL_RESULT_SESSION_STORE_PATH = `/tmp/openclaw-sessions-${process.pid}.json`;
|
||||
|
||||
const sendModule = await import("./send.js");
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
|
||||
(...args) => sendMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(sendModule, "reactMessageDiscord").mockImplementation(async (...args) => {
|
||||
reactMock(...args);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const replyRuntimeModule = await import("openclaw/plugin-sdk/reply-runtime");
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessage").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithDispatcher").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithBufferedDispatcher").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
|
||||
const conversationRuntimeModule = await import("openclaw/plugin-sdk/conversation-runtime");
|
||||
type ReadChannelAllowFromStore = typeof conversationRuntimeModule.readChannelAllowFromStore;
|
||||
type UpsertChannelPairingRequest = typeof conversationRuntimeModule.upsertChannelPairingRequest;
|
||||
@@ -49,22 +33,42 @@ function createPairingStoreMocks() {
|
||||
}
|
||||
|
||||
const pairingStoreMocks = createPairingStoreMocks();
|
||||
vi.spyOn(conversationRuntimeModule, "readChannelAllowFromStore").mockImplementation((...args) =>
|
||||
pairingStoreMocks.readChannelAllowFromStore(...args),
|
||||
);
|
||||
vi.spyOn(conversationRuntimeModule, "upsertChannelPairingRequest").mockImplementation((...args) =>
|
||||
pairingStoreMocks.upsertChannelPairingRequest(...args),
|
||||
);
|
||||
|
||||
const configRuntimeModule = await import("openclaw/plugin-sdk/config-runtime");
|
||||
vi.spyOn(configRuntimeModule, "loadConfig").mockImplementation(
|
||||
(...args) => loadConfigMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "readSessionUpdatedAt").mockImplementation(() => undefined);
|
||||
vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
|
||||
() => "/tmp/openclaw-sessions.json",
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "updateLastRoute").mockImplementation(
|
||||
(...args) => updateLastRouteMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "resolveSessionKey").mockImplementation(vi.fn() as never);
|
||||
|
||||
export function installDiscordToolResultHarnessSpies() {
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
|
||||
(...args) => sendMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(sendModule, "reactMessageDiscord").mockImplementation(async (...args) => {
|
||||
reactMock(...args);
|
||||
return { ok: true };
|
||||
});
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessage").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithDispatcher").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithBufferedDispatcher").mockImplementation(
|
||||
(...args) => dispatchMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(conversationRuntimeModule, "readChannelAllowFromStore").mockImplementation((...args) =>
|
||||
pairingStoreMocks.readChannelAllowFromStore(...args),
|
||||
);
|
||||
vi.spyOn(conversationRuntimeModule, "upsertChannelPairingRequest").mockImplementation((...args) =>
|
||||
pairingStoreMocks.upsertChannelPairingRequest(...args),
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "loadConfig").mockImplementation(
|
||||
(...args) => loadConfigMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "readSessionUpdatedAt").mockImplementation(() => undefined);
|
||||
vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
|
||||
() => TOOL_RESULT_SESSION_STORE_PATH,
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "updateLastRoute").mockImplementation(
|
||||
(...args) => updateLastRouteMock(...args) as never,
|
||||
);
|
||||
vi.spyOn(configRuntimeModule, "resolveSessionKey").mockImplementation(vi.fn() as never);
|
||||
}
|
||||
|
||||
installDiscordToolResultHarnessSpies();
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
dispatchMock,
|
||||
installDiscordToolResultHarnessSpies,
|
||||
loadConfigMock,
|
||||
readAllowFromStoreMock,
|
||||
sendMock,
|
||||
TOOL_RESULT_SESSION_STORE_PATH,
|
||||
updateLastRouteMock,
|
||||
upsertPairingRequestMock,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
@@ -26,7 +28,7 @@ export const BASE_CFG: Config = {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
},
|
||||
session: { store: "/tmp/openclaw-sessions.json" },
|
||||
session: { store: TOOL_RESULT_SESSION_STORE_PATH },
|
||||
};
|
||||
|
||||
export const CATEGORY_GUILD_CFG = {
|
||||
@@ -45,6 +47,7 @@ export const CATEGORY_GUILD_CFG = {
|
||||
} satisfies Config;
|
||||
|
||||
export function resetDiscordToolResultHarness() {
|
||||
installDiscordToolResultHarnessSpies();
|
||||
__resetDiscordChannelInfoCacheForTest();
|
||||
sendMock.mockClear().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockClear();
|
||||
@@ -304,7 +307,7 @@ export function createMentionRequiredGuildConfig(overrides?: Partial<Config>): C
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as Config;
|
||||
};
|
||||
}
|
||||
|
||||
export function captureNextDispatchCtx<
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +1,24 @@
|
||||
import {
|
||||
Button,
|
||||
Row,
|
||||
Separator,
|
||||
TextDisplay,
|
||||
serializePayload,
|
||||
type ButtonInteraction,
|
||||
type ComponentData,
|
||||
type MessagePayloadObject,
|
||||
type TopLevelComponents,
|
||||
} from "@buape/carbon";
|
||||
import { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
ExecApprovalActionDescriptor,
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
buildExecApprovalActionDescriptors,
|
||||
createChannelNativeApprovalRuntime,
|
||||
getExecApprovalApproverDmNoticeText,
|
||||
resolveExecApprovalCommandDisplay,
|
||||
type ExecApprovalChannelRuntime,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createDiscordApprovalCapability,
|
||||
shouldHandleDiscordApprovalRequest,
|
||||
} from "../approval-native.js";
|
||||
import {
|
||||
getDiscordExecApprovalApprovers,
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
} from "../exec-approvals.js";
|
||||
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
|
||||
import { DiscordUiContainer } from "../ui.js";
|
||||
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
|
||||
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
|
||||
|
||||
const EXEC_APPROVAL_KEY = "execapproval";
|
||||
export { extractDiscordChannelId } from "../approval-native.js";
|
||||
export type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
};
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
|
||||
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
|
||||
return {
|
||||
content: getExecApprovalApproverDmNoticeText(),
|
||||
};
|
||||
}
|
||||
|
||||
type PendingApproval = {
|
||||
discordMessageId: string;
|
||||
discordChannelId: string;
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
};
|
||||
type DiscordPendingDelivery = {
|
||||
body: ReturnType<typeof stripUndefinedFields>;
|
||||
};
|
||||
type PreparedDeliveryTarget = {
|
||||
discordChannelId: string;
|
||||
recipientUserId?: string;
|
||||
};
|
||||
|
||||
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
|
||||
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
|
||||
}
|
||||
|
||||
function isPluginApprovalRequest(request: ApprovalRequest): request is PluginApprovalRequest {
|
||||
return resolveApprovalKindFromId(request.id) === "plugin";
|
||||
}
|
||||
|
||||
function encodeCustomIdValue(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
|
||||
function decodeCustomIdValue(value: string): string {
|
||||
try {
|
||||
@@ -91,15 +28,6 @@ function decodeCustomIdValue(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExecApprovalCustomId(
|
||||
approvalId: string,
|
||||
action: ExecApprovalDecision,
|
||||
): string {
|
||||
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
|
||||
";",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseExecApprovalData(
|
||||
data: ComponentData,
|
||||
): { approvalId: string; action: ExecApprovalDecision } | null {
|
||||
@@ -123,681 +51,18 @@ export function parseExecApprovalData(
|
||||
};
|
||||
}
|
||||
|
||||
type ExecApprovalContainerParams = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
commandPreview: string;
|
||||
commandSecondaryPreview?: string | null;
|
||||
metadataLines?: string[];
|
||||
actionRow?: Row<Button>;
|
||||
footer?: string;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
class ExecApprovalContainer extends DiscordUiContainer {
|
||||
constructor(params: ExecApprovalContainerParams) {
|
||||
const components: Array<TextDisplay | Separator | Row<Button>> = [
|
||||
new TextDisplay(`## ${params.title}`),
|
||||
];
|
||||
if (params.description) {
|
||||
components.push(new TextDisplay(params.description));
|
||||
}
|
||||
components.push(new Separator({ divider: true, spacing: "small" }));
|
||||
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
|
||||
if (params.commandSecondaryPreview) {
|
||||
components.push(
|
||||
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
|
||||
);
|
||||
}
|
||||
if (params.metadataLines?.length) {
|
||||
components.push(new TextDisplay(params.metadataLines.join("\n")));
|
||||
}
|
||||
if (params.actionRow) {
|
||||
components.push(params.actionRow);
|
||||
}
|
||||
if (params.footer) {
|
||||
components.push(new Separator({ divider: false, spacing: "small" }));
|
||||
components.push(new TextDisplay(`-# ${params.footer}`));
|
||||
}
|
||||
super({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
components,
|
||||
accentColor: params.accentColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ExecApprovalActionButton extends Button {
|
||||
customId: string;
|
||||
label: string;
|
||||
style: ButtonStyle;
|
||||
|
||||
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
|
||||
super();
|
||||
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
|
||||
this.label = params.descriptor.label;
|
||||
this.style =
|
||||
params.descriptor.style === "success"
|
||||
? ButtonStyle.Success
|
||||
: params.descriptor.style === "primary"
|
||||
? ButtonStyle.Primary
|
||||
: params.descriptor.style === "danger"
|
||||
? ButtonStyle.Danger
|
||||
: ButtonStyle.Secondary;
|
||||
}
|
||||
}
|
||||
|
||||
class ExecApprovalActionRow extends Row<Button> {
|
||||
constructor(params: {
|
||||
approvalId: string;
|
||||
ask?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalDecision[];
|
||||
}) {
|
||||
super(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: params.approvalId,
|
||||
ask: params.ask,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
}).map(
|
||||
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createApprovalActionRow(request: ApprovalRequest): Row<Button> {
|
||||
if (isPluginApprovalRequest(request)) {
|
||||
return new ExecApprovalActionRow({
|
||||
approvalId: request.id,
|
||||
});
|
||||
}
|
||||
return new ExecApprovalActionRow({
|
||||
approvalId: request.id,
|
||||
ask: request.request.ask,
|
||||
allowedDecisions: request.request.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
|
||||
const lines: string[] = [];
|
||||
if (request.request.cwd) {
|
||||
lines.push(`- Working Directory: ${request.request.cwd}`);
|
||||
}
|
||||
if (request.request.host) {
|
||||
lines.push(`- Host: ${request.request.host}`);
|
||||
}
|
||||
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
|
||||
lines.push(`- Env Overrides: ${request.request.envKeys.join(", ")}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`- Agent: ${request.request.agentId}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildPluginApprovalMetadataLines(request: PluginApprovalRequest): string[] {
|
||||
const lines: string[] = [];
|
||||
const severity = request.request.severity ?? "warning";
|
||||
lines.push(
|
||||
`- Severity: ${severity === "critical" ? "Critical" : severity === "info" ? "Info" : "Warning"}`,
|
||||
);
|
||||
if (request.request.toolName) {
|
||||
lines.push(`- Tool: ${request.request.toolName}`);
|
||||
}
|
||||
if (request.request.pluginId) {
|
||||
lines.push(`- Plugin: ${request.request.pluginId}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`- Agent: ${request.request.agentId}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
|
||||
const components: TopLevelComponents[] = [container];
|
||||
return { components };
|
||||
}
|
||||
|
||||
function formatCommandPreview(commandText: string, maxChars: number): string {
|
||||
const commandRaw =
|
||||
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
|
||||
return commandRaw.replace(/`/g, "\u200b`");
|
||||
}
|
||||
|
||||
function formatOptionalCommandPreview(
|
||||
commandText: string | null | undefined,
|
||||
maxChars: number,
|
||||
): string | null {
|
||||
if (!commandText) {
|
||||
return null;
|
||||
}
|
||||
return formatCommandPreview(commandText, maxChars);
|
||||
}
|
||||
|
||||
function resolveExecApprovalPreviews(
|
||||
request: ExecApprovalRequest["request"],
|
||||
maxChars: number,
|
||||
secondaryMaxChars: number,
|
||||
): { commandPreview: string; commandSecondaryPreview: string | null } {
|
||||
const { commandText, commandPreview: secondaryPreview } =
|
||||
resolveExecApprovalCommandDisplay(request);
|
||||
return {
|
||||
commandPreview: formatCommandPreview(commandText, maxChars),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars),
|
||||
};
|
||||
}
|
||||
|
||||
function createExecApprovalRequestContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
1000,
|
||||
500,
|
||||
);
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Exec Approval Required",
|
||||
description: "A command needs your approval.",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
metadataLines: buildExecApprovalMetadataLines(params.request),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
|
||||
accentColor: "#FFA500",
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginApprovalRequestContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
const severity = params.request.request.severity ?? "warning";
|
||||
const accentColor =
|
||||
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval Required",
|
||||
description: "A plugin action needs your approval.",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecResolvedContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
|
||||
const decisionLabel =
|
||||
params.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
const accentColor =
|
||||
params.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: `Exec Approval: ${decisionLabel}`,
|
||||
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginResolvedContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const decisionLabel =
|
||||
params.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
const accentColor =
|
||||
params.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: `Plugin Approval: ${decisionLabel}`,
|
||||
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecExpiredContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Exec Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
commandPreview,
|
||||
commandSecondaryPreview,
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor: "#99AAB5",
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginExpiredContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor: "#99AAB5",
|
||||
});
|
||||
}
|
||||
|
||||
export type DiscordExecApprovalHandlerOpts = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
config: DiscordExecApprovalConfig;
|
||||
gatewayUrl?: string;
|
||||
cfg: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
|
||||
};
|
||||
|
||||
export class DiscordExecApprovalHandler {
|
||||
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
|
||||
private opts: DiscordExecApprovalHandlerOpts;
|
||||
|
||||
constructor(opts: DiscordExecApprovalHandlerOpts) {
|
||||
this.opts = opts;
|
||||
this.runtime = createChannelNativeApprovalRuntime<
|
||||
PendingApproval,
|
||||
PreparedDeliveryTarget,
|
||||
DiscordPendingDelivery
|
||||
>({
|
||||
label: "discord/exec-approvals",
|
||||
clientDisplayName: "Discord Exec Approvals",
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
gatewayUrl: this.opts.gatewayUrl,
|
||||
eventKinds: ["exec", "plugin"],
|
||||
nativeAdapter: createDiscordApprovalCapability(this.opts.config).native,
|
||||
isConfigured: () =>
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
configOverride: this.opts.config,
|
||||
}),
|
||||
shouldHandle: (request) => this.shouldHandle(request),
|
||||
buildPendingContent: ({ request }) => {
|
||||
const actionRow = createApprovalActionRow(request);
|
||||
const container = isPluginApprovalRequest(request)
|
||||
? createPluginApprovalRequestContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
actionRow,
|
||||
})
|
||||
: createExecApprovalRequestContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
actionRow,
|
||||
});
|
||||
const payload = buildExecApprovalPayload(container);
|
||||
return {
|
||||
body: stripUndefinedFields(serializePayload(payload)),
|
||||
};
|
||||
},
|
||||
sendOriginNotice: async ({ originTarget }) => {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(originTarget.to), {
|
||||
body: buildDiscordApprovalDmRedirectNotice(),
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"send-approval-dm-redirect-notice",
|
||||
);
|
||||
},
|
||||
prepareTarget: async ({ plannedTarget }) => {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
if (plannedTarget.surface === "origin") {
|
||||
return {
|
||||
dedupeKey: plannedTarget.target.to,
|
||||
target: {
|
||||
discordChannelId: plannedTarget.target.to,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const userId = plannedTarget.target.to;
|
||||
const dmChannel = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.userChannels(), {
|
||||
body: { recipient_id: userId },
|
||||
}) as Promise<{ id: string }>,
|
||||
"dm-channel",
|
||||
)) as { id: string };
|
||||
|
||||
if (!dmChannel?.id) {
|
||||
logError(`discord exec approvals: failed to create DM for user ${userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dedupeKey: dmChannel.id,
|
||||
target: {
|
||||
discordChannelId: dmChannel.id,
|
||||
recipientUserId: userId,
|
||||
},
|
||||
};
|
||||
},
|
||||
deliverTarget: async ({
|
||||
plannedTarget,
|
||||
preparedTarget,
|
||||
pendingContent,
|
||||
request: _request,
|
||||
}) => {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
const message = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
|
||||
body: pendingContent.body,
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
|
||||
)) as { id: string; channel_id: string };
|
||||
|
||||
if (!message?.id) {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logError("discord exec approvals: failed to send to channel");
|
||||
} else if (preparedTarget.recipientUserId) {
|
||||
logError(
|
||||
`discord exec approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
discordMessageId: message.id,
|
||||
discordChannelId: preparedTarget.discordChannelId,
|
||||
};
|
||||
},
|
||||
onOriginNoticeError: ({ error }) => {
|
||||
logError(`discord exec approvals: failed to send DM redirect notice: ${String(error)}`);
|
||||
},
|
||||
onDuplicateSkipped: ({ preparedTarget, request }) => {
|
||||
logDebug(
|
||||
`discord exec approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
|
||||
);
|
||||
},
|
||||
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logDebug(
|
||||
`discord exec approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
logDebug(
|
||||
`discord exec approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`,
|
||||
);
|
||||
},
|
||||
onDeliveryError: ({ error, plannedTarget }) => {
|
||||
if (plannedTarget.surface === "origin") {
|
||||
logError(`discord exec approvals: failed to send to channel: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
logError(
|
||||
`discord exec approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
|
||||
);
|
||||
},
|
||||
finalizeResolved: async ({ request, resolved, entries }) => {
|
||||
await this.finalizeResolved(request, resolved, entries);
|
||||
},
|
||||
finalizeExpired: async ({ request, entries }) => {
|
||||
await this.finalizeExpired(request, entries);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
shouldHandle(request: ApprovalRequest): boolean {
|
||||
return shouldHandleDiscordApprovalRequest({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
request,
|
||||
configOverride: this.opts.config,
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.runtime.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.runtime.stop();
|
||||
}
|
||||
|
||||
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
|
||||
await this.runtime.handleRequested(request);
|
||||
}
|
||||
|
||||
async handleApprovalResolved(resolved: ApprovalResolved): Promise<void> {
|
||||
await this.runtime.handleResolved(resolved);
|
||||
}
|
||||
|
||||
async handleApprovalTimeout(approvalId: string, _source?: "channel" | "dm"): Promise<void> {
|
||||
await this.runtime.handleExpired(approvalId);
|
||||
}
|
||||
|
||||
private async finalizeResolved(
|
||||
request: ApprovalRequest,
|
||||
resolved: ApprovalResolved,
|
||||
entries: PendingApproval[],
|
||||
): Promise<void> {
|
||||
const container = isPluginApprovalRequest(request)
|
||||
? createPluginResolvedContainer({
|
||||
request,
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})
|
||||
: createExecResolvedContainer({
|
||||
request,
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
|
||||
for (const pending of entries) {
|
||||
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeExpired(
|
||||
request: ApprovalRequest,
|
||||
entries: PendingApproval[],
|
||||
): Promise<void> {
|
||||
const container = isPluginApprovalRequest(request)
|
||||
? createPluginExpiredContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})
|
||||
: createExecExpiredContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
for (const pending of entries) {
|
||||
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
container: DiscordUiContainer,
|
||||
): Promise<void> {
|
||||
if (!this.opts.config.cleanupAfterResolve) {
|
||||
await this.updateMessage(channelId, messageId, container);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
|
||||
await discordRequest(
|
||||
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
|
||||
"delete-approval",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
|
||||
await this.updateMessage(channelId, messageId, container);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
container: DiscordUiContainer,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
const payload = buildExecApprovalPayload(container);
|
||||
|
||||
await discordRequest(
|
||||
() =>
|
||||
rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: stripUndefinedFields(serializePayload(payload)),
|
||||
}),
|
||||
"update-approval",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: failed to update message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
|
||||
const method =
|
||||
resolveApprovalKindFromId(approvalId) === "plugin"
|
||||
? "plugin.approval.resolve"
|
||||
: "exec.approval.resolve";
|
||||
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
|
||||
|
||||
try {
|
||||
await this.runtime.request(method, {
|
||||
id: approvalId,
|
||||
decision,
|
||||
});
|
||||
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: resolve failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the list of configured approver IDs. */
|
||||
getApprovers(): string[] {
|
||||
return getDiscordExecApprovalApprovers({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
configOverride: this.opts.config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecApprovalButtonContext = {
|
||||
handler: DiscordExecApprovalHandler;
|
||||
getApprovers: () => string[];
|
||||
resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export class ExecApprovalButton extends Button {
|
||||
label = "execapproval";
|
||||
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
|
||||
customId = "execapproval:seed=1";
|
||||
style = ButtonStyle.Primary;
|
||||
private ctx: ExecApprovalButtonContext;
|
||||
|
||||
constructor(ctx: ExecApprovalButtonContext) {
|
||||
constructor(private readonly ctx: ExecApprovalButtonContext) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
@@ -808,14 +73,11 @@ export class ExecApprovalButton extends Button {
|
||||
content: "This approval is no longer valid.",
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the user is an authorized approver
|
||||
const approvers = this.ctx.handler.getApprovers();
|
||||
const approvers = this.ctx.getApprovers();
|
||||
const userId = interaction.userId;
|
||||
if (!approvers.some((id) => String(id) === userId)) {
|
||||
try {
|
||||
@@ -823,9 +85,7 @@ export class ExecApprovalButton extends Button {
|
||||
content: "⛔ You are not authorized to approve exec requests.",
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,31 +96,52 @@ export class ExecApprovalButton extends Button {
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
// Acknowledge immediately so Discord does not fail the interaction while
|
||||
// the gateway resolve roundtrip completes. The resolved event will update
|
||||
// the approval card in-place with the final state.
|
||||
try {
|
||||
await interaction.acknowledge();
|
||||
} catch {
|
||||
// Interaction may have expired, try to continue anyway
|
||||
}
|
||||
|
||||
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
|
||||
} catch {}
|
||||
|
||||
const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action);
|
||||
if (!ok) {
|
||||
try {
|
||||
await interaction.followUp({
|
||||
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// On success, the handleApprovalResolved event will update the message with the final result
|
||||
}
|
||||
}
|
||||
|
||||
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
|
||||
return new ExecApprovalButton(ctx);
|
||||
}
|
||||
|
||||
export function createDiscordExecApprovalButtonContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
config: DiscordExecApprovalConfig;
|
||||
gatewayUrl?: string;
|
||||
}): ExecApprovalButtonContext {
|
||||
return {
|
||||
getApprovers: () =>
|
||||
getDiscordExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
configOverride: params.config,
|
||||
}),
|
||||
resolveApproval: async (approvalId, decision) => {
|
||||
try {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: params.cfg,
|
||||
approvalId,
|
||||
decision,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
clientDisplayName: `Discord approval (${params.accountId})`,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import * as undici from "undici";
|
||||
import * as ws from "ws";
|
||||
import { validateDiscordProxyUrl } from "../proxy-fetch.js";
|
||||
@@ -57,7 +58,7 @@ function isTransientDiscordGatewayResponse(status: number, body: string): boolea
|
||||
if (status >= 500) {
|
||||
return true;
|
||||
}
|
||||
const normalized = body.toLowerCase();
|
||||
const normalized = normalizeLowercaseStringOrEmpty(body);
|
||||
return (
|
||||
normalized.includes("upstream connect error") ||
|
||||
normalized.includes("disconnect/reset before headers") ||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { mergeAbortSignals } from "./timeouts.js";
|
||||
|
||||
const DISCORD_CDN_HOSTNAMES = [
|
||||
@@ -564,7 +565,7 @@ function isImageAttachment(attachment: APIAttachment): boolean {
|
||||
if (mime.startsWith("image/")) {
|
||||
return true;
|
||||
}
|
||||
const name = attachment.filename?.toLowerCase() ?? "";
|
||||
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -274,10 +274,9 @@ describe("Discord native slash commands with commands.allowFrom", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock
|
||||
.calls[0]?.[0] as
|
||||
const dispatchCall:
|
||||
| Parameters<typeof dispatcherModule.dispatchReplyWithDispatcher>[0]
|
||||
| undefined;
|
||||
| undefined = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock.calls[0]?.[0];
|
||||
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
|
||||
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
|
||||
@@ -48,19 +48,18 @@ function createNativeCommand(
|
||||
throw new Error(`missing native command: ${name}`);
|
||||
}
|
||||
const baseCfg: ReturnType<typeof loadConfig> = opts?.cfg ?? {};
|
||||
const discordConfig = (opts?.discordConfig ?? baseCfg.channels?.discord ?? {}) as NonNullable<
|
||||
OpenClawConfig["channels"]
|
||||
>["discord"];
|
||||
const discordConfig: NonNullable<OpenClawConfig["channels"]>["discord"] =
|
||||
opts?.discordConfig ?? baseCfg.channels?.discord ?? {};
|
||||
const cfg =
|
||||
opts?.discordConfig === undefined
|
||||
? baseCfg
|
||||
: ({
|
||||
: {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
discord: discordConfig,
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
};
|
||||
return createDiscordNativeCommand({
|
||||
command,
|
||||
cfg,
|
||||
|
||||
@@ -312,7 +312,9 @@ function buildDiscordCommandOptions(params: {
|
||||
model: context?.model,
|
||||
});
|
||||
const filtered = focusValue
|
||||
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
|
||||
? choices.filter((choice) =>
|
||||
normalizeLowercaseStringOrEmpty(choice.label).includes(focusValue),
|
||||
)
|
||||
: choices;
|
||||
await interaction.respond(
|
||||
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user