Compare commits

..

1 Commits

Author SHA1 Message Date
Tak Hoffman
91625aa9f3 feat capability CLI on latest main 2026-04-06 17:30:59 -05:00
1372 changed files with 18899 additions and 28374 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -6,57 +6,68 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- 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.
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
- 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.
### Fixes
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- 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.
- 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)
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) 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.
- 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.
- 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.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Discord/gateway monitor: use `ws://` again for gateway monitor sockets so Discord monitor connections recover reliably after recent gateway socket changes.
- Control UI/auth URLs: detect mistaken `?token=` links, show the correct `#token=` fragment hint only on real auth failures, and stop masking the real problem behind a generic device-identity error. (#54842)
- Control UI/chat layout: keep Copy and Canvas actions plus mobile exec-approval overlays from covering chat text or command previews on narrow screens. (#61514)
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps its content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Secrets/x_search: keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load after the plugin-owned auth move.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
- TUI/history and heartbeat: keep assistant commentary hidden on both streamed and reloaded TUI history views, preserve the phase-sanitized REST history contract, and stop forced heartbeat runs from targeting subagent sessions. (#61463) Thanks @100yenadmin.
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
- 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.
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
- Providers/OpenAI: accept case-insensitive `plugins.entries.openai.config.personality` values, keep unknown overrides on the friendly overlay path, and add `on` as an alias for `friendly`. Thanks @vincentkoc.
- Discord/thread titles: stop forcing a hardcoded temperature for generated auto-thread names so Codex-backed thread title generation works on `openai-codex/*` models again. (#59525)
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops 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`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
- Agents/history: keep history-based reply reads and subagent completion summaries on `final_answer` text only so internal commentary stops leaking into user-visible follow-up replies. (#61747) Thanks @afurm.
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
- 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)
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution.
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- 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)
- 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.
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
## 2026.4.5

View File

@@ -62,7 +62,6 @@ RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
@@ -160,6 +159,10 @@ COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@@ -1327,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -361,14 +361,6 @@
}
}
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation",
"plan.0.step"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -1327,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1,4 +1,4 @@
64ff922efc6146d867f3858141772094a8a72cba99a8fd61878551175dd8c822 config-baseline.json
5d0ce975352ff2b03077f6d71e9fe99ab0f0b118da0f72d47dc989c83f13d668 config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2375f50cfc8f29df7de7ec84d72ea654ef6eb203204f295fc287a11d63ed2cdb plugin-sdk-api-baseline.json
103ee7cf995fe6a0a3d109ad4bb785d342fa1eba83e3295dee3a5ee0bb315b4a plugin-sdk-api-baseline.jsonl
08615a28ed3deb20a96c9cd8fd7237a4cbb209ceec93dca03b543979304459e4 plugin-sdk-api-baseline.json
683c1249dc15529d8e79bc75e9c00484551cb74126befee507fffcf786e01833 plugin-sdk-api-baseline.jsonl

View File

@@ -227,10 +227,7 @@ Quick mental model (evaluation order for group messages):
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention when the channel
supports reply metadata. Quoting a bot message can also count as an implicit
mention on channels that expose quote metadata. Current built-in cases include
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5
{

View File

@@ -61,17 +61,13 @@ What the Matrix wizard actually asks for:
- optional device name
- whether to enable E2EE
- whether to configure Matrix room access now
- whether to configure Matrix invite auto-join now
- when invite auto-join is enabled, whether it should be `allowlist`, `always`, or `off`
Wizard behavior that matters:
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
@@ -81,8 +77,6 @@ Wizard behavior that matters:
If you leave it unset, the bot will not join invited rooms or fresh DM-style invites, so it will not appear in new groups or invited DMs unless you join manually first.
Set `autoJoin: "allowlist"` together with `autoJoinAllowlist` to restrict which invites it accepts, or set `autoJoin: "always"` if you want it to join every invite.
In `allowlist` mode, `autoJoinAllowlist` only accepts `!roomId:server`, `#alias:server`, or `*`.
</Warning>
Allowlist example:

View File

@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
@@ -423,7 +423,6 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
Reply threading controls:

View File

@@ -124,7 +124,6 @@ Example:
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
- This applies both to allowlisted groups and open group mode.
- Quoting a bot message counts as an implicit mention for group activation.
- Authorized control commands (for example `/new`) can bypass mention gating.
- When a group message is skipped because mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
- Group history limit defaults to `messages.groupChat.historyLimit` (fallback `50`). You can override per account with `channels.zalouser.historyLimit`.

116
docs/cli/capability.md Normal file
View File

@@ -0,0 +1,116 @@
---
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw capability` commands
- Designing stable headless capability automation
title: "Capability CLI"
---
# Capability CLI
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw capability
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
media
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
memory
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw capability model run --prompt "hello" --json
openclaw capability media image generate --prompt "friendly lobster" --json
openclaw capability media tts status --json
openclaw capability embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "media.image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,6 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`capability`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -248,6 +249,16 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
capability
list
inspect
model run|list|inspect|providers|auth login|logout|status
media image generate|edit|describe|describe-many|providers
media audio transcribe|providers
media tts convert|voices|providers|status|enable|disable|set-provider
media video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token
auth order get|set|clear
sandbox

View File

@@ -47,7 +47,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: ten sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Config: five sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
@@ -58,7 +58,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- Should be fast and stable
- Projects note:
- Untargeted `pnpm test` now runs ten smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-ui`, `core-unit-support`, `core-contracts`, `core-bundled`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- Untargeted `pnpm test` now runs eight smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-support`, `core-contracts`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
@@ -305,15 +305,12 @@ Notes:
- `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts`
- `OPENCLAW_LIVE_ACP_BIND=1`
- Defaults:
- ACP agents in Docker: `claude,codex,gemini`
- ACP agent for direct `pnpm test:live ...`: `claude`
- ACP agent: `claude`
- Synthetic channel: Slack DM-style conversation context
- ACP backend: `acpx`
- Overrides:
- `OPENCLAW_LIVE_ACP_BIND_AGENT=claude`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=codex`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@<version>'`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
@@ -333,20 +330,10 @@ Docker recipe:
pnpm test:docker:live-acp-bind
```
Single-agent Docker recipes:
```bash
pnpm test:docker:live-acp-bind:claude
pnpm test:docker:live-acp-bind:codex
pnpm test:docker:live-acp-bind:gemini
```
Docker notes:
- The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`.
- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`.
- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing.
- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI.
### Recommended live recipes

View File

@@ -1120,8 +1120,7 @@ authoring plugins:
`openclaw/plugin-sdk/secret-input`, and
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
wiring. `channel-inbound` is the shared home for debounce, mention matching,
inbound mention-policy helpers, envelope formatting, and inbound envelope
context helpers.
envelope formatting, and inbound envelope context helpers.
`channel-setup` is the narrow optional-install setup seam.
`setup-runtime` is the runtime-safe setup surface used by `setupEntry` /
deferred startup, including the import-safe setup patch adapters.

View File

@@ -152,87 +152,6 @@ surfaces:
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
## Inbound mention policy
Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
- reply-to-bot detection
- quoted-bot detection
- thread-participation checks
- service/system-message exclusions
- platform-native caches needed to prove bot participation
Good fit for the shared helper:
- `requireMention`
- explicit mention result
- implicit mention allowlist
- command bypass
- final skip decision
Preferred flow:
1. Compute local mention facts.
2. Pass those facts into `resolveInboundMentionDecision({ facts, policy })`.
3. Use `decision.effectiveWasMentioned`, `decision.shouldBypassMention`, and `decision.shouldSkip` in your inbound gate.
```typescript
import {
implicitMentionKindWhen,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
const mentionMatch = matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const facts = {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
hasAnyMention: mentionMatch.hasExplicitMention,
implicitMentionKinds: [
...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
],
};
const decision = resolveInboundMentionDecision({
facts,
policy: {
isGroup,
requireMention,
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
if (decision.shouldSkip) return;
```
`api.runtime.channel.mentions` exposes the same shared mention helpers for
bundled channel plugins that already depend on runtime injection:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.
## Walkthrough
<Steps>

View File

@@ -249,8 +249,7 @@ Current bundled provider examples:
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
| `plugin-sdk/provider-http` | Provider HTTP helpers | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch` | Provider web-fetch helpers | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |

View File

@@ -108,7 +108,7 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -134,8 +134,7 @@ explicitly promotes one as public.
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch` | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
@@ -157,7 +156,6 @@ explicitly promotes one as public.
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |

View File

@@ -330,46 +330,6 @@ api.runtime.tools.registerMemoryCli(/* ... */);
Channel-specific runtime helpers (available when a channel plugin is loaded).
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for
bundled channel plugins that use runtime injection:
```typescript
const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
"reply_to_bot",
isReplyToBot,
),
},
policy: {
isGroup,
requireMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
```
Available mention helpers:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
`api.runtime.channel.mentions` intentionally does not expose the older
`resolveMentionGating*` compatibility helpers. Prefer the normalized
`{ facts, policy }` path.
## Storing runtime references
Use `createPluginRuntimeStore` to store the runtime reference for use outside

View File

@@ -13,7 +13,7 @@ title: "Tests"
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute ten sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eight sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.

View File

@@ -68,9 +68,7 @@ Use `action: "list"` to inspect available providers and models at runtime:
| `count` | number | Number of images to generate (14) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. When a fallback provider supports a nearby geometry option instead of the exact requested one, OpenClaw remaps to the closest supported size, aspect ratio, or resolution before submission. Truly unsupported overrides are still reported in the tool result.
Tool results report the applied settings. When OpenClaw remaps geometry during provider fallback, the returned `size`, `aspectRatio`, and `resolution` values reflect what was actually sent, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. The tool passes what each provider supports, ignores the rest, and reports dropped overrides in the tool result.
## Configuration
@@ -106,10 +104,6 @@ Notes:
- Auto-detection is auth-aware. A provider default only enters the candidate list
when OpenClaw can actually authenticate that provider.
- Auto-detection is enabled by default. Set
`agents.defaults.mediaGenerationAutoProviderFallback: false` if you want image
generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
- Use `action: "list"` to inspect the currently registered providers, their
default models, and auth env-var hints.

View File

@@ -131,12 +131,8 @@ Direct generation example:
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw still validates hard limits
such as input counts before submission. When a provider supports duration but
uses a shorter maximum than the requested value, OpenClaw automatically clamps
to the closest supported duration. Truly unsupported optional hints are ignored
with a warning when the selected provider or model cannot honor them.
Tool results report the applied settings. When OpenClaw clamps duration during provider fallback, the returned `durationSeconds` reflects the submitted value and `details.normalization.durationSeconds` shows the requested-to-applied mapping.
such as input counts before submission, but unsupported optional hints are
ignored with a warning when the selected provider or model cannot honor them.
## Async behavior for the shared provider-backed path
@@ -198,10 +194,6 @@ When generating music, OpenClaw tries providers in this order:
If a provider fails, the next candidate is tried automatically. If all fail, the
error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
music generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
## Provider notes
- Google uses Lyria 3 batch generation. The current bundled flow supports

View File

@@ -154,9 +154,7 @@ and the shared live sweep.
| `model` | string | Provider/model override (e.g. `runway/gen4.5`) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw already normalizes duration to the closest provider-supported value, and it also remaps translated geometry hints such as size-to-aspect-ratio when a fallback provider exposes a different control surface. Truly unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Tool results report the applied settings. When OpenClaw remaps duration or geometry during provider fallback, the returned `durationSeconds`, `size`, `aspectRatio`, and `resolution` values reflect what was submitted, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. Unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Reference inputs also select the runtime mode:
@@ -184,10 +182,6 @@ When generating a video, OpenClaw resolves the model in this order:
If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
video generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
```json5
{
agents: {

View File

@@ -4,7 +4,7 @@
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"acpx": "0.5.1"
"acpx": "0.5.0"
},
"openclaw": {
"extensions": [

View File

@@ -1,56 +0,0 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
export type AcpAgentRegistry = {
resolve(agent: string): string | undefined;
list(): string[];
};
export type AcpSessionRecord = Record<string, unknown>;
export type AcpSessionStore = {
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
save(record: AcpSessionRecord): Promise<void>;
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
mcpServers?: unknown;
permissionMode?: unknown;
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: {
handle?: AcpRuntimeHandle;
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

55
extensions/acpx/src/acpx-runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport =
import("../../../src/acp/runtime/types.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput =
import("../../../src/acp/runtime/types.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../../../src/acp/runtime/types.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../../../src/acp/runtime/types.js").AcpRuntimeHandle;
export type AcpRuntimeTurnInput = import("../../../src/acp/runtime/types.js").AcpRuntimeTurnInput;
export type AcpRuntimeStatus = import("../../../src/acp/runtime/types.js").AcpRuntimeStatus;
export type AcpRuntimeCapabilities =
import("../../../src/acp/runtime/types.js").AcpRuntimeCapabilities;
export type AcpSessionStore = {
load(sessionId: string): Promise<unknown>;
save(record: unknown): Promise<void>;
};
export type AcpAgentRegistry = {
resolve(agentId: string): string;
list(): string[];
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
permissionMode: string;
mcpServers?: unknown[];
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): unknown;
export function createAgentRegistry(...args: unknown[]): AcpAgentRegistry;
export function createFileSessionStore(...args: unknown[]): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

View File

@@ -1,6 +0,0 @@
export function formatErrorMessage(error) {
if (error instanceof Error) {
return error.message || error.name || "Error";
}
return String(error);
}

View File

@@ -4,7 +4,6 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "./error-format.mjs";
import { splitCommandLine } from "./mcp-command-line.mjs";
function decodePayload(argv) {
@@ -95,7 +94,7 @@ function main() {
child.stdout.pipe(process.stdout);
child.on("error", (error) => {
process.stderr.write(`${formatErrorMessage(error)}\n`);
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -37,8 +37,6 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -142,50 +140,4 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("can skip the embedded runtime probe via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {});
const service = createAcpxRuntimeService({
runtimeFactory: () =>
({
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability,
isHealthy: () => false,
doctor: async () => ({ ok: false, message: "nope" }),
}) as never,
});
await service.start(ctx);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
await service.stop?.(ctx);
});
it("can skip the embedded runtime backend via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtimeFactory = vi.fn(() => {
throw new Error("runtime factory should not run when ACPX is skipped");
});
const service = createAcpxRuntimeService({
runtimeFactory: runtimeFactory as never,
});
await service.start(ctx);
expect(runtimeFactory).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
);
});
});

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
AcpRuntime,
OpenClawPluginService,
@@ -91,11 +90,6 @@ export function createAcpxRuntimeService(
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
@@ -119,10 +113,6 @@ export function createAcpxRuntimeService(
});
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
return;
}
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
@@ -146,7 +136,9 @@ export function createAcpxRuntimeService(
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
ctx.logger.warn(
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},

View File

@@ -1,5 +1,4 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@@ -103,7 +102,7 @@ export async function generateBearerTokenFromIam(params: {
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {
region: params.region,
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
@@ -234,7 +233,7 @@ export async function discoverMantleModels(params: {
return models;
} catch (error) {
log.debug?.("Mantle model discovery error", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return cached?.models ?? [];
}

View File

@@ -1,11 +1,13 @@
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
type JsonRecord = Record<string, unknown>;
const LEGACY_PATH = "models.bedrockDiscovery";
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBlockedObjectKey(key: string): boolean {
return BLOCKED_OBJECT_KEYS.has(key);
}

View File

@@ -6,7 +6,6 @@ import {
type ListInferenceProfilesCommandOutput,
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
import type {
BedrockDiscoveryConfig,
@@ -215,7 +214,7 @@ async function fetchInferenceProfileSummaries(
return profiles;
} catch (error) {
log.debug?.("Skipping inference profile discovery", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return [];
}
@@ -407,7 +406,7 @@ export async function discoverBedrockModels(params: {
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
log.warn("Failed to discover Bedrock models", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
}
return [];

View File

@@ -1,12 +1,6 @@
import { capturePluginRegistration } from "openclaw/plugin-sdk/testing";
import { describe, expect, it, vi } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import {
ANTHROPIC_FRONTIER_EXECUTION_BIAS,
ANTHROPIC_FRONTIER_INTERACTION_STYLE,
ANTHROPIC_FRONTIER_OUTPUT_CONTRACT,
ANTHROPIC_FRONTIER_TOOL_CALL_STYLE,
} from "./prompt-overlay.js";
const { readClaudeCliCredentialsForSetupMock, readClaudeCliCredentialsForRuntimeMock } = vi.hoisted(
() => ({
@@ -53,60 +47,6 @@ describe("anthropic provider replay hooks", () => {
).toBe("native");
});
it("registers frontier Claude prompt contributions", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const contribution = {
stablePrefix: ANTHROPIC_FRONTIER_OUTPUT_CONTRACT,
sectionOverrides: {
interaction_style: ANTHROPIC_FRONTIER_INTERACTION_STYLE,
tool_call_style: ANTHROPIC_FRONTIER_TOOL_CALL_STYLE,
execution_bias: ANTHROPIC_FRONTIER_EXECUTION_BIAS,
},
};
expect(
provider.resolveSystemPromptContribution?.({
config: undefined,
agentDir: undefined,
workspaceDir: undefined,
provider: "anthropic",
modelId: "claude-sonnet-4-6",
promptMode: "full",
runtimeChannel: undefined,
runtimeCapabilities: undefined,
agentId: undefined,
} as never),
).toEqual(contribution);
expect(
provider.resolveSystemPromptContribution?.({
config: undefined,
agentDir: undefined,
workspaceDir: undefined,
provider: "claude-cli",
modelId: "claude-sonnet-4-6",
promptMode: "full",
runtimeChannel: undefined,
runtimeCapabilities: undefined,
agentId: undefined,
} as never),
).toEqual(contribution);
expect(
provider.resolveSystemPromptContribution?.({
config: undefined,
agentDir: undefined,
workspaceDir: undefined,
provider: "anthropic",
modelId: "claude-haiku-4-5",
promptMode: "full",
runtimeChannel: undefined,
runtimeCapabilities: undefined,
agentId: undefined,
} as never),
).toBeUndefined();
});
it("owns replay policy for Claude transports", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);

View File

@@ -1,72 +0,0 @@
import { CLAUDE_CLI_BACKEND_ID } from "./cli-shared.js";
const ANTHROPIC_PROVIDER_IDS = new Set(["anthropic", CLAUDE_CLI_BACKEND_ID]);
const ANTHROPIC_FRONTIER_MODEL_PREFIXES = ["claude-sonnet-4", "claude-opus-4"];
export const ANTHROPIC_FRONTIER_INTERACTION_STYLE = `## Interaction Style
Be warm, collaborative, and quietly supportive.
Communicate like a capable teammate, not a policy document.
Keep preambles short and use brief progress updates while you work.
If the user asks you to do the work, act in the same turn instead of restating the plan.
Default to concise natural replies unless the user asks for depth.
Let personality show through phrasing and judgment, not through filler.`;
export const ANTHROPIC_FRONTIER_OUTPUT_CONTRACT = `## Claude Output Contract
Follow the latest user instruction over older summaries, memories, or prior plans when they conflict.
Do not present a summary, restatement, or plan as if it were real progress.
Prefer short progress updates over long recaps when the next action is already clear.
Stay within the current request instead of widening into unrelated scenario updates, grand wrap-ups, or suite-wide status recaps.
For scoped reports, return plain bullets or short paragraphs unless the user explicitly asked for tables, decorative headings, or ornamental markdown.
Do not invent final tallies, "QA complete" claims, or overall completion counts unless the current turn or tool results actually established them.
Multi-part requests stay incomplete until every requested item is handled or clearly marked blocked.`;
export const ANTHROPIC_FRONTIER_EXECUTION_BIAS = `## Execution Bias
When tools are available and the next action is clear, act before recapping.
Do not say you will inspect, search, open, edit, or verify something unless you emit the tool call in the same turn.
After compaction or summary refresh, resume the next unfinished action instead of restarting the analysis from scratch.
Keep going until the requested outcome is complete or clearly blocked.`;
export const ANTHROPIC_FRONTIER_TOOL_CALL_STYLE = `## Tool Call Style
For routine inspection, search, open, read, edit, or verify steps, call the tool immediately instead of narrating the intent first.
Keep pre-tool commentary brief and only use it when the action is sensitive, non-obvious, or user-requested.`;
function matchesAnthropicFrontierModel(modelId?: string): boolean {
const normalizedModelId = modelId?.trim().toLowerCase() ?? "";
return ANTHROPIC_FRONTIER_MODEL_PREFIXES.some((prefix) => normalizedModelId.startsWith(prefix));
}
export function shouldApplyAnthropicPromptOverlay(params: {
modelProviderId?: string;
modelId?: string;
}): boolean {
return (
ANTHROPIC_PROVIDER_IDS.has(params.modelProviderId ?? "") &&
matchesAnthropicFrontierModel(params.modelId)
);
}
export function resolveAnthropicSystemPromptContribution(params: {
modelProviderId?: string;
modelId?: string;
}) {
if (
!shouldApplyAnthropicPromptOverlay({
modelProviderId: params.modelProviderId,
modelId: params.modelId,
})
) {
return undefined;
}
return {
stablePrefix: ANTHROPIC_FRONTIER_OUTPUT_CONTRACT,
sectionOverrides: {
interaction_style: ANTHROPIC_FRONTIER_INTERACTION_STYLE,
tool_call_style: ANTHROPIC_FRONTIER_TOOL_CALL_STYLE,
execution_bias: ANTHROPIC_FRONTIER_EXECUTION_BIAS,
},
};
}

View File

@@ -33,7 +33,6 @@ import {
normalizeAnthropicProviderConfig,
} from "./config-defaults.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { resolveAnthropicSystemPromptContribution } from "./prompt-overlay.js";
import { buildAnthropicReplayPolicy } from "./replay-policy.js";
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
@@ -463,11 +462,6 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
provider.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()
: undefined,
resolveSystemPromptContribution: (ctx) =>
resolveAnthropicSystemPromptContribution({
modelProviderId: ctx.provider,
modelId: ctx.modelId,
}),
buildReplayPolicy: buildAnthropicReplayPolicy,
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
resolveReasoningOutputMode: () => "native",

View File

@@ -8,7 +8,6 @@ import {
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("anthropic-stream");
@@ -158,9 +157,9 @@ export function createAnthropicFastModeWrapper(
}
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: readStringValue(model.provider),
api: readStringValue(model.api),
baseUrl: readStringValue(model.baseUrl),
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {
@@ -184,9 +183,9 @@ export function createAnthropicServiceTierWrapper(
}
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: readStringValue(model.provider),
api: readStringValue(model.api),
baseUrl: readStringValue(model.baseUrl),
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -5,7 +5,6 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
@@ -59,7 +58,7 @@ export function resolveBlueBubblesAccount(params: {
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
config: merged,
configured,
baseUrl,

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
@@ -131,7 +130,7 @@ export async function downloadBlueBubblesAttachment(
cause: error,
});
}
const text = formatErrorMessage(error);
const text = error instanceof Error ? error.message : String(error);
throw new Error(`BlueBubbles attachment download failed: ${text}`, { cause: error });
}
}

View File

@@ -17,7 +17,6 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
@@ -136,7 +135,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalizeOptionalString(normalized);
const to = normalized?.trim();
if (!to) {
return null;
}
@@ -161,7 +160,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = normalizeOptionalString(value);
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
@@ -197,7 +196,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = normalizeOptionalString(display);
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
@@ -215,7 +214,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
}
// Last resort: return display or target as-is
return normalizeOptionalString(display) || normalizeOptionalString(target) || "";
return display?.trim() || target?.trim() || "";
},
},
setup: blueBubblesSetupAdapter,
@@ -287,7 +286,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: normalizeOptionalString(context.To),
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef,
}),
@@ -315,7 +314,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = normalizeOptionalString(to);
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
@@ -329,7 +328,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = normalizeOptionalString(replyToId) ?? "";
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";

View File

@@ -1,10 +1,20 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/infra-runtime";
import { asNullableRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";
export const asRecord = asNullableRecord;
const readString = readStringField;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) {

View File

@@ -1,13 +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 { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import {
asRecord,
normalizeWebhookMessage,
normalizeWebhookReaction,
} from "./monitor-normalize.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
@@ -93,11 +88,17 @@ function parseBlueBubblesWebhookPayload(
try {
return { ok: true, value: JSON.parse(payload) as unknown };
} catch (error) {
return { ok: false, error: formatErrorMessage(error) };
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function maskSecret(value: string): string {
if (value.length <= 6) {
return "***";

View File

@@ -1,5 +1,3 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BaseProbeResult } from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
@@ -25,7 +23,7 @@ const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires:
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
return normalizeOptionalString(accountId) || "default";
return accountId?.trim() || "default";
}
/**
@@ -174,7 +172,7 @@ export async function probeBlueBubbles(params: {
return {
ok: false,
status: null,
error: formatErrorMessage(err),
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -1,4 +1,3 @@
import { asRecord } from "./monitor-normalize.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
@@ -25,6 +24,11 @@ export function extractBlueBubblesMessageId(payload: unknown): string {
return "unknown";
}
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
const record = payload as Record<string, unknown>;
const dataRecord = asRecord(record.data);
const resultRecord = asRecord(record.result);

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { normalizeOptionalString, stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import {
getCachedBlueBubblesPrivateApiStatus,
@@ -137,9 +137,8 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
chat.chat_identifier,
];
for (const candidate of candidates) {
const value = normalizeOptionalString(candidate);
if (value) {
return value;
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
@@ -160,7 +159,8 @@ function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
if (parts.length < 3) {
return null;
}
return normalizeOptionalString(parts[2]) ?? null;
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
@@ -479,7 +479,7 @@ export async function sendMessageBlueBubbles(
);
}
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,

View File

@@ -8,7 +8,6 @@ import {
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesAccount, resolveDefaultBlueBubblesAccountId } from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
@@ -40,7 +39,7 @@ function validateBlueBubblesAllowFromEntry(value: string): string | null {
if (parsed.kind === "handle" && !parsed.handle) {
return null;
}
return normalizeOptionalString(value) ?? null;
return value.trim() || null;
} catch {
return null;
}
@@ -77,7 +76,7 @@ const promptBlueBubblesAllowFrom = createPromptParsedAllowFromForAccount({
});
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
const trimmed = normalizeOptionalString(value) ?? "";
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return "Required";
}
@@ -109,11 +108,11 @@ function applyBlueBubblesSetupPatch(
}
function resolveBlueBubblesServerUrl(cfg: OpenClawConfig, accountId: string): string | undefined {
return normalizeOptionalString(resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl);
return resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl?.trim() || undefined;
}
function resolveBlueBubblesWebhookPath(cfg: OpenClawConfig, accountId: string): string | undefined {
return normalizeOptionalString(resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath);
return resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath?.trim() || undefined;
}
function validateBlueBubblesWebhookPath(value: string): string | undefined {

View File

@@ -1,5 +1,4 @@
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
import { asRecord } from "./monitor-normalize.js";
import type { ChannelAccountSnapshot } from "./runtime-api.js";
type BlueBubblesAccountStatus = {
@@ -18,6 +17,10 @@ type BlueBubblesProbeResult = {
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -25,30 +28,28 @@ function asString(value: unknown): string | null {
function readBlueBubblesAccountStatus(
value: ChannelAccountSnapshot,
): BlueBubblesAccountStatus | null {
const record = asRecord(value);
if (!record) {
if (!isRecord(value)) {
return null;
}
return {
accountId: record.accountId,
enabled: record.enabled,
configured: record.configured,
running: record.running,
baseUrl: record.baseUrl,
lastError: record.lastError,
probe: record.probe,
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
running: value.running,
baseUrl: value.baseUrl,
lastError: value.lastError,
probe: value.probe,
};
}
function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null {
const record = asRecord(value);
if (!record) {
if (!isRecord(value)) {
return null;
}
return {
ok: typeof record.ok === "boolean" ? record.ok : undefined,
status: typeof record.status === "number" ? record.status : null,
error: asString(record.error) ?? null,
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: typeof value.status === "number" ? value.status : null,
error: asString(value.error) ?? null,
};
}

View File

@@ -6,7 +6,6 @@ import {
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/channel-targets";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export type BlueBubblesService = "imessage" | "sms" | "auto";
@@ -30,7 +29,7 @@ const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
const trimmed = normalizeOptionalString(value);
const trimmed = value.trim();
if (!trimmed) {
return null;
}
@@ -38,9 +37,9 @@ function parseRawChatGuid(value: string): string | null {
if (parts.length !== 3) {
return null;
}
const service = normalizeOptionalString(parts[0]);
const separator = normalizeOptionalString(parts[1]);
const identifier = normalizeOptionalString(parts[2]);
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) {
return null;
}
@@ -55,7 +54,7 @@ function stripPrefix(value: string, prefix: string): string {
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = normalizeOptionalString(value) ?? "";
const trimmed = value.trim();
if (!trimmed) {
return "";
}
@@ -66,7 +65,7 @@ function stripBlueBubblesPrefix(value: string): string {
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = normalizeOptionalString(value);
const trimmed = value.trim();
if (!trimmed) {
return false;
}
@@ -140,7 +139,7 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = normalizeOptionalString(parts[2]);
const handle = parts[2]?.trim();
if (handle) {
return normalizeBlueBubblesHandle(handle);
}
@@ -223,7 +222,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return true;
}
if (normalized) {
const normalizedTrimmed = normalizeOptionalString(normalized);
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
@@ -347,7 +346,7 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = normalizeOptionalString(raw) ?? "";
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };
}
@@ -413,11 +412,11 @@ export function formatBlueBubblesChatTarget(params: {
if (params.chatId && Number.isFinite(params.chatId)) {
return `chat_id:${params.chatId}`;
}
const guid = normalizeOptionalString(params.chatGuid);
const guid = params.chatGuid?.trim();
if (guid) {
return `chat_guid:${guid}`;
}
const identifier = normalizeOptionalString(params.chatIdentifier);
const identifier = params.chatIdentifier?.trim();
if (identifier) {
return `chat_identifier:${identifier}`;
}

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-path";
import type { BlueBubblesAccountConfig } from "./types.js";
@@ -7,7 +6,7 @@ export { normalizeWebhookPath };
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = normalizeOptionalString(config?.webhookPath);
const raw = config?.webhookPath?.trim();
if (raw) {
return normalizeWebhookPath(raw);
}

View File

@@ -3,7 +3,6 @@ import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import {
createBraveSchema,
mapBraveLlmContextResults,
@@ -20,6 +19,10 @@ type ConfigTarget = Parameters<
NonNullable<WebSearchProviderPlugin["setConfiguredCredentialValue"]>
>[0];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function resolveProviderWebSearchPluginConfig(
config: ConfigInput,
pluginId: string,

View File

@@ -1,41 +0,0 @@
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
function getTopLevelCredentialValue(searchConfig?: Record<string, unknown>): unknown {
return searchConfig?.apiKey;
}
function setTopLevelCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
searchConfigTarget.apiKey = value;
}
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
onboardingScopes: ["text-inference"],
credentialLabel: "Brave Search API key",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: getTopLevelCredentialValue,
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: () => null,
};
}

View File

@@ -1,6 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "./src/record-shared.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function listContainsBrowser(value: unknown): boolean {
return (

View File

@@ -1,5 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
browserAct,
@@ -107,7 +106,7 @@ function formatConsoleToolResult(result: {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
targetId: readStringValue(result.targetId),
targetId: typeof result.targetId === "string" ? result.targetId : undefined,
messageCount: Array.isArray(result.messages) ? result.messages.length : undefined,
},
};
@@ -134,7 +133,7 @@ function isChromeStaleTargetError(profile: string | undefined, err: unknown): bo
function stripTargetIdFromActRequest(
request: Parameters<typeof browserAct>[1],
): Parameters<typeof browserAct>[1] | null {
const targetId = normalizeOptionalString(request.targetId);
const targetId = typeof request.targetId === "string" ? request.targetId.trim() : undefined;
if (!targetId) {
return null;
}
@@ -195,7 +194,7 @@ export async function executeSnapshotAction(params: {
const refs: "aria" | "role" | undefined =
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
const hasMaxChars = Object.hasOwn(input, "maxChars");
const targetId = normalizeOptionalString(input.targetId);
const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined;
const limit =
typeof input.limit === "number" && Number.isFinite(input.limit) ? input.limit : undefined;
const maxChars =
@@ -206,8 +205,8 @@ export async function executeSnapshotAction(params: {
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
const depth =
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
const selector = normalizeOptionalString(input.selector);
const frame = normalizeOptionalString(input.frame);
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
@@ -315,8 +314,8 @@ export async function executeConsoleAction(params: {
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
const { input, baseUrl, profile, proxyRequest } = params;
const level = normalizeOptionalString(input.level);
const targetId = normalizeOptionalString(input.targetId);
const level = typeof input.level === "string" ? input.level.trim() : undefined;
const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined;
if (proxyRequest) {
const result = (await proxyRequest({
method: "GET",

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import {
executeActAction,
executeConsoleAction,
@@ -115,7 +114,7 @@ export const __testing = {
};
function readOptionalTargetAndTimeout(params: Record<string, unknown>) {
const targetId = normalizeOptionalString(params.targetId);
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? params.timeoutMs
@@ -648,7 +647,7 @@ export function createBrowserTool(opts?: {
proxyRequest,
});
case "pdf": {
const targetId = normalizeOptionalString(params.targetId);
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const result = proxyRequest
? ((await proxyRequest({
method: "POST",
@@ -710,7 +709,7 @@ export function createBrowserTool(opts?: {
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText = readStringValue(params.promptText);
const promptText = typeof params.promptText === "string" ? params.promptText : undefined;
const { targetId, timeoutMs } = readOptionalTargetAndTimeout(params);
if (proxyRequest) {
const result = await proxyRequest({

View File

@@ -1,7 +1,6 @@
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { isLoopbackHost } from "../gateway/net.js";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js";
import type { ResolvedBrowserConfig } from "./config.js";
@@ -34,9 +33,8 @@ function buildNoVncBootstrapHtml(params: ResolvedNoVncObserver): string {
autoconnect: "1",
resize: "remote",
});
const password = normalizeOptionalString(params.password);
if (password) {
hash.set("password", password);
if (params.password?.trim()) {
hash.set("password", params.password);
}
const targetUrl = `http://127.0.0.1:${params.noVncPort}/vnc.html#${hash.toString()}`;
const encodedTarget = JSON.stringify(targetUrl);
@@ -82,7 +80,7 @@ export async function startBrowserBridgeServer(params: {
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("Referrer-Policy", "no-referrer");
const rawToken = normalizeOptionalString(req.query?.token);
const rawToken = typeof req.query?.token === "string" ? req.query.token.trim() : "";
if (!rawToken) {
res.status(400).send("Missing token");
return;
@@ -96,8 +94,8 @@ export async function startBrowserBridgeServer(params: {
});
}
const authToken = normalizeOptionalString(params.authToken);
const authPassword = normalizeOptionalString(params.authPassword);
const authToken = params.authToken?.trim() || undefined;
const authPassword = params.authPassword?.trim() || undefined;
if (!authToken && !authPassword) {
throw new Error("bridge server requires auth (authToken/authPassword missing)");
}

View File

@@ -1,4 +1,3 @@
import { normalizeString } from "../record-shared.js";
import type { SnapshotAriaNode } from "./client.js";
import {
getRoleSnapshotStats,
@@ -21,6 +20,17 @@ function normalizeRole(node: ChromeMcpSnapshotNode): string {
return role || "generic";
}
function normalizeString(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function escapeQuoted(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}

View File

@@ -4,8 +4,6 @@ import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { asRecord } from "../record-shared.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import type { BrowserTab } from "./client.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
@@ -47,6 +45,12 @@ const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asPages(value: unknown): ChromeMcpStructuredPage[] {
if (!Array.isArray(value)) {
return [];
@@ -59,7 +63,7 @@ function asPages(value: unknown): ChromeMcpStructuredPage[] {
}
out.push({
id: record.id,
url: readStringValue(record.url),
url: typeof record.url === "string" ? record.url : undefined,
selected: record.selected === true,
});
}
@@ -107,7 +111,7 @@ function extractTextPages(result: ChromeMcpToolResult): ChromeMcpStructuredPage[
}
pages.push({
id: Number.parseInt(match[1] ?? "", 10),
url: normalizeOptionalString(match[2]),
url: match[2]?.trim() || undefined,
selected: Boolean(match[3]),
});
}

View File

@@ -1,7 +1,3 @@
import {
normalizeOptionalString,
normalizeOptionalTrimmedStringList,
} from "openclaw/plugin-sdk/text-runtime";
import {
type BrowserConfig,
type BrowserProfileConfig,
@@ -116,7 +112,15 @@ function resolveCdpPortRangeStart(
return start;
}
const normalizeStringList = normalizeOptionalTrimmedStringList;
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
if (!Array.isArray(raw) || raw.length === 0) {
return undefined;
}
const values = raw
.map((value) => value.trim())
.filter((value): value is string => value.length > 0);
return values.length > 0 ? values : undefined;
}
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const rawPolicy = cfg?.ssrfPolicy as
@@ -234,8 +238,8 @@ export function resolveBrowserConfig(
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = normalizeOptionalString(cfg?.executablePath);
const defaultProfileFromConfig = normalizeOptionalString(cfg?.defaultProfile);
const executablePath = cfg?.executablePath?.trim() || undefined;
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";

View File

@@ -1,10 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveUserPath } from "../utils.js";
import { assertCdpEndpointAllowed } from "./cdp.helpers.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
@@ -84,8 +82,8 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const createProfile = async (params: CreateProfileParams): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = normalizeOptionalString(params.cdpUrl);
const rawUserDataDir = normalizeOptionalString(params.userDataDir);
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
const rawUserDataDir = params.userDataDir?.trim() || undefined;
const normalizedUserDataDir = rawUserDataDir ? resolveUserPath(rawUserDataDir) : undefined;
const driver = params.driver === "existing-session" ? "existing-session" : undefined;
@@ -129,7 +127,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
await assertCdpEndpointAllowed(parsed.normalized, state.resolved.ssrfPolicy);
} catch (err) {
throw new BrowserValidationError(formatErrorMessage(err));
throw new BrowserValidationError(err instanceof Error ? err.message : String(err));
}
if (driver === "existing-session") {
throw new BrowserValidationError(

View File

@@ -459,7 +459,7 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
} catch (err) {
lastErr = err;
// Don't retry rate-limit errors; retrying worsens the 429.
const errMsg = formatErrorMessage(err);
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("rate limit")) {
break;
}

View File

@@ -1,4 +1,3 @@
import { formatErrorMessage } from "../infra/errors.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";
@@ -881,7 +880,7 @@ export async function batchViaPlaywright(opts: {
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
results.push({ ok: true });
} catch (err) {
const message = formatErrorMessage(err);
const message = err instanceof Error ? err.message : String(err);
results.push({ ok: false, error: message });
if (opts.stopOnError !== false) {
break;

View File

@@ -1,4 +1,3 @@
import { formatErrorMessage } from "../infra/errors.js";
import { parseRoleRef } from "./pw-role-snapshot.js";
let nextUploadArmId = 0;
@@ -50,7 +49,7 @@ export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: numb
}
export function toAIFriendlyError(error: unknown, selector: string): Error {
const message = formatErrorMessage(error);
const message = error instanceof Error ? error.message : String(error);
if (message.includes("strict mode violation")) {
const countMatch = message.match(/resolved to (\d+) elements/);

View File

@@ -1,4 +1,3 @@
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
export async function cookiesGetViaPlaywright(opts: {
@@ -64,7 +63,7 @@ export async function storageGetViaPlaywright(opts: {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const kind = opts.kind;
const key = readStringValue(opts.key);
const key = typeof opts.key === "string" ? opts.key : undefined;
const values = await page.evaluate(
({ kind: kind2, key: key2 }) => {
const store = kind2 === "session" ? window.sessionStorage : window.localStorage;

View File

@@ -1,5 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
type BrowserRequestProfileParams = {
query?: Record<string, unknown>;
body?: unknown;
@@ -32,16 +30,20 @@ export function isPersistentBrowserProfileMutation(method: string, path: string)
export function resolveRequestedBrowserProfile(
params: BrowserRequestProfileParams,
): string | undefined {
const queryProfile = normalizeOptionalString(params.query?.profile);
const queryProfile =
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
if (queryProfile) {
return queryProfile;
}
if (params.body && typeof params.body === "object") {
const bodyProfile =
"profile" in params.body ? normalizeOptionalString(params.body.profile) : undefined;
"profile" in params.body && typeof params.body.profile === "string"
? params.body.profile.trim()
: undefined;
if (bodyProfile) {
return bodyProfile;
}
}
return normalizeOptionalString(params.profile);
const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined;
return explicitProfile || undefined;
}

View File

@@ -1,4 +1,3 @@
import { formatErrorMessage } from "../../infra/errors.js";
import {
clickChromeMcpElement,
closeChromeMcpTab,
@@ -1086,7 +1085,7 @@ export function registerBrowserAgentActRoutes(
try {
actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : [];
} catch (err) {
return jsonError(res, 400, formatErrorMessage(err));
return jsonError(res, 400, err instanceof Error ? err.message : String(err));
}
if (!actions.length) {
return jsonError(res, 400, "actions are required");

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
@@ -31,7 +30,7 @@ export function registerBrowserAgentDebugRoutes(
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl,
targetId: tab.targetId,
level: normalizeOptionalString(level),
level: level.trim() || undefined,
});
res.json({ ok: true, messages, targetId: tab.targetId });
},
@@ -74,7 +73,7 @@ export function registerBrowserAgentDebugRoutes(
const result = await pw.getNetworkRequestsViaPlaywright({
cdpUrl,
targetId: tab.targetId,
filter: normalizeOptionalString(filter),
filter: filter.trim() || undefined,
clear,
});
res.json({ ok: true, targetId: tab.targetId, ...result });

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedBrowserProfile } from "../config.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
@@ -42,13 +41,14 @@ export function resolveSnapshotPlan(params: {
explicitFormat,
mode,
});
const limitRaw = readStringValue(params.query.limit);
const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
const maxCharsRaw = readStringValue(params.query.maxChars);
const limit = Number.isFinite(Number(limitRaw)) ? Number(limitRaw) : undefined;
const maxCharsRaw =
typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
Number.isFinite(Number(maxCharsRaw)) && Number(maxCharsRaw) > 0
? Math.floor(Number(maxCharsRaw))
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
@@ -68,8 +68,8 @@ export function resolveSnapshotPlan(params: {
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selectorValue = normalizeOptionalString(toStringOrEmpty(params.query.selector));
const frameSelectorValue = normalizeOptionalString(toStringOrEmpty(params.query.frame));
const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined;
const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined;
return {
format,

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import type { BrowserRouteContext } from "../server-context.js";
import {
readBody,
@@ -168,7 +167,7 @@ export function registerBrowserAgentStorageRoutes(
cdpUrl,
targetId: tab.targetId,
kind,
key: normalizeOptionalString(key),
key: key.trim() || undefined,
});
res.json({ ok: true, targetId: tab.targetId, ...result });
},
@@ -293,7 +292,7 @@ export function registerBrowserAgentStorageRoutes(
const targetId = resolveTargetIdFromBody(body);
const clear = toBoolean(body.clear) ?? false;
const username = toStringOrEmpty(body.username) || undefined;
const password = readStringValue(body.password);
const password = typeof body.password === "string" ? body.password : undefined;
await withPlaywrightRouteContext({
req,

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { parseBooleanValue } from "../../utils/boolean.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
@@ -15,14 +14,14 @@ export function getProfileContext(
// Check query string first (works for GET and POST)
if (typeof req.query.profile === "string") {
profileName = normalizeOptionalString(req.query.profile);
profileName = req.query.profile.trim() || undefined;
}
// Fall back to body for POST requests
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body as Record<string, unknown>;
if (typeof body.profile === "string") {
profileName = normalizeOptionalString(body.profile);
profileName = body.profile.trim() || undefined;
}
}

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { danger, defaultRuntime } from "../core-api.js";
import {
@@ -62,9 +61,9 @@ export function registerBrowserElementCommands(
body: {
kind: "click",
ref: refValue,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
doubleClick: Boolean(opts.double),
button: normalizeOptionalString(opts.button),
button: opts.button?.trim() || undefined,
modifiers,
},
successMessage: (result) => {
@@ -96,7 +95,7 @@ export function registerBrowserElementCommands(
text,
submit: Boolean(opts.submit),
slowly: Boolean(opts.slowly),
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `typed into ref ${refValue}`,
});
@@ -110,7 +109,7 @@ export function registerBrowserElementCommands(
.action(async (key: string, opts, cmd) => {
await runElementAction({
cmd,
body: { kind: "press", key, targetId: normalizeOptionalString(opts.targetId) },
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
successMessage: `pressed ${key}`,
});
});
@@ -123,7 +122,7 @@ export function registerBrowserElementCommands(
.action(async (ref: string, opts, cmd) => {
await runElementAction({
cmd,
body: { kind: "hover", ref, targetId: normalizeOptionalString(opts.targetId) },
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
successMessage: `hovered ref ${ref}`,
});
});
@@ -147,7 +146,7 @@ export function registerBrowserElementCommands(
body: {
kind: "scrollIntoView",
ref: refValue,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
timeoutMs,
@@ -168,7 +167,7 @@ export function registerBrowserElementCommands(
kind: "drag",
startRef,
endRef,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `dragged ${startRef}${endRef}`,
});
@@ -187,7 +186,7 @@ export function registerBrowserElementCommands(
kind: "select",
ref,
values,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `selected ${values.join(", ")}`,
});

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import {
danger,
@@ -58,7 +57,8 @@ export function registerBrowserFilesAndDownloadsCommands(
) {
const resolveTimeoutAndTarget = (opts: { timeoutMs?: unknown; targetId?: unknown }) => {
const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : undefined;
const targetId = normalizeOptionalString(opts.targetId);
const targetId =
typeof opts.targetId === "string" ? opts.targetId.trim() || undefined : undefined;
return { timeoutMs, targetId };
};
@@ -109,9 +109,9 @@ export function registerBrowserFilesAndDownloadsCommands(
path: "/hooks/file-chooser",
body: {
paths: normalizedPaths,
ref: normalizeOptionalString(opts.ref),
inputRef: normalizeOptionalString(opts.inputRef),
element: normalizeOptionalString(opts.element),
ref: opts.ref?.trim() || undefined,
inputRef: opts.inputRef?.trim() || undefined,
element: opts.element?.trim() || undefined,
targetId,
timeoutMs,
},
@@ -137,7 +137,7 @@ export function registerBrowserFilesAndDownloadsCommands(
await runDownloadCommand(cmd, opts, {
path: "/wait/download",
body: {
path: normalizeOptionalString(outPath),
path: outPath?.trim() || undefined,
},
});
});
@@ -193,7 +193,7 @@ export function registerBrowserFilesAndDownloadsCommands(
path: "/hooks/dialog",
body: {
accept,
promptText: normalizeOptionalString(opts.prompt),
promptText: opts.prompt?.trim() || undefined,
targetId,
timeoutMs,
},

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { danger, defaultRuntime } from "../core-api.js";
import {
@@ -32,7 +31,7 @@ export function registerBrowserFormWaitEvalCommands(
body: {
kind: "fill",
fields,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
});
logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`);
@@ -61,7 +60,7 @@ export function registerBrowserFormWaitEvalCommands(
.action(async (selector: string | undefined, opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
try {
const sel = normalizeOptionalString(selector);
const sel = selector?.trim() || undefined;
const load =
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
? (opts.load as "load" | "domcontentloaded" | "networkidle")
@@ -73,13 +72,13 @@ export function registerBrowserFormWaitEvalCommands(
body: {
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: normalizeOptionalString(opts.text),
textGone: normalizeOptionalString(opts.textGone),
text: opts.text?.trim() || undefined,
textGone: opts.textGone?.trim() || undefined,
selector: sel,
url: normalizeOptionalString(opts.url),
url: opts.url?.trim() || undefined,
loadState: load,
fn: normalizeOptionalString(opts.fn),
targetId: normalizeOptionalString(opts.targetId),
fn: opts.fn?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
},
timeoutMs,
@@ -111,8 +110,8 @@ export function registerBrowserFormWaitEvalCommands(
body: {
kind: "evaluate",
fn: opts.fn,
ref: normalizeOptionalString(opts.ref),
targetId: normalizeOptionalString(opts.targetId),
ref: opts.ref?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { runBrowserResizeWithOutput } from "../browser-cli-resize.js";
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
import { danger, defaultRuntime } from "../core-api.js";
@@ -25,7 +24,7 @@ export function registerBrowserNavigationCommands(
query: profile ? { profile } : undefined,
body: {
url,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { runCommandWithRuntime } from "../core-api.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { danger, defaultRuntime, shortenHomePath } from "./core-api.js";
@@ -30,8 +29,8 @@ export function registerBrowserActionObserveCommands(
method: "GET",
path: "/console",
query: {
level: normalizeOptionalString(opts.level),
targetId: normalizeOptionalString(opts.targetId),
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
},
},
@@ -59,7 +58,7 @@ export function registerBrowserActionObserveCommands(
method: "POST",
path: "/pdf",
query: profile ? { profile } : undefined,
body: { targetId: normalizeOptionalString(opts.targetId) },
body: { targetId: opts.targetId?.trim() || undefined },
},
{ timeoutMs: 20000 },
);
@@ -98,7 +97,7 @@ export function registerBrowserActionObserveCommands(
query: profile ? { profile } : undefined,
body: {
url,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
timeoutMs,
maxChars,
},

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { runCommandWithRuntime } from "../core-api.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { danger, defaultRuntime, shortenHomePath } from "./core-api.js";
@@ -60,8 +59,8 @@ function resolveDebugQuery(params: {
filter?: unknown;
}) {
return {
targetId: normalizeOptionalString(params.targetId),
filter: normalizeOptionalString(params.filter),
targetId: typeof params.targetId === "string" ? params.targetId.trim() || undefined : undefined,
filter: typeof params.filter === "string" ? params.filter.trim() || undefined : undefined,
clear: Boolean(params.clear),
profile: params.profile,
};
@@ -84,7 +83,7 @@ export function registerBrowserDebugCommands(
query: resolveProfileQuery(profile),
body: {
ref: ref.trim(),
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
});
if (printJsonResult(parent, result)) {
@@ -190,7 +189,7 @@ export function registerBrowserDebugCommands(
path: "/trace/start",
query: resolveProfileQuery(profile),
body: {
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
snapshots: Boolean(opts.snapshots),
sources: Boolean(opts.sources),
@@ -218,8 +217,8 @@ export function registerBrowserDebugCommands(
path: "/trace/stop",
query: resolveProfileQuery(profile),
body: {
targetId: normalizeOptionalString(opts.targetId),
path: normalizeOptionalString(opts.out),
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
},
});
if (printJsonResult(parent, result)) {

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import {
danger,
@@ -32,10 +31,10 @@ export function registerBrowserInspectCommands(
path: "/screenshot",
query: profile ? { profile } : undefined,
body: {
targetId: normalizeOptionalString(targetId),
targetId: targetId?.trim() || undefined,
fullPage: Boolean(opts.fullPage),
ref: normalizeOptionalString(opts.ref),
element: normalizeOptionalString(opts.element),
ref: opts.ref?.trim() || undefined,
element: opts.element?.trim() || undefined,
type: opts.type === "jpeg" ? "jpeg" : "png",
},
},
@@ -79,13 +78,13 @@ export function registerBrowserInspectCommands(
try {
const query: Record<string, string | number | boolean | undefined> = {
format,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
interactive: opts.interactive ? true : undefined,
compact: opts.compact ? true : undefined,
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
selector: normalizeOptionalString(opts.selector),
frame: normalizeOptionalString(opts.frame),
selector: opts.selector?.trim() || undefined,
frame: opts.frame?.trim() || undefined,
labels: opts.labels ? true : undefined,
mode,
profile,

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { callGatewayFromCli, type GatewayRpcOpts } from "./core-api.js";
export type BrowserParentOpts = GatewayRpcOpts & {
@@ -76,7 +75,7 @@ export async function callBrowserResize(
kind: "resize",
width: params.width,
height: params.height,
targetId: normalizeOptionalString(params.targetId),
targetId: params.targetId?.trim() || undefined,
},
},
extra,

View File

@@ -1,20 +1,29 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { danger, defaultRuntime, inheritOptionFromParent } from "./core-api.js";
function resolveUrl(opts: { url?: string }, command: Command): string | undefined {
return (
normalizeOptionalString(opts.url) ??
normalizeOptionalString(inheritOptionFromParent<string>(command, "url"))
);
if (typeof opts.url === "string" && opts.url.trim()) {
return opts.url.trim();
}
const inherited = inheritOptionFromParent<string>(command, "url");
if (typeof inherited === "string" && inherited.trim()) {
return inherited.trim();
}
return undefined;
}
function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined {
return (
normalizeOptionalString(rawTargetId) ??
normalizeOptionalString(inheritOptionFromParent<string>(command, "targetId"))
);
const local = typeof rawTargetId === "string" ? rawTargetId.trim() : "";
if (local) {
return local;
}
const inherited = inheritOptionFromParent<string>(command, "targetId");
if (typeof inherited !== "string") {
return undefined;
}
const trimmed = inherited.trim();
return trimmed ? trimmed : undefined;
}
async function runMutationRequest(params: {

View File

@@ -1,5 +1,4 @@
import type { Command } from "commander";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { runCommandWithRuntime } from "../core-api.js";
import { runBrowserResizeWithOutput } from "./browser-cli-resize.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
@@ -92,7 +91,7 @@ export function registerBrowserStateCommands(
path: "/set/offline",
body: {
offline,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `offline: ${offline}`,
});
@@ -108,7 +107,8 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const headersJsonValue =
normalizeOptionalString(opts.headersJson) ?? normalizeOptionalString(headersJson);
(typeof opts.headersJson === "string" && opts.headersJson.trim()) ||
(headersJson?.trim() ? headersJson.trim() : undefined);
if (!headersJsonValue) {
throw new Error("Missing headers JSON (pass --headers-json or positional JSON argument)");
}
@@ -131,7 +131,7 @@ export function registerBrowserStateCommands(
query: profile ? { profile } : undefined,
body: {
headers,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
},
{ timeoutMs: 20000 },
@@ -157,10 +157,10 @@ export function registerBrowserStateCommands(
parent,
path: "/set/credentials",
body: {
username: normalizeOptionalString(username),
username: username?.trim() || undefined,
password,
clear: Boolean(opts.clear),
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: opts.clear ? "credentials cleared" : "credentials set",
});
@@ -184,9 +184,9 @@ export function registerBrowserStateCommands(
latitude: Number.isFinite(latitude) ? latitude : undefined,
longitude: Number.isFinite(longitude) ? longitude : undefined,
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
origin: normalizeOptionalString(opts.origin),
origin: opts.origin?.trim() || undefined,
clear: Boolean(opts.clear),
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: opts.clear ? "geolocation cleared" : "geolocation set",
});
@@ -212,7 +212,7 @@ export function registerBrowserStateCommands(
path: "/set/media",
body: {
colorScheme,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `media colorScheme: ${colorScheme}`,
});
@@ -230,7 +230,7 @@ export function registerBrowserStateCommands(
path: "/set/timezone",
body: {
timezoneId,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `timezone: ${timezoneId}`,
});
@@ -248,7 +248,7 @@ export function registerBrowserStateCommands(
path: "/set/locale",
body: {
locale,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `locale: ${locale}`,
});
@@ -266,7 +266,7 @@ export function registerBrowserStateCommands(
path: "/set/device",
body: {
name,
targetId: normalizeOptionalString(opts.targetId),
targetId: opts.targetId?.trim() || undefined,
},
successMessage: `device: ${name}`,
});

View File

@@ -1,12 +1,10 @@
import { note } from "openclaw/plugin-sdk/browser-setup-tools";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
parseBrowserMajorVersion,
readBrowserVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./browser/chrome.executables.js";
import type { OpenClawConfig } from "./config/config.js";
import { asRecord } from "./record-shared.js";
const CHROME_MCP_MIN_MAJOR = 144;
const REMOTE_DEBUGGING_PAGES = [
@@ -15,6 +13,12 @@ const REMOTE_DEBUGGING_PAGES = [
"edge://inspect/#remote-debugging",
].join(", ");
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
type ExistingSessionProfile = {
name: string;
userDataDir?: string;
@@ -27,7 +31,8 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[]
}
const profiles = new Map<string, ExistingSessionProfile>();
const defaultProfile = normalizeOptionalString(browser.defaultProfile) ?? "";
const defaultProfile =
typeof browser.defaultProfile === "string" ? browser.defaultProfile.trim() : "";
if (defaultProfile === "user") {
profiles.set("user", { name: "user" });
}
@@ -39,12 +44,11 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[]
for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) {
const profile = asRecord(rawProfile);
const driver = normalizeOptionalString(profile?.driver) ?? "";
const driver = typeof profile?.driver === "string" ? profile.driver.trim() : "";
if (driver === "existing-session") {
profiles.set(profileName, {
name: profileName,
userDataDir: normalizeOptionalString(profile?.userDataDir),
});
const userDataDir =
typeof profile?.userDataDir === "string" ? profile.userDataDir.trim() : undefined;
profiles.set(profileName, { name: profileName, userDataDir: userDataDir || undefined });
}
}

View File

@@ -1,20 +0,0 @@
import {
asNullableRecord,
hasNonEmptyString as sharedHasNonEmptyString,
isRecord,
} from "openclaw/plugin-sdk/text-runtime";
export { asNullableRecord as asRecord, isRecord };
export const hasNonEmptyString = sharedHasNonEmptyString;
export function normalizeString(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}

View File

@@ -4,7 +4,6 @@ import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
import { isPrivateNetworkOptInEnabled, isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
import { redactCdpUrl, resolveBrowserConfig, resolveProfile } from "./browser/config.js";
import { resolveBrowserControlAuth } from "./browser/control-auth.js";
import { hasNonEmptyString } from "./record-shared.js";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -12,6 +11,10 @@ const BLOCKED_HOSTNAMES = new Set([
"metadata.google.internal",
]);
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isTrustedPrivateHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized.length > 0 && BLOCKED_HOSTNAMES.has(normalized);

View File

@@ -7,7 +7,6 @@ import {
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login";
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
import {
CHUTES_DEFAULT_MODEL_REF,
applyChutesApiKeyConfig,
@@ -83,7 +82,7 @@ async function runChutesOAuth(ctx: ProviderAuthContext): Promise<ProviderAuthRes
access: creds.access,
refresh: creds.refresh,
expires: creds.expires,
email: readStringValue(creds.email),
email: typeof creds.email === "string" ? creds.email : undefined,
credentialExtra: {
clientId,
...("accountId" in creds && typeof creds.accountId === "string"

View File

@@ -17,7 +17,7 @@ import {
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
const DEFAULT_COMFY_LOCAL_BASE_URL = "http://127.0.0.1:8188";
const DEFAULT_COMFY_CLOUD_BASE_URL = "https://cloud.comfy.org";
@@ -87,6 +87,10 @@ export function _setComfyFetchGuardForTesting(impl: typeof fetchWithSsrFGuard |
comfyFetchGuard = impl ?? fetchWithSsrFGuard;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readConfigString(config: ComfyProviderConfig, key: string): string | undefined {
const value = config[key];
if (typeof value !== "string") {

View File

@@ -1,7 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
clearDeviceBootstrapTokens,
definePluginEntry,
@@ -140,7 +139,7 @@ const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
};
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = normalizeOptionalString(raw);
const candidate = raw.trim();
if (!candidate) {
return null;
}
@@ -148,7 +147,7 @@ function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null
if (parsedUrl) {
return parsedUrl;
}
const hostPort = normalizeOptionalString(candidate.split("/", 1)[0]) ?? "";
const hostPort = candidate.split("/", 1)[0]?.trim() ?? "";
return hostPort ? `${schemeFallback}://${hostPort}` : null;
}
@@ -231,7 +230,7 @@ function pickMatchingIPv4(predicate: (address: string) => boolean): string | nul
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = normalizeOptionalString(entry.address) ?? "";
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
@@ -282,7 +281,10 @@ function resolveAuthLabel(cfg: OpenClawPluginApi["config"]): ResolveAuthLabelRes
function pickFirstDefined(candidates: Array<unknown>): string | null {
for (const value of candidates) {
const trimmed = normalizeOptionalString(value);
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
@@ -310,9 +312,8 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
const scheme = resolveScheme(cfg);
const port = resolveGatewayPort(cfg);
const configuredPublicUrl = normalizeOptionalString(pluginCfg.publicUrl);
if (configuredPublicUrl) {
const url = normalizeUrl(configuredPublicUrl, scheme);
if (typeof pluginCfg.publicUrl === "string" && pluginCfg.publicUrl.trim()) {
const url = normalizeUrl(pluginCfg.publicUrl, scheme);
if (url) {
return { url, source: "plugins.entries.device-pair.config.publicUrl" };
}
@@ -328,8 +329,8 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
const remoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
if (remoteUrl) {
const remoteUrl = cfg.gateway?.remote?.url;
if (typeof remoteUrl === "string" && remoteUrl.trim()) {
const url = normalizeUrl(remoteUrl, scheme);
if (url) {
return { url, source: "gateway.remote.url" };
@@ -477,19 +478,14 @@ function canSendQrPngToChannel(channel: string): boolean {
function resolveQrReplyTarget(ctx: QrCommandContext): string {
if (ctx.channel === "discord") {
const senderId = normalizeOptionalString(ctx.senderId) ?? "";
const senderId = ctx.senderId?.trim() ?? "";
if (senderId) {
return senderId.startsWith("user:") || senderId.startsWith("channel:")
? senderId
: `user:${senderId}`;
}
}
return (
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
""
);
return ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
}
const PAIR_SETUP_NON_ISSUING_ACTIONS = new Set([
@@ -525,7 +521,7 @@ async function sendQrPngToSupportedChannel(params: {
qrFilePath: string;
}): Promise<boolean> {
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
const accountId = normalizeOptionalString(params.ctx.accountId) || undefined;
const accountId = params.ctx.accountId?.trim() || undefined;
const sender = QR_CHANNEL_SENDERS[params.ctx.channel];
if (!sender) {
return false;
@@ -561,7 +557,7 @@ export default definePluginEntry({
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
handler: async (ctx) => {
const args = normalizeOptionalString(ctx.args) ?? "";
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
@@ -583,7 +579,7 @@ export default definePluginEntry({
}
if (action === "notify") {
const notifyAction = normalizeOptionalString(tokens[1])?.toLowerCase() ?? "status";
const notifyAction = tokens[1]?.trim().toLowerCase() ?? "status";
return await handleNotifyCommand({
api,
ctx,
@@ -598,7 +594,7 @@ export default definePluginEntry({
const list = await listDevicePairing();
const selected = selectPendingApprovalRequest({
pending: list.pending,
requested: normalizeOptionalString(tokens[1]),
requested: tokens[1]?.trim(),
});
if (selected.reply) {
return selected.reply;
@@ -748,11 +744,7 @@ export default definePluginEntry({
};
}
const channel = ctx.channel;
const target =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const payload = await issueSetupPayload(urlResult.url);
if (channel === "telegram" && target) {

View File

@@ -1,6 +1,5 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawPluginApi } from "./api.js";
import { listDevicePairing } from "./api.js";
@@ -42,7 +41,7 @@ function formatStringList(values?: readonly string[]): string {
}
function formatRoleList(request: PendingPairingRequest): string {
const role = normalizeOptionalString(request.role);
const role = request.role?.trim();
if (role) {
return role;
}
@@ -59,9 +58,9 @@ export function formatPendingRequests(pending: PendingPairingRequest[]): string
}
const lines: string[] = ["Pending device pairing requests:"];
for (const req of pending) {
const label = normalizeOptionalString(req.displayName) || req.deviceId;
const platform = normalizeOptionalString(req.platform);
const ip = normalizeOptionalString(req.remoteIp);
const label = req.displayName?.trim() || req.deviceId;
const platform = req.platform?.trim();
const ip = req.remoteIp?.trim();
const parts = [
`- ${req.requestId}`,
label ? `name=${label}` : null,
@@ -93,14 +92,17 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
continue;
}
const record = item as Record<string, unknown>;
const to = normalizeOptionalString(record.to) ?? "";
const to = typeof record.to === "string" ? record.to.trim() : "";
if (!to) {
continue;
}
const accountId = normalizeOptionalString(record.accountId) ?? undefined;
const accountId =
typeof record.accountId === "string" && record.accountId.trim()
? record.accountId.trim()
: undefined;
const messageThreadId =
typeof record.messageThreadId === "string"
? normalizeOptionalString(record.messageThreadId) || undefined
? record.messageThreadId.trim() || undefined
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
@@ -120,14 +122,13 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
const notifiedRequestIds: Record<string, number> = {};
for (const [requestId, ts] of Object.entries(notifiedRaw)) {
const normalizedRequestId = normalizeOptionalString(requestId);
if (!normalizedRequestId) {
if (!requestId.trim()) {
continue;
}
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
continue;
}
notifiedRequestIds[normalizedRequestId] = Math.trunc(ts);
notifiedRequestIds[requestId] = Math.trunc(ts);
}
return { subscribers, notifiedRequestIds };
@@ -169,11 +170,7 @@ function resolveNotifyTarget(ctx: {
accountId?: string;
messageThreadId?: string | number;
}): NotifyTarget | null {
const to =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (!to) {
return null;
}
@@ -209,9 +206,9 @@ function upsertNotifySubscriber(
}
function buildPairingRequestNotificationText(request: PendingPairingRequest): string {
const label = normalizeOptionalString(request.displayName) || request.deviceId;
const platform = normalizeOptionalString(request.platform);
const ip = normalizeOptionalString(request.remoteIp);
const label = request.displayName?.trim() || request.deviceId;
const platform = request.platform?.trim();
const ip = request.remoteIp?.trim();
const role = formatRoleList(request);
const scopes = formatScopeList(request);
const lines = [

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { approveDevicePairing, listDevicePairing } from "./api.js";
import { formatPendingRequests } from "./notify.js";
@@ -44,8 +43,8 @@ export function selectPendingApprovalRequest(params: {
}
function formatApprovedPairingReply(approved: ApprovedPairingEntry): { text: string } {
const label = normalizeOptionalString(approved.device.displayName) || approved.device.deviceId;
const platform = normalizeOptionalString(approved.device.platform);
const label = approved.device.displayName?.trim() || approved.device.deviceId;
const platform = approved.device.platform?.trim();
const platformLabel = platform ? ` (${platform})` : "";
return { text: `✅ Paired ${label}${platformLabel}.` };
}

View File

@@ -1,7 +1,6 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { chromium } from "playwright-core";
import type { OpenClawConfig } from "../api.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js";
@@ -256,7 +255,7 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
if (error instanceof Error && error.message === IMAGE_SIZE_LIMIT_ERROR) {
throw error;
}
const reason = formatErrorMessage(error);
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
{ cause: error },

View File

@@ -1,7 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { PluginLogger } from "../api.js";
import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js";
@@ -380,3 +379,7 @@ function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefin
return Object.values(context).some((entry) => entry !== undefined) ? context : undefined;
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

View File

@@ -1,6 +1,5 @@
import fs from "node:fs/promises";
import { Static, Type } from "@sinclair/typebox";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
@@ -296,18 +295,19 @@ export function createDiffsTool(params: {
};
} catch (error) {
if (mode === "both") {
const errorMessage = formatErrorMessage(error);
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}\nFile rendering failed: ${errorMessage}`,
text:
`Diff viewer ready.\n${viewerUrl}\n` +
`File rendering failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: {
...baseDetails,
fileError: errorMessage,
imageError: errorMessage,
fileError: error instanceof Error ? error.message : String(error),
imageError: error instanceof Error ? error.message : String(error),
},
};
}

View File

@@ -1,4 +1,3 @@
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { DIFF_INDICATORS, DIFF_LAYOUTS, DIFF_THEMES } from "./types.js";
import type { DiffViewerPayload } from "./types.js";
@@ -86,6 +85,10 @@ function isViewerOptions(value: unknown): boolean {
return true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function includesValue<T extends readonly string[]>(values: T, value: unknown): value is T[number] {
return typeof value === "string" && values.includes(value as T[number]);
}

View File

@@ -4,13 +4,16 @@
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
"@buape/carbon": "0.14.0",
"@buape/carbon": "0.0.0-beta-20260406003433",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"discord-api-types": "^0.38.44",
"https-proxy-agent": "^9.0.0",
"opusscript": "^0.1.1"
},
"optionalDependencies": {
"@discordjs/opus": "^0.10.0"
},
"devDependencies": {
"openclaw": "workspace:*"
},
@@ -22,9 +25,6 @@
"optional": true
}
},
"optionalDependencies": {
"@discordjs/opus": "^0.10.0"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -3,7 +3,6 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
@@ -67,7 +66,7 @@ export function inspectDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
@@ -79,7 +78,7 @@ export function inspectDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",
@@ -93,7 +92,7 @@ export function inspectDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
@@ -110,7 +109,7 @@ export function inspectDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: envToken.replace(/^Bot\s+/i, ""),
tokenSource: "env",
tokenStatus: "available",
@@ -122,7 +121,7 @@ export function inspectDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",

View File

@@ -5,7 +5,6 @@ import {
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
@@ -70,7 +69,7 @@ export function resolveDiscordAccount(params: {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
config: merged,

View File

@@ -3,7 +3,6 @@ import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
} from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { inspectDiscordAccount } from "./account-inspect.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
@@ -125,7 +124,7 @@ export async function auditDiscordChannelPermissions(params: {
channels.push({
channelId,
ok: false,
error: formatErrorMessage(err),
error: err instanceof Error ? err.message : String(err),
matchKey: channelId,
matchSource: "id",
});

View File

@@ -9,7 +9,6 @@ import type {
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import {
createDiscordActionGate,
listEnabledDiscordAccounts,
@@ -170,7 +169,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "sendMessage") {
return extractToolSend(args, "sendMessage");
const to = typeof args.to === "string" ? args.to : undefined;
return to ? { to } : null;
}
if (action === "threadReply") {
const channelId = typeof args.channelId === "string" ? args.channelId.trim() : "";

View File

@@ -16,7 +16,6 @@ import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
@@ -677,7 +676,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
],
};
} catch (err) {
const message = formatErrorMessage(err);
const message = err instanceof Error ? err.message : String(err);
details.permissions = { channelId: parsedTarget.id, error: message };
return {
details,

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