diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0266c721748..8de4f3882c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,9 @@ jobs: - name: Check types and lint and oxfmt run: pnpm check + - name: Enforce safe external URL opening policy + run: pnpm lint:ui:no-raw-window-open + # Report-only dead-code scans. Runs after scope detection and stores machine-readable # results as artifacts for later triage before we enable hard gates. # Temporarily disabled in CI while we process initial findings. diff --git a/.gitignore b/.gitignore index b5ef33af2dd0..5ad5cac4da32 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,12 @@ skills-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig + +# Xcode build directories (xcodebuild output) +apps/ios/build/ +apps/shared/OpenClawKit/build/ +Swabble/build/ + # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json .ant-colony/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..9190f88b6e08 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> diff --git a/AGENTS.md b/AGENTS.md index 00ae79a05514..09ed6423ac46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`. - For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. +- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. ## NPM + 1Password (publish/verify) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c30bc75e3c..f6e1a71e0c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,29 +2,142 @@ Docs: https://docs.openclaw.ai -## Unreleased +## 2026.2.25 (Unreleased) + +### Changes + +- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. + +### Fixes + +- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. +- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. +- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. +- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. +- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. +- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. +- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. +- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. + +## 2026.2.24 + +### Changes + +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. +- Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model. +- Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces. +- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). +- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. ### Breaking -- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. ### Fixes -- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: harden `autoAllowSkills` matching to require pathless invocations with resolved executables, blocking `./`/absolute-path basename collisions from satisfying skill auto-allow checks under allowlist mode. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung. -- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. - -## 2026.2.23 (Unreleased) +- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. +- Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) +- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. +- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. +- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. +- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. +- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. +- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. +- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) +- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. +- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) +- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. +- Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. +- macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. +- macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. +- macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. +- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. +- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) +- Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. +- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. +- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. +- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. +- Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. +- Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. +- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. +- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting. +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. + +## 2026.2.23 ### Changes @@ -33,6 +146,10 @@ Docs: https://docs.openclaw.ai - Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. - Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. - Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. ### Breaking @@ -42,9 +159,8 @@ Docs: https://docs.openclaw.ai - Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. -- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. -- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. - Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. @@ -56,8 +172,6 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. -- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. -- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. - Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. @@ -75,8 +189,9 @@ Docs: https://docs.openclaw.ai - Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. -- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. Thanks @nedlir for reporting. - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. - Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. @@ -178,12 +293,12 @@ Docs: https://docs.openclaw.ai - Config/Channels: when `plugins.allow` is active, auto-enable/enable flows now also allowlist configured built-in channels so `channels..enabled=true` cannot remain blocked by restrictive plugin allowlists. - Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example `.backup-*`, `.bak`, `.disabled*`) and move updater backup directories under `.openclaw-install-backups`, preventing duplicate plugin-id collisions from archived copies. - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. -- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. Thanks @jiseoung for reporting. - Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. -- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. Thanks @tdjackey for reporting. +- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. Thanks @jiseoung for reporting. +- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. Thanks @jiseoung for reporting. +- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. Thanks @jiseoung for reporting. - Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) @@ -227,6 +342,7 @@ Docs: https://docs.openclaw.ai - Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove. - Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. - Memory/QMD: parse plain-text `qmd collection list --json` output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns `Collection not found ...`. (#23613) Thanks @leozhucn. +- iOS/Watch: normalize watch quick-action notification payloads, support mirrored indexed actions beyond primary/secondary, and fix iOS test-target signing/compile blockers for watch notify coverage. (#23636) Thanks @mbelinky. - Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. - Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. - Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. @@ -246,16 +362,16 @@ Docs: https://docs.openclaw.ai - Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`. - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). - Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. -- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. Thanks @tdjackey for reporting. - WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. -- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. Thanks @tdjackey for reporting. - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. -- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. -- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. Thanks @aether-ai-agent for reporting. +- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. Thanks @tdjackey for reporting. - Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. -- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. Thanks @aether-ai-agent for reporting. - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. - Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. - Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. @@ -304,28 +420,28 @@ Docs: https://docs.openclaw.ai - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. - Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. - Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. -- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. Thanks @tdjackey for reporting. - Network/SSRF: enable `autoSelectFamily` on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness. - Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. - Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. - Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. - Security/Exec: fail closed when `tools.exec.host=sandbox` is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3. -- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting. -- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting. +- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. Thanks @aether-ai-agent for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. Thanks @princeeismond-dot for reporting. - Security/SSRF: block RFC2544 benchmarking range (`198.18.0.0/15`) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example `::127.0.0.1`, `64:ff9b::8.8.8.8`) in shared IP parsing/classification. - Security/Archive: block zip symlink escapes during archive extraction. - Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. - Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku. - Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. - Security/Gateway: block node-role connections when device identity metadata is missing. -- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. Thanks @tdjackey for reporting. - Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. -- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. Thanks @tdjackey for reporting. +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. Thanks @tdjackey for reporting. - Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats. -- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting. +- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting. - Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore. - Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d4f2907045..1386bc4881ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ Welcome to the lobster tank! 🦞 - **Vincent Koc** - Agents, Telemetry, Hooks, Security - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + - **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 000000000000..1887eca27d95 --- /dev/null +++ b/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green diff --git a/README.md b/README.md index 4c3b53e9596a..27aa9db14907 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. @@ -149,7 +149,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). @@ -309,11 +308,359 @@ QVerisBot is built on OpenClaw. For deep architecture, channel internals, platfo ## Star History -

- - QVerisBot Star History Chart - -

+[![Star History Chart](https://api.star-history.com/svg?repos=openclaw/openclaw&type=date&legend=top-left)](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left) + +## Everything we built so far + +### Core platform + +- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). +- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). +- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups). +- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). + +### Channels + +- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). +- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). + +### Apps + nodes + +- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control. +- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing. +- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS. +- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure. + +### Tools + automation + +- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles. +- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot. +- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications. +- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub). +- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI. + +### Runtime + safety + +- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). +- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). +- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting). + +### Ops + packaging + +- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway. +- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth. +- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs. +- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging). + +## How it works (short) + +``` +WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat + │ + ▼ +┌───────────────────────────────┐ +│ Gateway │ +│ (control plane) │ +│ ws://127.0.0.1:18789 │ +└──────────────┬────────────────┘ + │ + ├─ Pi agent (RPC) + ├─ CLI (openclaw …) + ├─ WebChat UI + ├─ macOS app + └─ iOS / Android nodes +``` + +## Key subsystems + +- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)). +- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)). +- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control. +- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)). +- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation. +- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. + +## Tailscale access (Gateway dashboard) + +OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`: + +- `off`: no Tailscale automation (default). +- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default). +- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth). + +Notes: + +- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this). +- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`. +- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. +- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. + +Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web) + +## Remote Gateway (Linux is great) + +It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed. + +- **Gateway host** runs the exec tool and channel connections by default. +- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. + In short: exec runs where the Gateway lives; device actions run where the device lives. + +Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security) + +## macOS permissions via the Gateway protocol + +The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`: + +- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`). +- `system.notify` posts a user notification and fails if notifications are denied. +- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status. + +Elevated bash (host permissions) is separate from macOS TCC: + +- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. +- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. + +Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture) + +## Agent to Agent (sessions\_\* tools) + +- Use these to coordinate work across sessions without jumping between chat surfaces. +- `sessions_list` — discover active sessions (agents) and their metadata. +- `sessions_history` — fetch transcript logs for a session. +- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). + +Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool) + +## Skills registry (ClawHub) + +ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed. + +[ClawHub](https://clawhub.com) + +## Chat commands + +Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only): + +- `/status` — compact session status (model + tokens, cost when available) +- `/new` or `/reset` — reset the session +- `/compact` — compact session context (summary) +- `/think ` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only) +- `/verbose on|off` +- `/usage off|tokens|full` — per-response usage footer +- `/restart` — restart the gateway (owner-only in groups) +- `/activation mention|always` — group activation toggle (groups only) + +## Apps (optional) + +The Gateway alone delivers a great experience. All apps are optional and add extra features. + +If you plan to build/run companion apps, follow the platform runbooks below. + +### macOS (OpenClaw.app) (optional) + +- Menu bar control for the Gateway and health. +- Voice Wake + push-to-talk overlay. +- WebChat + debug tools. +- Remote gateway control over SSH. + +Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`). + +### iOS node (optional) + +- Pairs as a node via the Bridge. +- Voice trigger forwarding + Canvas surface. +- Controlled via `openclaw nodes …`. + +Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios). + +### Android node (optional) + +- Pairs via the same Bridge + pairing flow as iOS. +- Exposes Canvas, Camera, and Screen capture commands. +- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android). + +## Agent workspace + skills + +- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`). +- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`. +- Skills: `~/.openclaw/workspace/skills//SKILL.md`. + +## Configuration + +Minimal `~/.openclaw/openclaw.json` (model + defaults): + +```json5 +{ + agent: { + model: "anthropic/claude-opus-4-6", + }, +} +``` + +[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration) + +## Security model (important) + +- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. +- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. +- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. + +Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration) + +### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) + +- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`). +- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`. +- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. + +### [Telegram](https://docs.openclaw.ai/channels/telegram) + +- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins). +- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed. + +```json5 +{ + channels: { + telegram: { + botToken: "123456:ABCDEF", + }, + }, +} +``` + +### [Slack](https://docs.openclaw.ai/channels/slack) + +- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`). + +### [Discord](https://docs.openclaw.ai/channels/discord) + +- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins). +- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed. + +```json5 +{ + channels: { + discord: { + token: "1234abcd", + }, + }, +} +``` + +### [Signal](https://docs.openclaw.ai/channels/signal) + +- Requires `signal-cli` and a `channels.signal` config section. + +### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles) + +- **Recommended** iMessage integration. +- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`). +- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere. + +### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage) + +- Legacy macOS-only integration via `imsg` (Messages must be signed in). +- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. + +### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) + +- Configure a Teams app + Bot Framework, then add a `msteams` config section. +- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`. + +### [WebChat](https://docs.openclaw.ai/web/webchat) + +- Uses the Gateway WebSocket; no separate WebChat port/config. + +Browser control (optional): + +```json5 +{ + browser: { + enabled: true, + color: "#FF4500", + }, +} +``` + +## Docs + +Use these when you’re past the onboarding flow and want the deeper reference. + +- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai) +- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture) +- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration) +- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway) +- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web) +- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote) +- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard) +- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook) +- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub) +- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar) +- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android) +- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting) +- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security) + +## Advanced docs (discovery + control) + +- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery) +- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour) +- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing) +- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme) +- [Control UI](https://docs.openclaw.ai/web/control-ui) +- [Dashboard](https://docs.openclaw.ai/web/dashboard) + +## Operations & troubleshooting + +- [Health checks](https://docs.openclaw.ai/gateway/health) +- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock) +- [Background process](https://docs.openclaw.ai/gateway/background-process) +- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting) +- [Logging](https://docs.openclaw.ai/logging) + +## Deep dives + +- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop) +- [Presence](https://docs.openclaw.ai/concepts/presence) +- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox) +- [RPC adapters](https://docs.openclaw.ai/reference/rpc) +- [Queue](https://docs.openclaw.ai/concepts/queue) + +## Workspace & skills + +- [Skills config](https://docs.openclaw.ai/tools/skills-config) +- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default) +- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS) +- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP) +- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY) +- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL) +- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS) +- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER) + +## Platform internals + +- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup) +- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar) +- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake) +- [iOS node](https://docs.openclaw.ai/platforms/ios) +- [Android node](https://docs.openclaw.ai/platforms/android) +- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows) +- [Linux app](https://docs.openclaw.ai/platforms/linux) + +## Email hooks (Gmail) + +- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub) + +## Molty + +OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞 +by Peter Steinberger and the community. + +- [openclaw.ai](https://openclaw.ai) +- [soul.md](https://soul.md) +- [steipete.me](https://steipete.me) +- [@openclaw](https://x.com/openclaw) ## Community @@ -327,78 +674,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete sktbrd cpojer joshp123 sebslight Mariano Belinky Takhoffman tyler6204 quotentiroler Verite Igiraneza - bohdanpodvirnyi gumadeiras iHildy jaydenfyi joaohlisboa rodrigouroz Glucksberg mneves75 MatthieuBizien MaudeBot - vignesh07 vincentkoc smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt joshavant - christianklotz zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm mukhtharcm yinghaosang aether-ai-agent - nabbilkhan Mrseenz maxsumrall coygeek xadenryan VACInc juanpablodlc conroywhitney buerbaumer Bridgerz - hsrvc magimetal openclaw-bot meaningfool mudrii JustasM ENCHIGO patelhiren NicholasSpisak claude - jonisjongithub abhisekbasu1 theonejvo Blakeshannon jamesgroat Marvae BunsDev shakkernerd gejifeng akoscz - divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead natefikru daveonkels LeftX - Yida-Dev Masataka Shinohara arosstale riccardogiorato lc0rp adam91holt mousberg BillChirico shadril238 CharlieGreenman - hougangdev orlyjamie McRolly NWANGWU durenzidu JustYannicc Minidoracat magendary jessy2027 mteam88 hirefrank - M00N7682 dbhurley Eng. Juan Combetto Harrington-bot TSavo Lalit Singh julianengel jscaldwell55 bradleypriest TsekaLuk - benithors Shailesh loiie45e El-Fitz benostein pvtclawn thewilloftheshadow nachx639 0xRaini Taylor Asplund - Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino xinhuagu brandonwise - rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b leszekszpunar davidrudduck Jackten scald pycckuu Parker Todd Brooks - simonemacario omair445 AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron popomore - Patrick Barletta shayan919293 不做了睡大觉 Lucky Michael Lee sircrumpet peschee dakshaymehta nicolasstanley davidiach - nonggia.liang seheepeak danielwanwx hudson-rivera misterdas Shuai-DaiDai dominicnunez obviyus lploc94 sfo2001 - lutr0 dirbalak cathrynlavery kiranjd danielz1z Iranb cdorsey AdeboyeDN j2h4u Alg0rix - Skyler Miao peetzweg/ TideFinder Clawborn emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez - webvijayi garnetlyx jlowin liebertar Max rhuanssauro joshrad-dev osolmaz adityashaw2 CashWilliams - sheeek asklee-klawd h0tp-ftw constansino Mitsuyuki Osabe onutc ryan artuskg Solvely-Colin mcaxtr - HirokiKobayashi-R taw0002 Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo Thorfinn wu-tian807 crimeacs - manuelhettich mcinteerj unisone bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King mahanandhi andreesg - connorshea dinakars777 divisonofficer Flash-LHR Protocol Zero kyleok Limitless slonce70 grp06 robbyczgw-cla - JayMishra-source ngutman ide-rea badlogic lailoo amitbiswal007 azade-c John-Rood Iron9521 roshanasingh4 - tosh-hamburg dlauer ezhikkk Shivam Kumar Raut jabezborja Mykyta Bozhenko YuriNachos Josh Phillips Wangnov jadilson12 - 康熙 akramcodez clawdinator[bot] emonty kaizen403 Whoaa512 chriseidhof wangai-studio ysqander Yurii Chukhlib - 17jmumford aj47 google-labs-jules[bot] hyf0-agent Kenny Lee Lukavyi Operative-001 superman32432432 DylanWoodAkers Hisleren - widingmarcus-cyber antons austinm911 boris721 damoahdominic dan-dr doodlewind GHesericsu HeimdallStrategy imfing - jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf Randy Torres Ryan Lisse sumleo Yeom-JinHo zisisp - akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose koala73 - L36 Server Marc mitschabaude-bot mkbehr Oren Rain shtse8 sibbl thesomewhatyou zats - chrisrodz echoVic Friederike Seiler gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin - Jonathan D. Rhyne (DJ-D) Joshua Mitchell Justin Ling kelvinCB Kit manmal MattQ Milofax mitsuhiko neist - pejmanjohn Ralph rmorse rubyrunsstuff rybnikov Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 - AkashKobal ameno- awkoy BinHPdev bonald Chris Taylor dawondyifraw dguido Django Navarro evalexpr - henrino3 humanwritten hyojin joeykrug justinhuangcode larlyssa liuy ludd50155 Mark Liu natedenh - odysseus0 pcty-nextgen-service-account pi0 Roopak Nijhara Sean McLellan Syhids tmchow Ubuntu uli-will-code xiaose - Aaron Konyer aaronveklabs Aditya Singh andreabadesso Andrii battman21 BinaryMuse cash-echo-bot CJWTRUST Clawd - Clawdbot ClawdFx cordx56 danballance Elarwei001 EnzeD erik-agens Evizero fcatuhe gildo - Grynn hanxiao Ignacio itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior - jverdi kentaro loeclos longmaba Marco Marandiz MarvinCui mjrussell odnxe optimikelabs oswalpalash - p6l-richard philipp-spiess Pocket Clawd RamiNoodle733 Raymond Berger Rob Axelsen Sash Catanzarite sauerdaniel Sriram Naidu Thota T5-AndyML - thejhinvirtuoso travisp VAC william arzt Yao yudshj zknicker 尹凯 {Suksham-sharma} 0oAstro - 8BlT Abdul535 abhaymundhara abhijeet117 aduk059 afurm aisling404 akari-musubi alejandro maza Alex-Alaniz - alexanderatallah alexstyl AlexZhangji amabito andrewting19 anisoptera araa47 arthyn Asleep123 Ayush Ojha - Ayush10 baccula beefiker bennewton999 bguidolim blacksmith-sh[bot] bqcfjwhz85-arch bravostation Buddy (AI) caelum0x - calvin-hpnet championswimmer chenglun.hu Chloe-VP Claw Clawdbot Maintainers cristip73 danielcadenhead dario-github DarwinsBuddy - David-Marsh-Photo davidbors-snyk dcantu96 dependabot[bot] Developer Dimitrios Ploutarchos Drake Thomsen dvrshil dxd5001 dylanneve1 - elliotsecops EmberCF ereid7 eternauta1337 f-trycua fan Felix Krause foeken frankekn fujiwara-tofu-shop - ganghyun kim gaowanqi08141999 gerardward2007 gitpds gtsifrikas habakan HassanFleyah HazAT hcl headswim - hlbbbbbbb Hubert hugobarauna hyaxia iamEvanYT ikari ikari-pl Iron ironbyte-rgb Ítalo Souza - Jamie Openshaw Jane Jarvis Deploy jarvis89757 jasonftl jasonsschin Jefferson Nunn jg-noncelogic jigar joeynyc - Jon Uleis Josh Long justyannicc Karim Naguib Kasper Neist Christjansen Keshav Rao Kevin Lin Kira knocte Knox - Kristijan Jovanovski Kyle Chen Latitude Bot Levi Figueira Liu Weizhan Lloyd Loganaden Velvindron lsh411 Lucas Kim Luka Zhang - Lukáš Loukota Lukin mac mimi mac26ai MackDing Mahsum Aktas Marc Beaupre Marcus Neves Mario Zechner Markus Buhatem Koch - Martin Púčik Martin Schürrer MarvinDontPanic Mateusz Michalik Matias Wainsten Matt Ezell Matt mini Matthew Dicembrino Mauro Bolis mcwigglesmcgee - meaadore1221-afk Mert Çiçekçi Michael Verrilli Miles minghinmatthewlam Mourad Boustani Mr. Guy Mustafa Tag Eldeen myfunc Nate - Nathaniel Kelner Netanel Draiman niceysam Nick Lamb Nick Taylor Nikolay Petrov NM nobrainer-tech Noctivoro norunners - Ocean Vael Ogulcan Celik Oleg Kossoy Olshansk Omar Khaleel OpenClaw Agent Ozgur Polat Pablo Nunez Palash Oswal pasogott - Patrick Shao Paul Pamment Paulo Portella Peter Lee Petra Donka Pham Nam pierreeurope pip-nomel plum-dawg pookNast - Pratham Dubey Quentin rafaelreis-r Raikan10 Ramin Shirali Hossein Zade Randy Torres Raphael Borg Ellul Vincenti Ratul Sarna Richard Pinedo Rick Qian - robhparker Rohan Nagpal Rohan Patil rohanpatriot Rolf Fredheim Rony Kelner Ryan Nelson Samrat Jha Santosh Sascha Reuter - Saurabh.Chopade saurav470 seans-openclawbot SecondThread seewhy Senol Dogan Sergiy Dybskiy Shadow shatner Shaun Loo - Shaun Mason Shiva Prasad Shrinija Kummari Siddhant Jain Simon Kelly SK Heavy Industries sldkfoiweuaranwdlaiwyeoaw Soumyadeep Ghosh Spacefish spiceoogway - Stephen Chen Steve succ985 Suksham Sunwoo Yu Suvin Nimnaka Swader swizzmagik Tag techboss - testingabc321 tewatia The Admiral therealZpoint-bot tian Xiao Tim Krase Timo Lins Tom McKenzie Tom Peri Tomas Hajek - Tomsun28 Tonic Travis Hinton Travis Irby Tulsi Prasad Ty Sabs Tyler uos-status Vai Varun Kruthiventi - Vibe Kanban Victor Castell victor-wu.eth vikpos Vincent VintLin Vladimir Peshekhonov void Vultr-Clawd Admin William Stock - williamtwomey Wimmie Winry Winston wolfred Xin Xinhe Hu Xu Haoran Yash Yaxuan42 - Yazin Yevhen Bobrov Yi Wang ymat19 Yuan Chen Yuanhai Zach Knickerbocker Zaf (via OpenClaw) zhixian 石川 諒 - 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hrdwdmrbl jiulingyun - kitze latitudeki5223 loukotal Manuel Maly minghinmatthewlam MSch odrobnik pcty-nextgen-ios-builder rafaelreis-r ratulsarna - reeltimeapps rhjoh ronak-guliani snopoke thesash timkrase + steipete sktbrd cpojer joshp123 Mariano Belinky Takhoffman sebslight tyler6204 quotentiroler Verite Igiraneza + gumadeiras bohdanpodvirnyi vincentkoc iHildy jaydenfyi Glucksberg joaohlisboa rodrigouroz mneves75 BunsDev + MatthieuBizien MaudeBot vignesh07 smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt + joshavant christianklotz mudrii zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm yinghaosang + nabbilkhan mukhtharcm aether-ai-agent coygeek Mrseenz maxsumrall xadenryan VACInc juanpablodlc conroywhitney + Harald Buerbaumer akoscz Bridgerz hsrvc magimetal openclaw-bot meaningfool JustasM Phineas1500 ENCHIGO + Hiren Patel NicholasSpisak claude jonisjongithub theonejvo abhisekbasu1 Ryan Haines Blakeshannon jamesgroat Marvae + arosstale shakkernerd gejifeng divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead + natefikru daveonkels LeftX Yida-Dev Masataka Shinohara Lewis riccardogiorato lc0rp adam91holt mousberg + BillChirico shadril238 CharlieGreenman hougangdev Mars orlyjamie McRolly NWANGWU LI SHANXIN Simone Macario durenzidu + JustYannicc Minidoracat magendary Jessy LANGE mteam88 brandonwise hirefrank M00N7682 dbhurley Eng. Juan Combetto + Harrington-bot TSavo Lalit Singh julianengel Jay Caldwell Kirill Shchetynin nachx639 bradleypriest TsekaLuk benithors + Shailesh thewilloftheshadow jackheuberger loiie45e El-Fitz benostein pvtclawn 0xRaini ruypang xinhuagu + Taylor Asplund adhitShet Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino + rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b omair445 dorukardahan leszekszpunar Clawborn davidrudduck scald + Igor Markelov rrenamed Parker Todd Brooks AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron + popomore Patrick Barletta shayan919293 不做了睡大觉 Luis Conde Harry Cui Kepler SidQin-cyber Lucky Michael Lee sircrumpet + peschee dakshaymehta davidiach nonggia.liang seheepeak obviyus danielwanwx osolmaz minupla misterdas + Shuai-DaiDai dominicnunez lploc94 sfo2001 lutr0 dirbalak cathrynlavery Joly0 kiranjd niceysam + danielz1z Iranb carrotRakko Oceanswave cdorsey AdeboyeDN j2h4u Alg0rix Skyler Miao peetzweg/ + TideFinder CornBrother0x DukeDeSouth emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez webvijayi + garnetlyx miloudbelarebia Jeremiah Lowin liebertar Max rhuanssauro joshrad-dev adityashaw2 CashWilliams taw0002 + asklee-klawd h0tp-ftw constansino mcaxtr onutc ryan unisone artuskg Solvely-Colin pahdo + Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo wu-tian807 ngutman crimeacs manuelhettich mcinteerj + bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King justinhuangcode mahanandhi andreesg connorshea dinakars777 + Flash-LHR JINNYEONG KIM Protocol Zero kyleok Limitless grp06 robbyczgw-cla slonce70 JayMishra-source ide-rea + lailoo badlogic echoVic amitbiswal007 azade-c John Rood dddabtc Jonathan Works roshanasingh4 tosh-hamburg + dlauer ezhikkk Shivam Kumar Raut Mykyta Bozhenko YuriNachos Josh Phillips ThomsenDrake Wangnov akramcodez jadilson12 + Whoaa512 clawdinator[bot] emonty kaizen403 chriseidhof Lukavyi wangai-studio ysqander aj47 google-labs-jules[bot] + hyf0-agent Jeremy Mumford Kenny Lee superman32432432 widingmarcus-cyber DylanWoodAkers antons austinm911 boris721 damoahdominic + dan-dr doodlewind GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf + Randy Torres sumleo Yeom-JinHo akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 jonasjancarik + koala73 mitschabaude-bot mkbehr Oren shtse8 sibbl thesomewhatyou zats chrisrodz frankekn + gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin Jonathan D. Rhyne (DJ-D) Justin Ling kelvinCB + manmal Matthew MattQ Milofax mitsuhiko neist pejmanjohn ProspectOre rmorse rubyrunsstuff + rybnikov santiagomed Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 AkashKobal ameno- awkoy + battman21 BinHPdev bonald dashed dawondyifraw dguido Django Navarro evalexpr henrino3 humanwritten + hyojin joeykrug larlyssa liuy Mark Liu natedenh odysseus0 pcty-nextgen-service-account pi0 Syhids + tmchow uli-will-code aaronveklabs andreabadesso BinaryMuse cash-echo-bot CJWTRUST cordx56 danballance Elarwei001 + EnzeD erik-agens Evizero fcatuhe gildo Grynn huntharo hydro13 itsjaydesu ivanrvpereira + jverdi kentaro loeclos longmaba MarvinCui MisterGuy420 mjrussell odnxe optimikelabs oswalpalash + p6l-richard philipp-spiess RamiNoodle733 Raymond Berger Rob Axelsen sauerdaniel SleuthCo T5-AndyML TaKO8Ki thejhinvirtuoso + travisp yudshj zknicker 0oAstro 8BlT Abdul535 abhaymundhara aduk059 afurm aisling404 + akari-musubi Alex-Alaniz alexanderatallah alexstyl andrewting19 araa47 Asleep123 Ayush10 bennewton999 bguidolim + caelum0x championswimmer Chloe-VP dario-github DarwinsBuddy David-Marsh-Photo dcantu96 dndodson dvrshil dxd5001 + dylanneve1 EmberCF ephraimm ereid7 eternauta1337 foeken gtsifrikas HazAT iamEvanYT ikari-pl + kesor knocte MackDing nobrainer-tech Noctivoro Olshansk Pratham Dubey Raikan10 SecondThread Swader + testingabc321 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou carlulsoe hrdwdmrbl hugobarauna jayhickey jiulingyun + kitze latitudeki5223 loukotal minghinmatthewlam MSch odrobnik rafaelreis-r ratulsarna reeltimeapps rhjoh + ronak-guliani snopoke thesash timkrase

diff --git a/SECURITY.md b/SECURITY.md index 378eceaff914..eb42a3355720 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,7 @@ These are frequently reported but are typically closed with no code change: - Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). - Operator-intended local features (for example TUI local `!` shell) presented as remote injection. - Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. - ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. - Missing HSTS findings on default local/loopback deployments. @@ -93,6 +94,14 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun - Implicit exec calls (no explicit host in the tool call) follow the same behavior. - This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. +## Trusted Plugin Concept (Core) + +Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. + +- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host. +- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary. +- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin. + ## Out of Scope - Public Internet Exposure @@ -101,6 +110,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun - Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) - Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) - Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). - Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) - Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. - Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact @@ -159,6 +169,23 @@ Plugins/extensions are loaded **in-process** with the Gateway and are treated as - Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. - Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. +## Temp Folder Boundary (Media/Sandbox) + +OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts: + +- Preferred temp root: `/tmp/openclaw` (when available and safe on the host). +- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-` on multi-user hosts). + +Security boundary notes: + +- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. +- Arbitrary host tmp paths are not treated as trusted media roots. +- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. +- Enforcement reference points: + - temp root resolver: `src/infra/tmp-openclaw-dir.ts` + - SDK temp helpers: `src/plugin-sdk/temp-path.ts` + - messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs` + ## Operational Guidance For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: @@ -168,7 +195,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep ### Tool filesystem hardening - `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory. +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory. - Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. ### Web Interface Safety diff --git a/appcast.xml b/appcast.xml index 0f8acfe3a3a6..902d60972fd7 100644 --- a/appcast.xml +++ b/appcast.xml @@ -209,251 +209,106 @@ - 2026.2.22 - Mon, 23 Feb 2026 01:51:13 +0100 + 2026.2.24 + Wed, 25 Feb 2026 02:59:30 +0000 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 14126 - 2026.2.22 + 14728 + 2026.2.24 15.0 - OpenClaw 2026.2.22 + OpenClaw 2026.2.24

Changes

    -
  • Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc.
  • -
  • Update/Core: add an optional built-in auto-updater for package installs (update.auto.*), default-off, with stable rollout delay+jitter and beta hourly cadence.
  • -
  • CLI/Update: add openclaw update --dry-run to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
  • -
  • Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
  • -
  • Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012)
  • -
  • iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
  • -
  • Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc.
  • -
  • Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
  • -
  • Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
  • -
  • Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc.
  • -
  • Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
  • -
  • Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
  • -
  • Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints.
  • -
  • Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit auth.deviceToken support in connect frames and tests.
  • -
  • Skills: remove bundled food-order skill from this repo; manage/install it from ClawHub instead.
  • -
  • Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
  • +
  • Auto-reply/Abort shortcuts: expand standalone stop phrases (stop openclaw, stop action, stop run, stop agent, please stop, and related variants), accept trailing punctuation (for example STOP OPENCLAW!!!), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact do not do that as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.
  • +
  • Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.
  • +
  • Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.
  • +
  • Security/Audit: add security.trust_model.multi_user_heuristic to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (sandbox.mode="all", workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
  • +
  • Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping @buape/carbon pinned.

Breaking

    -
  • BREAKING: tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require /verbose on or /verbose full.
  • -
  • BREAKING: CLI local onboarding now sets session.dmScope to per-channel-peer by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set session.dmScope to main. (#23468) Thanks @bmendonca3.
  • -
  • BREAKING: unify channel preview-streaming config to channels..streaming with enum values off | partial | block | progress, and move Slack native stream toggle to channels.slack.nativeStreaming. Legacy keys (streamMode, Slack boolean streaming) are still read and migrated by openclaw doctor --fix, but canonical saved config/docs now use the unified names.
  • -
  • BREAKING: remove legacy Gateway device-auth signature v1. Device-auth clients must now sign v2 payloads with the per-connection connect.challenge nonce and send device.nonce; nonce-less connects are rejected.
  • +
  • BREAKING: Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example user:, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
  • +
  • BREAKING: Security/Sandbox: block Docker network: "container:" namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true (break-glass). Thanks @tdjackey for reporting.

Fixes

    -
  • Security/CLI: redact sensitive values in openclaw config get output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo.
  • -
  • Install/Discord Voice: make @discordjs/opus an optional dependency so openclaw install/update no longer hard-fails when native Opus builds fail, while keeping opusscript as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
  • -
  • Docker/Setup: precreate $OPENCLAW_CONFIG_DIR/identity during docker-setup.sh so CLI commands that need device identity (for example devices list) avoid EACCES ... /home/node/.openclaw/identity failures on restrictive bind mounts. (#23948) Thanks @ackson-beep.
  • -
  • Exec/Background: stop applying the default exec timeout to background sessions (background: true or explicit yieldMs) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303)
  • -
  • Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
  • -
  • Slack/Threading: respect replyToMode when Slack auto-populates top-level thread_ts, and ignore inline replyToId directive tags when replyToMode is off so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
  • -
  • Slack/Extension: forward message read threadId to readMessages and use delivery-context threadId as outbound thread_ts fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
  • -
  • Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via conversations.open before calling files.uploadV2, which rejects non-channel IDs. chat.postMessage tolerates user IDs directly, but files.uploadV2completeUploadExternal validates channel_id against ^[CGDZ][A-Z0-9]{8,}$, causing invalid_arguments when agents reply with media to DM conversations.
  • -
  • Webchat/Chat: apply assistant final payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux.
  • -
  • Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle.
  • -
  • Webchat/Performance: reload chat.history after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz.
  • -
  • Webchat/Sessions: preserve external session routing metadata when internal chat.send turns run under webchat, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to webchat and misroute follow-up delivery. (#23258) Thanks @binary64.
  • -
  • Webchat/Sessions: preserve existing session label across /new and /reset rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer.
  • -
  • Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including chat.inject) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber.
  • -
  • Chat/UI: strip inline reply/audio directive tags ([[reply_to_current]], [[reply_to:]], [[audio_as_voice]]) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
  • -
  • Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message.
  • -
  • Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops.
  • -
  • Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions.
  • -
  • Telegram/Polling: clear Telegram webhooks (deleteWebhook) before starting long-poll getUpdates, including retry handling for transient cleanup failures.
  • -
  • Telegram/Webhook: add channels.telegram.webhookPort config support and pass it through plugin startup wiring to the monitor listener.
  • -
  • Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via chrome.storage.session, recover from target_closed navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (alarms, webNavigation). (#15099, #6175, #8468, #9807)
  • -
  • Browser/Relay: treat extension websocket as connected only when OPEN, allow reconnect when a stale CLOSING/CLOSED extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate 409 rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
  • -
  • Browser/Remote CDP: extend stale-target recovery so ensureTabAvailable() now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict tab not found errors when multiple tabs exist; includes remote-profile regression tests. (#15989)
  • -
  • Gateway/Pairing: treat operator.admin as satisfying other operator.* scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
  • -
  • Gateway/Pairing: auto-approve loopback scope-upgrade pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber.
  • -
  • Gateway/Scopes: include operator.read and operator.write in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit pairing required disconnects on loopback gateways. (#22582) thanks @YuzuruS.
  • -
  • Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
  • -
  • Gateway/Restart: fix restart-loop edge cases by keeping openclaw.mjs -> dist/entry.js bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
  • -
  • Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001.
  • -
  • Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to failed/ instead of retrying on every restart. (#23794) Thanks @aldoeliacim.
  • -
  • Cron/Status: split execution outcome (lastRunStatus) from delivery outcome (lastDeliveryStatus) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors.
  • -
  • Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto.
  • -
  • Cron: honor cron.maxConcurrentRuns in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
  • -
  • Cron/Run: enforce the same per-job timeout guard for manual cron.run executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl.
  • -
  • Cron/Run: persist the manual-run runningAtMs marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently.
  • -
  • Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery.
  • -
  • Cron/Main session: honor abort/timeout signals while retrying wakeMode=now heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window.
  • -
  • Cron/Schedule: for every jobs, prefer lastRunAtMs + everyMs when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber.
  • -
  • Cron/Service: execute manual cron.run jobs outside the cron lock (while still persisting started/finished state atomically) so cron.list and cron.status remain responsive during long forced runs. (#23628) Thanks @dsgraves.
  • -
  • Cron/Timer: keep a watchdog recheck timer armed while onTimer is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
  • -
  • Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory.
  • -
  • Cron/Isolation: force fresh session IDs for isolated cron runs so sessionTarget="isolated" executions never reuse prior run context. (#23470) Thanks @echoVic.
  • -
  • Plugins/Install: strip workspace:* devDependency entries from copied plugin manifests before npm install --omit=dev, preventing EUNSUPPORTEDPROTOCOL install failures for npm-published channel plugins (including Feishu and MS Teams).
  • -
  • Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip openclaw: workspace:* from plugin devDependencies during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603)
  • -
  • Config/Channels: auto-enable built-in channels by writing channels..enabled=true (not plugins.entries.), and stop adding built-ins to plugins.allow, preventing plugins.entries.telegram: plugin not found validation failures.
  • -
  • Config/Channels: when plugins.allow is active, auto-enable/enable flows now also allowlist configured built-in channels so channels..enabled=true cannot remain blocked by restrictive plugin allowlists.
  • -
  • Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example .backup-*, .bak, .disabled*) and move updater backup directories under .openclaw-install-backups, preventing duplicate plugin-id collisions from archived copies.
  • -
  • Plugins/CLI: make openclaw plugins enable and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
  • -
  • Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Sessions: redact sensitive token patterns from sessions_history tool output and surface contentRedacted metadata when masking occurs. (#16928) Thanks @aether-ai-agent.
  • -
  • Security/Exec: stop trusting PATH-derived directories for safe-bin allowlist checks, add explicit tools.exec.safeBinTrustedDirs, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Elevated: match tools.elevated.allowFrom against sender identities only (not recipient ctx.To), closing a recipient-token bypass for /elevated authorization. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Security/Group policy: harden channels.*.groups.*.toolsBySender matching by requiring explicit sender-key types (id:, e164:, username:, name:), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting.
  • -
  • Channels/Group policy: fail closed when groupPolicy: "allowlist" is set without explicit groups, honor account-level groupPolicy overrides, and enforce groupPolicy: "disabled" as a hard group block. (#22215) Thanks @etereo.
  • -
  • Telegram/Discord extensions: propagate trusted mediaLocalRoots through extension outbound sendMedia options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227)
  • -
  • Agents/Exec: honor explicit agent context when resolving tools.exec defaults for runs with opaque/non-agent session keys, so per-agent host/security/ask policies are applied consistently. (#11832)
  • -
  • Doctor/Security: add an explicit warning that approvals.exec.enabled=false disables forwarding only, while enforcement remains driven by host-local exec-approvals.json policy. (#15047)
  • -
  • Sandbox/Docker: default sandbox container user to the workspace owner uid:gid when agents.*.sandbox.docker.user is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979)
  • -
  • Plugins/Media sandbox: propagate trusted mediaLocalRoots through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718)
  • -
  • Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example /workspace/... and file:///workspace/...) to host workspace roots before workspace-only validation, preventing false Path escapes sandbox root rejections for sandbox file tools. (#9560)
  • -
  • Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144)
  • -
  • Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (env, nice, nohup, stdbuf, timeout) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting.
  • -
  • Node/macOS exec host: default headless macOS node system.run to local execution and only route through the companion app when OPENCLAW_NODE_EXEC_HOST=app is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547)
  • -
  • Sandbox/Media: map container workspace paths (/workspace/... and file:///workspace/...) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
  • -
  • Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
  • -
  • Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
  • -
  • Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
  • -
  • Control UI/WebSocket: send a stable per-tab instanceId in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui.
  • -
  • Config/Memory: allow "mistral" in agents.defaults.memorySearch.provider and agents.defaults.memorySearch.fallback schema validation. (#14934) Thanks @ThomsenDrake.
  • -
  • Feishu/Commands: in group chats, command authorization now falls back to top-level channels.feishu.allowFrom when per-group allowFrom is not set, so /command no longer gets blocked by an unintended empty allowlist. (#23756)
  • -
  • Dev tooling: prevent CLAUDE.md symlink target regressions by excluding CLAUDE symlink sentinels from oxfmt and marking them -text in .gitattributes, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
  • -
  • Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
  • -
  • Feishu/Media: for inbound video messages that include both file_key (video) and image_key (thumbnail), prefer file_key when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
  • -
  • Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (mtime+size) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
  • -
  • Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark SILENT_REPLY_TOKEN (NO_REPLY) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
  • -
  • Providers/OpenRouter: inject cache_control on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed.
  • -
  • Installer/Smoke tests: remove legacy OPENCLAW_USE_GUM overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly.
  • -
  • Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0.
  • -
  • Providers/OpenRouter: default reasoning to enabled when the selected model advertises reasoning: true and no session/directive override is set. (#22513) Thanks @zwffff.
  • -
  • Providers/OpenRouter: map /think levels to reasoning.effort in embedded runs while preserving explicit reasoning.max_tokens payloads. (#17236) Thanks @robbyczgw-cla.
  • -
  • Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, anthropic/...) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson.
  • -
  • Providers/OpenRouter: preserve the required openrouter/ prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445.
  • -
  • Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko.
  • -
  • Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example openrouter/@preset/...) by treating /model ...@profile auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream.
  • -
  • Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo.
  • -
  • Cron/Follow-up: pass resolved agentDir through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk.
  • -
  • Agents/Media: route tool-result MEDIA: extraction through shared parser validation so malformed prose like MEDIA:-prefixed ... is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM.
  • -
  • Logging: cap single log-file size with logging.maxFileBytes (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms.
  • -
  • Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (withRemoteHttpResponse) so embeddings and batch flows use one request/release path.
  • -
  • Memory/Embeddings: apply configured remote-base host pinning (allowedHostnames) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook.
  • -
  • Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement.
  • -
  • Memory/Index: detect memory source-set changes (for example enabling sessions after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring --force. (#17576) Thanks @TarsAI-Agent.
  • -
  • Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove.
  • -
  • Memory/QMD: on Windows, resolve bare qmd/mcporter command names to npm shim executables (.cmd) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with spawn ... ENOENT on default npm installs. (#23899) Thanks @arcbuilder-ai.
  • -
  • Memory/QMD: parse plain-text qmd collection list --json output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns Collection not found .... (#23613) Thanks @leozhucn.
  • -
  • Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
  • -
  • Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early Cannot read properties of undefined (reading 'trim') crashes during subagent spawn and wait flows.
  • -
  • Agents/Workspace: guard resolveUserPath against undefined/null input to prevent Cannot read properties of undefined (reading 'trim') crashes when workspace paths are missing in embedded runner flows.
  • -
  • Auth/Profiles: keep active cooldownUntil/disabledUntil windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual usageStats cleanup. (#23516, #23536) Thanks @arosstale.
  • -
  • Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to allowlist (instead of inheriting channels.defaults.groupPolicy) when channels. is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3.
  • -
  • Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to wss://, rejecting insecure non-loopback ws:// targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3.
  • -
  • CLI/Sessions: pass the configured sessions directory when resolving transcript paths in agentCommand, so custom session.store locations resume sessions reliably. Thanks @davidrudduck.
  • -
  • Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started signal-cli is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
  • -
  • Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber.
  • -
  • Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
  • -
  • ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early gateway not connected request races. (#23390) Thanks @janckerchen.
  • -
  • Gateway/Auth: preserve OPENCLAW_GATEWAY_PASSWORD env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
  • -
  • Gateway/Auth: preserve shared-token gateway token mismatch auth errors when auth.token fallback device-token checks fail, and reserve device token mismatch guidance for explicit auth.deviceToken failures.
  • -
  • Gateway/Tools: when agent tools pass an allowlisted gatewayUrl override, resolve local override tokens from env/config fallback but keep remote overrides strict to gateway.remote.token, preventing local token leakage to remote targets.
  • -
  • Gateway/Client: keep cached device-auth tokens on device token mismatch closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
  • -
  • Node host/Exec: keep strict Windows allowlist behavior for cmd.exe /c shell-wrapper runs, and return explicit approval guidance when blocked (SYSTEM_RUN_DENIED: allowlist miss).
  • -
  • Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with 1008 pairing required.
  • -
  • Security/Audit: add openclaw security audit detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (security.exposure.open_groups_with_runtime_or_fs).
  • -
  • Security/Audit: make gateway.real_ip_fallback_enabled severity conditional for loopback trusted-proxy setups (warn for loopback-only trustedProxies, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
  • -
  • Security/Exec env: block request-scoped HOME and ZDOTDIR overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Exec env: block SHELLOPTS/PS4 in host exec env sanitizers and restrict shell-wrapper (bash|sh|zsh ... -c/-lc) request env overrides to a small explicit allowlist (TERM, LANG, LC_*, COLORTERM, NO_COLOR, FORCE_COLOR) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • WhatsApp/Security: enforce allowFrom for direct-message outbound targets in all send modes (including mode: "explicit"), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann.
  • -
  • Security/Exec approvals: fail closed on shell line continuations (\\\n/\\\r\n) and treat shell-wrapper execution as approval-required in allowlist mode, preventing $\\ newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including gateway.controlUi.dangerouslyDisableDeviceAuth=true) and point operators to openclaw security audit.
  • -
  • Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Security/Exec approvals: treat env and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: require explicit safe-bin profiles for tools.exec.safeBins entries in allowlist mode (remove generic safe-bin profile fallback), and add tools.exec.safeBinProfiles for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp trigger_id fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime Date.now()+Math.random() token/id patterns.
  • -
  • Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including hooks.transformsDir and hooks.mappings[].transform.module) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Telegram/WSL2: disable autoSelectFamily by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync /proc/version probes on fetch/send paths. (#21916) Thanks @MizukiMachine.
  • -
  • Telegram/Network: default Node 22+ DNS result ordering to ipv4first for Telegram fetch paths and add OPENCLAW_TELEGRAM_DNS_RESULT_ORDER/channels.telegram.network.dnsResultOrder overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg.
  • -
  • Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov.
  • -
  • Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus.
  • -
  • Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies.
  • -
  • Telegram/Replies: normalize file:// and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies.
  • -
  • Telegram/Replies: extract forwarded-origin context from unified reply targets (reply_to_message and external_reply) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr.
  • -
  • Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower update_id updates after out-of-order completion. (#23284) thanks @frankekn.
  • -
  • Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic.
  • -
  • Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound app.options calls. (#23209) Thanks @0xgaia.
  • -
  • Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
  • -
  • Slack/Queue routing: preserve string thread_ts values through collect-mode queue drain and DM deliveryContext updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc.
  • -
  • Telegram/Native commands: set ctx.Provider="telegram" for native slash-command context so elevated gate checks resolve provider correctly (fixes provider (ctx.Provider) failures in /elevated flows). (#23748) Thanks @serhii12.
  • -
  • Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
  • -
  • Cron/Gateway: keep cron.list and cron.status responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
  • -
  • Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged memory.qmd.paths and memory.qmd.scope.rules no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
  • -
  • Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728.
  • -
  • Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear invalid cron schedule: expr is required error instead of crashing with undefined.trim failures and auto-disable churn. (#23223) Thanks @asimons81.
  • -
  • Memory/QMD: migrate legacy unscoped collection bindings (for example memory-root) to per-agent scoped names (for example memory-root-main) during startup when safe, so QMD-backed memory_search no longer fails with Collection not found after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
  • -
  • Memory/QMD: normalize Han-script BM25 search queries before invoking qmd search so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130.
  • -
  • TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
  • -
  • TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96.
  • -
  • TUI/Status: request immediate renders after setting sending/waiting activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
  • -
  • TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
  • -
  • Agents/Fallbacks: treat JSON payloads with type: "api_error" + "Internal server error" as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
  • -
  • Agents/Google: sanitize non-base64 thought_signature/thoughtSignature values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic.
  • -
  • Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
  • -
  • Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 HTTP 400 failures on tool continuations. (#23698) Thanks @echoVic.
  • -
  • Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
  • -
  • Agents/Replies: emit a default completion acknowledgement (✅ Done.) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue.
  • -
  • Agents/Subagents: honor tools.subagents.tools.alsoAllow and explicit subagent allow entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example sessions_send) are no longer blocked unless re-denied in tools.subagents.tools.deny. (#23359) Thanks @goren-beehero.
  • -
  • Agents/Subagents: make announce call timeouts configurable via agents.defaults.subagents.announceTimeoutMs and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon.
  • -
  • Agents/Diagnostics: include resolved lifecycle error text in embedded run agent end warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
  • -
  • Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar.
  • -
  • Plugins/Hooks: run legacy before_agent_start once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710.
  • -
  • Models/Config: default missing Anthropic provider/model api fields to anthropic-messages during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123.
  • -
  • Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit scopes, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
  • -
  • Memory/QMD: add optional memory.qmd.mcporter search routing so QMD query/search/vsearch can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
  • -
  • Infra/Network: classify undici TypeError: fetch failed as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung.
  • -
  • Telegram/Retry: classify undici TypeError: fetch failed as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg.
  • -
  • Docs/Telegram: correct Node 22+ network defaults (autoSelectFamily, dnsResultOrder) and clarify Telegram setup does not use positional openclaw channels login telegram. (#23609) Thanks @ryanbastic.
  • -
  • BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
  • -
  • BlueBubbles/Private API cache: treat unknown (null) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic.
  • -
  • BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits handle but provides DM chatGuid, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31.
  • -
  • Security/Audit: add openclaw security audit finding gateway.nodes.allow_commands_dangerous for risky gateway.nodes.allowCommands overrides, with severity upgraded to critical on remote gateway exposure.
  • -
  • Gateway/Control plane: reduce cross-client write limiter contention by adding connId fallback keying when device ID and client IP are both unavailable.
  • -
  • Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (__proto__, constructor, prototype) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn.
  • -
  • Security/Shell env: validate login-shell executable paths for shell-env fallback (/etc/shells + trusted prefixes), block SHELL/HOME/ZDOTDIR in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin HOME to the real user home while dropping ZDOTDIR and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Network/SSRF: enable autoSelectFamily on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness.
  • -
  • Security/Config: make parsed chat allowlist checks fail closed when allowFrom is empty, restoring expected DM/pairing gating.
  • -
  • Security/Exec: in non-default setups that manually add sort to tools.exec.safeBins, block sort --compress-program so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
  • -
  • Security/Exec approvals: when users choose allow-always for shell-wrapper commands (for example /bin/zsh -lc ...), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863.
  • -
  • Security/Exec: fail closed when tools.exec.host=sandbox is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3.
  • -
  • Security/macOS app beta: enforce path-only system.run allowlist matching (drop basename matches like echo), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Agents: auto-generate and persist a dedicated commands.ownerDisplaySecret when commands.ownerDisplay=hash, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • -
  • Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting.
  • -
  • Security/SSRF: block RFC2544 benchmarking range (198.18.0.0/15) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example ::127.0.0.1, 64:ff9b::8.8.8.8) in shared IP parsing/classification.
  • -
  • Security/Archive: block zip symlink escapes during archive extraction.
  • -
  • Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative ../ sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed.
  • -
  • Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example /tmp -> /private/tmp on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku.
  • -
  • Security/Discord: add openclaw security audit warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel users, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway: block node-role connections when device identity metadata is missing.
  • -
  • Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Media/Understanding: preserve application/pdf MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
  • -
  • Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback index.html. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway avatars: block symlink traversal during local avatar data: URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before /avatar resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
  • -
  • Security/Control UI avatars: harden /avatar/:agentId local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
  • -
  • Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared safeFetch so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
  • -
  • Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
  • -
  • Chat/Usage/TUI: strip synthetic inbound metadata blocks (including Conversation info and trailing Untrusted context channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
  • -
  • CI/Tests: fix TypeScript case-table typing and lint assertion regressions so pnpm check passes again after Synology Chat landing. (#23012) Thanks @druide67.
  • -
  • Security/Browser relay: harden extension relay auth token handling for /extension and /cdp pathways.
  • -
  • Cron: persist delivered state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
  • -
  • Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
  • -
  • Config/Channels: whitelist channels.modelByChannel in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger unknown channel id validation errors or bogus modelByChannel plugin enables. (#23412) Thanks @ProspectOre.
  • -
  • Config/Bindings: allow optional bindings[].comment in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic.
  • -
  • Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
  • -
  • Gateway/Daemon: verify gateway health after daemon restart.
  • -
  • Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
  • +
  • Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (channel/to/thread) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
  • +
  • Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.
  • +
  • Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)
  • +
  • Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from last to none (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
  • +
  • Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
  • +
  • Cron/Heartbeat delivery: stop inheriting cached session lastThreadId for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
  • +
  • Messaging tool dedupe: treat originating channel metadata as authoritative for same-target message.send suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so delivery-mirror transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
  • +
  • Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.
  • +
  • Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.
  • +
  • Gateway/Models: honor explicit agents.defaults.models allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in models.list, and allow sessions.patch//model selection for those refs without false model not allowed errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.
  • +
  • Control UI/Agents: inherit agents.defaults.model.fallbacks in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.
  • +
  • Automation/Subagent/Cron reliability: honor ANNOUNCE_SKIP in sessions_spawn completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include cron in the coding tool profile so /tools/invoke can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
  • +
  • Discord/Voice reliability: restore runtime DAVE dependency (@snazzah/davey), add configurable DAVE join options (channels.discord.voice.daveEncryption and channels.discord.voice.decryptionFailureTolerance), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)
  • +
  • Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all block payloads), fixing missing Discord replies in channels.discord.streaming=block mode. (#25839, #25836, #25792) Thanks @pewallin.
  • +
  • Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire messages.statusReactions.{emojis,timing} into Discord reaction lifecycle control, and compact model-picker custom_id keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
  • +
  • WhatsApp/Web reconnect: treat close status 440 as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
  • +
  • WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with Reasoning: before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
  • +
  • Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
  • +
  • Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
  • +
  • Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
  • +
  • Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram autoSelectFamily decisions so outbound fetch calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
  • +
  • Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
  • +
  • Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.
  • +
  • Slack/DM routing: treat D* channel IDs as direct messages even when Slack sends an incorrect channel_type, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
  • +
  • Zalo/Group policy: enforce sender authorization for group messages with groupPolicy + groupAllowFrom (fallback to allowFrom), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.
  • +
  • macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
  • +
  • macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.
  • +
  • macOS/Voice wake routing: default forwarded voice-wake transcripts to the webchat channel (instead of ambiguous last routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.
  • +
  • macOS/Gateway launch: prefer an available openclaw binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.
  • +
  • macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
  • +
  • macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.
  • +
  • Windows/Exec shell selection: prefer PowerShell 7 (pwsh) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing && command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.
  • +
  • Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 dev=0 stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false Local media path is not safe to read drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
  • +
  • iMessage/Reasoning safety: harden iMessage echo suppression with outbound messageId matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.
  • +
  • Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.
  • +
  • Providers/Google reasoning: sanitize invalid negative thinkingBudget payloads for Gemini 3.1 requests by dropping -1 budgets and mapping configured reasoning effort to thinkingLevel, preventing malformed reasoning payloads on google-generative-ai. (#25900)
  • +
  • Providers/SiliconFlow: normalize thinking="off" to thinking: null for Pro/* model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
  • +
  • Models/Bedrock auth: normalize additional Bedrock provider aliases (bedrock, aws-bedrock, aws_bedrock, amazon bedrock) to canonical amazon-bedrock, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
  • +
  • Models/Providers: preserve explicit user reasoning overrides when merging provider model config with built-in catalog metadata, so reasoning: false is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
  • +
  • Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false pairing required failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
  • +
  • CLI/Memory search: accept --query for openclaw memory search (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
  • +
  • CLI/Doctor: correct stale recovery hints to use valid commands (openclaw gateway status --deep and openclaw configure --section model). (#24485) Thanks @chilu18.
  • +
  • Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
  • +
  • Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid plugins.entries. writes when ids differ. (#25275) Thanks @zerone0x.
  • +
  • Config/Plugins: treat stale removed google-antigravity-auth plugin references as compatibility warnings (not hard validation errors) across plugins.entries, plugins.allow, plugins.deny, and plugins.slots.memory, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
  • +
  • Config/Meta: accept numeric meta.lastTouchedAt timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write Date.now() values. (#25491) Thanks @mcaxtr.
  • +
  • Usage accounting: parse Moonshot/Kimi cached_tokens fields (including prompt_tokens_details.cached_tokens) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
  • +
  • Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
  • +
  • Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit status/code/http 402 detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
  • +
  • Sessions/Tool-result guard: avoid generating synthetic toolResult entries for assistant turns that ended with stopReason: "aborted" or "error", preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
  • +
  • Auto-reply/Reset hooks: guarantee native /new and /reset flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
  • +
  • Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
  • +
  • Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.
  • +
  • Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not ; joins) to avoid POSIX sh do; syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
  • +
  • Sandbox/Config: preserve dangerouslyAllowReservedContainerTargets and dangerouslyAllowExternalBindSources during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.
  • +
  • Gateway/Security: enforce gateway auth for the exact /api/channels plugin root path (plus /api/channels/ descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
  • +
  • Exec approvals: treat bare allowlist * as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
  • +
  • iOS/Signing: improve scripts/ios-team-id.sh for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode xcodebuild output directories (apps/ios/build, apps/shared/OpenClawKit/build, Swabble/build). (#22773) Thanks @brianleach.
  • +
  • Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
  • +
  • Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (LD_*, DYLD_*, SSLKEYLOGFILE, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
  • +
  • Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width HOOK:...) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
  • +
  • Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.
  • +
  • Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host os.tmpdir() trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
  • +
  • Security/Message actions: enforce local media root checks for sendAttachment and setGroupIcon when sandboxRoot is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.
  • +
  • Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.
  • +
  • Security/Workspace FS: normalize @-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.
  • +
  • Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so dmPolicy: "allowlist" with empty allowedUserIds rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.
  • +
  • Security/Native images: enforce tools.fs.workspaceOnly for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: bind system.run command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only rawCommand mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.
  • +
  • Security/Exec companion host: forward canonical system.run display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested /usr/bin/env chains cannot bypass shell-wrapper approval gating in allowlist + ask=on-miss mode. Thanks @tdjackey for reporting.
  • +
  • Security/Exec: limit default safe-bin trusted directories to immutable system paths (/bin, /usr/bin) and require explicit opt-in (tools.exec.safeBinTrustedDirs) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured safeBins resolve outside trusted dirs. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.

View full changelog

]]>
- +
\ No newline at end of file diff --git a/apps/android/README.md b/apps/android/README.md index c2ae5a2179bf..5e4d32359e0a 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -1,13 +1,26 @@ -## OpenClaw Node (Android) (internal) +## OpenClaw Android App -Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**. +Status: **extremely alpha**. The app is actively being rebuilt from the ground up. -Notes: -- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). -- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android). -- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose). +### Rebuild Checklist + +- [x] New 4-step onboarding flow +- [x] Connect tab with `Setup Code` + `Manual` modes +- [x] Encrypted persistence for gateway setup/auth state +- [x] Chat UI restyled +- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect) +- [ ] QR code scanning in onboarding +- [ ] Performance improvements +- [ ] Streaming support in chat UI +- [ ] Request camera/location and other permissions in onboarding/settings flow +- [ ] Push notifications for gateway/chat status updates +- [ ] Security hardening (biometric lock, token handling, safer defaults) +- [ ] Voice tab full functionality +- [ ] Screen tab full functionality +- [ ] Full end-to-end QA and release hardening ## Open in Android Studio + - Open the folder `apps/android`. ## Build / Run @@ -23,16 +36,19 @@ cd apps/android ## Connect / Pair -1) Start the gateway (on your “master” machine): +1) Start the gateway (on your main machine): + ```bash pnpm openclaw gateway --port 18789 --verbose ``` 2) In the Android app: -- Open **Settings** -- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port). + +- Open the **Connect** tab. +- Use **Setup Code** or **Manual** mode to connect. 3) Approve pairing (on the gateway machine): + ```bash openclaw nodes pending openclaw nodes approve @@ -49,3 +65,8 @@ More details: `docs/platforms/android.md`. - Camera: - `CAMERA` for `camera.snap` and `camera.clip` - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` + +## Contributions + +This Android app is currently being rebuilt. +Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord. diff --git a/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt new file mode 100644 index 000000000000..472064afc4b8 --- /dev/null +++ b/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 52e1014e7ba7..ffe7d1d77c30 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -2,7 +2,6 @@ import com.android.build.api.variant.impl.VariantOutputImpl plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") } @@ -13,7 +12,7 @@ android { sourceSets { getByName("main") { - assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")) + assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") } } @@ -21,8 +20,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602230 - versionName = "2026.2.23" + versionCode = 202602250 + versionName = "2026.2.25" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -97,7 +96,7 @@ kotlin { } dependencies { - val composeBom = platform("androidx.compose:compose-bom:2025.12.00") + val composeBom = platform("androidx.compose:compose-bom:2026.02.00") implementation(composeBom) androidTestImplementation(composeBom) @@ -112,7 +111,7 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.6") + implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -120,12 +119,17 @@ dependencies { implementation("com.google.android.material:material:1.13.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.exifinterface:exifinterface:1.4.2") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("org.bouncycastle:bcprov-jdk18on:1.83") + implementation("org.commonmark:commonmark:0.27.1") + implementation("org.commonmark:commonmark-ext-autolink:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1") + implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1") // CameraX (for node.invoke camera.* parity) implementation("androidx.camera:camera-core:1.5.2") @@ -139,9 +143,9 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") - testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") - testImplementation("org.robolectric:robolectric:4.16") + testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3") + testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3") + testImplementation("org.robolectric:robolectric:4.16.1") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt index 2bbfd8712f92..cafe0958f86a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt @@ -1,9 +1,7 @@ package ai.openclaw.android -import android.Manifest import android.content.pm.ApplicationInfo import android.os.Bundle -import android.os.Build import android.view.WindowManager import android.webkit.WebView import androidx.activity.ComponentActivity @@ -11,7 +9,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -32,8 +29,6 @@ class MainActivity : ComponentActivity() { val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 WebView.setWebContentsDebuggingEnabled(isDebuggable) applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() NodeForegroundService.start(this) permissionRequester = PermissionRequester(this) screenCaptureRequester = ScreenCaptureRequester(this) @@ -93,38 +88,4 @@ class MainActivity : ComponentActivity() { WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE controller.hide(WindowInsetsCompat.Type.systemBars()) } - - private fun requestDiscoveryPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT >= 33) { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) - } - } else { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) - } - } - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) return - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) - } - } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt index d9123d10293e..7076f09a292f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -14,6 +14,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { private val runtime: NodeRuntime = (app as NodeApp).runtime val canvas: CanvasController = runtime.canvas + val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl + val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated + val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending + val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText val camera: CameraCaptureManager = runtime.camera val screenRecorder: ScreenRecordManager = runtime.screenRecorder val sms: SmsManager = runtime.sms @@ -22,6 +26,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val discoveryStatusText: StateFlow = runtime.discoveryStatusText val isConnected: StateFlow = runtime.isConnected + val isNodeConnected: StateFlow = runtime.nodeConnected val statusText: StateFlow = runtime.statusText val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress @@ -53,6 +58,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualPort: StateFlow = runtime.manualPort val manualTls: StateFlow = runtime.manualTls val gatewayToken: StateFlow = runtime.gatewayToken + val onboardingCompleted: StateFlow = runtime.onboardingCompleted val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled val chatSessionKey: StateFlow = runtime.chatSessionKey @@ -110,6 +116,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setGatewayToken(value) } + fun setGatewayPassword(value: String) { + runtime.setGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + runtime.setOnboardingCompleted(value) + } + fun setCanvasDebugStatusEnabled(value: Boolean) { runtime.setCanvasDebugStatusEnabled(value) } @@ -130,6 +144,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setTalkEnabled(enabled) } + fun logGatewayDebugSnapshot(source: String = "manual") { + runtime.logGatewayDebugSnapshot(source) + } + fun refreshGatewayConnection() { runtime.refreshGatewayConnection() } @@ -158,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.handleCanvasA2UIActionFromWebView(payloadJson) } + fun requestCanvasRehydrate(source: String = "screen_tab") { + runtime.requestCanvasRehydrate(source = source, force = true) + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index aec192c25bbc..3e804ec8a076 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.SystemClock +import android.util.Log import androidx.core.content.ContextCompat import ai.openclaw.android.chat.ChatController import ai.openclaw.android.chat.ChatMessage @@ -163,6 +164,12 @@ class NodeRuntime(context: Context) { isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, + onCanvasA2uiPush = { + _canvasA2uiHydrated.value = true + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + }, + onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, ) private lateinit var gatewayEventHandler: GatewayEventHandler @@ -174,6 +181,8 @@ class NodeRuntime(context: Context) { private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private val _nodeConnected = MutableStateFlow(false) + val nodeConnected: StateFlow = _nodeConnected.asStateFlow() private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() @@ -194,6 +203,13 @@ class NodeRuntime(context: Context) { private val _screenRecordActive = MutableStateFlow(false) val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() + private val _canvasA2uiHydrated = MutableStateFlow(false) + val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow() + private val _canvasRehydratePending = MutableStateFlow(false) + val canvasRehydratePending: StateFlow = _canvasRehydratePending.asStateFlow() + private val _canvasRehydrateErrorText = MutableStateFlow(null) + val canvasRehydrateErrorText: StateFlow = _canvasRehydrateErrorText.asStateFlow() + private val _serverName = MutableStateFlow(null) val serverName: StateFlow = _serverName.asStateFlow() @@ -207,8 +223,9 @@ class NodeRuntime(context: Context) { val isForeground: StateFlow = _isForeground.asStateFlow() private var lastAutoA2uiUrl: String? = null + private var didAutoRequestCanvasRehydrate = false + private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false - private var nodeConnected = false private var operatorStatusText: String = "Offline" private var nodeStatusText: String = "Offline" @@ -254,14 +271,23 @@ class NodeRuntime(context: Context) { identityStore = identityStore, deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> - nodeConnected = true + _nodeConnected.value = true nodeStatusText = "Connected" + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() maybeNavigateToA2uiOnConnect() + requestCanvasRehydrate(source = "node_connect", force = false) }, onDisconnected = { message -> - nodeConnected = false + _nodeConnected.value = false nodeStatusText = message + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() showLocalCanvasOnDisconnect() }, @@ -304,9 +330,9 @@ class NodeRuntime(context: Context) { _isConnected.value = operatorConnected _statusText.value = when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" + operatorConnected && _nodeConnected.value -> "Connected" + operatorConnected && !_nodeConnected.value -> "Connected (node offline)" + !operatorConnected && _nodeConnected.value -> "Connected (operator offline)" operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText else -> nodeStatusText } @@ -328,9 +354,63 @@ class NodeRuntime(context: Context) { private fun showLocalCanvasOnDisconnect() { lastAutoA2uiUrl = null + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null canvas.navigate("") } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { + scope.launch { + if (!_nodeConnected.value) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Node offline. Reconnect and retry." + return@launch + } + if (!force && didAutoRequestCanvasRehydrate) return@launch + didAutoRequestCanvasRehydrate = true + val requestId = canvasRehydrateSeq.incrementAndGet() + _canvasRehydratePending.value = true + _canvasRehydrateErrorText.value = null + + val sessionKey = resolveMainSessionKey() + val prompt = + "Restore canvas now for session=$sessionKey source=$source. " + + "If existing A2UI state exists, replay it immediately. " + + "If not, create and render a compact mobile-friendly dashboard in Canvas." + val sent = + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(prompt)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + if (!sent) { + if (!force) { + didAutoRequestCanvasRehydrate = false + } + if (canvasRehydrateSeq.get() == requestId) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry." + } + Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable") + return@launch + } + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." + } + } + } + val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled @@ -345,7 +425,10 @@ class NodeRuntime(context: Context) { val manualPort: StateFlow = prefs.manualPort val manualTls: StateFlow = prefs.manualTls val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) + fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled @@ -531,6 +614,15 @@ class NodeRuntime(context: Context) { prefs.setTalkEnabled(value) } + fun logGatewayDebugSnapshot(source: String = "manual") { + val flowToken = gatewayToken.value.trim() + val loadedToken = prefs.loadGatewayToken().orEmpty() + Log.i( + "OpenClawGatewayDebug", + "source=$source manualEnabled=${manualEnabled.value} host=${manualHost.value} port=${manualPort.value} tls=${manualTls.value} flowTokenLen=${flowToken.length} loadTokenLen=${loadedToken.length} connected=${isConnected.value} status=${statusText.value}", + ) + } + fun refreshGatewayConnection() { val endpoint = connectedEndpoint ?: return val token = prefs.loadGatewayToken() @@ -639,10 +731,10 @@ class NodeRuntime(context: Context) { contextJson = contextJson, ) - val connected = nodeConnected + val connected = _nodeConnected.value var error: String? = null if (connected) { - try { + val sent = nodeSession.sendNodeEvent( event = "agent.request", payloadJson = @@ -654,8 +746,8 @@ class NodeRuntime(context: Context) { put("key", JsonPrimitive(actionId)) }.toString(), ) - } catch (e: Throwable) { - error = e.message ?: "send failed" + if (!sent) { + error = "send failed" } } else { error = "gateway not connected" diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt index 29ef4a3eaae1..f03e2b56e0b0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -75,6 +75,10 @@ class SecurePrefs(context: Context) { MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") val gatewayToken: StateFlow = _gatewayToken + private val _onboardingCompleted = + MutableStateFlow(prefs.getBoolean("onboarding.completed", false)) + val onboardingCompleted: StateFlow = _onboardingCompleted + private val _lastDiscoveredStableId = MutableStateFlow( prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", @@ -148,8 +152,18 @@ class SecurePrefs(context: Context) { } fun setGatewayToken(value: String) { - prefs.edit { putString("gateway.manual.token", value) } - _gatewayToken.value = value + val trimmed = value.trim() + prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) } + _gatewayToken.value = trimmed + } + + fun setGatewayPassword(value: String) { + saveGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + prefs.edit { putBoolean("onboarding.completed", value) } + _onboardingCompleted.value = value } fun setCanvasDebugStatusEnabled(value: Boolean) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt index 3ed69ee5b243..335f3b0d70be 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt @@ -261,11 +261,7 @@ class ChatController( val key = _sessionKey.value try { if (supportsChatSubscribe) { - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - } catch (_: Throwable) { - // best-effort - } + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") @@ -325,6 +321,12 @@ class ChatController( val state = payload["state"].asStringOrNull() when (state) { + "delta" -> { + val text = parseAssistantDeltaText(payload) + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } "final", "aborted", "error" -> { if (state == "error") { _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" @@ -351,9 +353,8 @@ class ChatController( private fun handleAgentEvent(payloadJson: String) { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val runId = payload["runId"].asStringOrNull() - val sessionId = _sessionId.value - if (sessionId != null && runId != sessionId) return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return val stream = payload["stream"].asStringOrNull() val data = payload["data"].asObjectOrNull() @@ -398,6 +399,21 @@ class ChatController( } } + private fun parseAssistantDeltaText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"].asArrayOrNull() ?: return null + for (item in content) { + val obj = item.asObjectOrNull() ?: continue + if (obj["type"].asStringOrNull() != "text") continue + val text = obj["text"].asStringOrNull() + if (!text.isNullOrEmpty()) { + return text + } + } + return null + } + private fun publishPendingToolCalls() { _pendingToolCalls.value = pendingToolCallsById.values.sortedBy { it.startedAtMs } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 0f49541daff7..4e210de8fb99 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -131,8 +131,8 @@ class GatewaySession( fun currentCanvasHostUrl(): String? = canvasHostUrl fun currentMainSessionKey(): String? = mainSessionKey - suspend fun sendNodeEvent(event: String, payloadJson: String?) { - val conn = currentConnection ?: return + suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean { + val conn = currentConnection ?: return false val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } val params = buildJsonObject { @@ -147,8 +147,10 @@ class GatewaySession( } try { conn.request("node.event", params, timeoutMs = 8_000) + return true } catch (err: Throwable) { Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") + return false } } @@ -301,16 +303,31 @@ class GatewaySession( val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken - val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) + var res = request("connect", payload, timeoutMs = 8_000) if (!res.ok) { val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { + val hasStoredToken = !storedToken.isNullOrBlank() + val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank() + if (canRetryWithShared) { + val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim()) + val sharedRes = request("connect", sharedPayload, timeoutMs = 8_000) + if (!sharedRes.ok) { + val retryMsg = sharedRes.error?.message ?: msg + throw IllegalStateException(retryMsg) + } + // Stored device token was bypassed successfully; clear stale token for future connects. deviceAuthStore.clearToken(identity.deviceId, options.role) + res = sharedRes + } else { + throw IllegalStateException(msg) } - throw IllegalStateException(msg) } + handleConnectSuccess(res, identity.deviceId) + connectDeferred.complete(Unit) + } + + private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() @@ -318,16 +335,15 @@ class GatewaySession( val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) + deviceAuthStore.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() - canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) + canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) val sessionDefaults = obj["snapshot"].asObjectOrNull() ?.get("sessionDefaults").asObjectOrNull() mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() onConnected(serverName, remoteAddress, mainSessionKey) - connectDeferred.complete(Unit) } private fun buildConnectParams( @@ -611,24 +627,30 @@ class GatewaySession( return parts.joinToString("|") } - private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { + private fun normalizeCanvasHostUrl( + raw: String?, + endpoint: GatewayEndpoint, + isTlsConnection: Boolean, + ): String? { val trimmed = raw?.trim().orEmpty() val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } val host = parsed?.host?.trim().orEmpty() val port = parsed?.port ?: -1 val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } - - // Detect TLS reverse proxy: endpoint on port 443, or domain-based host - val tls = endpoint.port == 443 || endpoint.host.contains(".") - - // If raw URL is a non-loopback address AND we're behind TLS reverse proxy, - // fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy) - if (trimmed.isNotBlank() && !isLoopbackHost(host)) { - if (tls && port > 0 && port != 443) { - // Rewrite the URL to use the reverse proxy port instead of the raw gateway port - val fixedScheme = "https" - val formattedHost = if (host.contains(":")) "[${host}]" else host - return "$fixedScheme://$formattedHost" + val suffix = buildUrlSuffix(parsed) + + // If raw URL is a non-loopback address and this connection uses TLS, + // normalize scheme/port to the endpoint we actually connected to. + if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) { + val needsTlsRewrite = + isTlsConnection && + ( + !scheme.equals("https", ignoreCase = true) || + (port > 0 && port != endpoint.port) || + (port <= 0 && endpoint.port != 443) + ) + if (needsTlsRewrite) { + return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix) } return trimmed } @@ -639,14 +661,26 @@ class GatewaySession( ?: endpoint.host.trim() if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } - // When connecting through a reverse proxy (TLS on standard port), use the - // connection endpoint's scheme and port instead of the raw canvas port. - val fallbackScheme = if (tls) "https" else scheme - // Behind reverse proxy, always use the proxy port (443), not the raw canvas port - val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port) - val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost - val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort" - return "$fallbackScheme://$formattedHost$portSuffix" + // For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata. + val fallbackScheme = if (isTlsConnection) "https" else scheme + // For TLS, always use the connected endpoint port. + val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port) + return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix) + } + + private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String { + val loweredScheme = scheme.lowercase() + val formattedHost = if (host.contains(":")) "[${host}]" else host + val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port" + return "$loweredScheme://$formattedHost$portSuffix$suffix" + } + + private fun buildUrlSuffix(uri: java.net.URI?): String { + if (uri == null) return "" + val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: "" + val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: "" + val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: "" + return "$path$query$fragment" } private fun isLoopbackHost(raw: String?): Boolean { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt index c46770a6367f..d0747ee32b00 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -10,6 +10,9 @@ import androidx.core.graphics.scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.io.ByteArrayOutputStream import android.util.Base64 import org.json.JSONObject @@ -31,6 +34,8 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + private val _currentUrl = MutableStateFlow(null) + val currentUrl: StateFlow = _currentUrl.asStateFlow() private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" @@ -45,9 +50,16 @@ class CanvasController { applyDebugStatus() } + fun detach(webView: WebView) { + if (this.webView === webView) { + this.webView = null + } + } + fun navigate(url: String) { val trimmed = url.trim() this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed + _currentUrl.value = this.url reload() } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index d15d928e0a45..9b449fc85f37 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -176,7 +176,7 @@ class ConnectionManager( caps = emptyList(), commands = emptyList(), permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"), + client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"), userAgent = buildUserAgent(), ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index e44896db0fa1..91e9da8add14 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -20,6 +20,8 @@ class InvokeDispatcher( private val isForeground: () -> Boolean, private val cameraEnabled: () -> Boolean, private val locationEnabled: () -> Boolean, + private val onCanvasA2uiPush: () -> Unit, + private val onCanvasA2uiReset: () -> Unit, ) { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { // Check foreground requirement for canvas/camera/screen commands @@ -117,6 +119,7 @@ class InvokeDispatcher( ) } val res = canvas.eval(A2UIHandler.a2uiResetJS) + onCanvasA2uiReset() GatewaySession.InvokeResult.ok(res) } OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { @@ -143,6 +146,7 @@ class InvokeDispatcher( } val js = A2UIHandler.a2uiApplyMessagesJS(messages) val res = canvas.eval(js) + onCanvasA2uiPush() GatewaySession.InvokeResult.ok(res) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt new file mode 100644 index 000000000000..f733d154ed95 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -0,0 +1,150 @@ +package ai.openclaw.android.ui + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import ai.openclaw.android.MainViewModel + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val webViewRef = remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + onDispose { + val webView = webViewRef.value ?: return@onDispose + viewModel.canvas.detach(webView) + webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName) + webView.stopLoading() + webView.destroy() + webViewRef.value = null + } + } + + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.useWideViewPort = false + settings.loadWithOverviewMode = false + settings.builtInZoomControls = false + settings.displayZoomControls = false + settings.setSupportZoom(false) + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e( + "OpenClawWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("OpenClawWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "OpenClawWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "OpenClawWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + + val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) + viewModel.canvas.attach(this) + webViewRef.value = this + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "openclawCanvasA2UIAction" + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt new file mode 100644 index 000000000000..9f7cf2211a16 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -0,0 +1,497 @@ +package ai.openclaw.android.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class ConnectInputMode { + SetupCode, + Manual, +} + +@Composable +fun ConnectTabScreen(viewModel: MainViewModel) { + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val manualHost by viewModel.manualHost.collectAsState() + val manualPort by viewModel.manualPort.collectAsState() + val manualTls by viewModel.manualTls.collectAsState() + val manualEnabled by viewModel.manualEnabled.collectAsState() + val gatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var advancedOpen by rememberSaveable { mutableStateOf(false) } + var inputMode by + remember(manualEnabled, manualHost, gatewayToken) { + mutableStateOf( + if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) { + ConnectInputMode.Manual + } else { + ConnectInputMode.SetupCode + }, + ) + } + var setupCode by rememberSaveable { mutableStateOf("") } + var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } + var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } + var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } + var passwordInput by rememberSaveable { mutableStateOf("") } + var validationText by rememberSaveable { mutableStateOf(null) } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + style = mobileCallout, + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) { + composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl } + } + + val activeEndpoint = + remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) { + when { + isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!! + inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set" + else -> manualResolvedEndpoint ?: "Not set" + } + } + + val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway" + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Gateway Connection", style = mobileTitle1, color = mobileText) + Text( + "One primary action. Open advanced controls only when needed.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = mobileText) + } + } + + Button( + onClick = { + if (isConnected) { + viewModel.disconnect() + validationText = null + return@Button + } + + val config = + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + + validationText = null + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (isConnected) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + onClick = { advancedOpen = !advancedOpen }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced controls", style = mobileHeadline, color = mobileText) + Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls", + tint = mobileTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MethodChip( + label = "Setup Code", + active = inputMode == ConnectInputMode.SetupCode, + onClick = { inputMode = ConnectInputMode.SetupCode }, + ) + MethodChip( + label = "Manual", + active = inputMode == ConnectInputMode.Manual, + onClick = { inputMode = ConnectInputMode.Manual }, + ) + } + + Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + + if (inputMode == ConnectInputMode.SetupCode) { + Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = { + setupCode = it + validationText = null + }, + placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + if (!setupResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = setupResolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip( + label = "Android Emulator", + onClick = { + manualHostInput = "10.0.2.2" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + QuickFillChip( + label = "Localhost", + onClick = { + manualHostInput = "127.0.0.1" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + } + + Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualHostInput, + onValueChange = { + manualHostInput = it + validationText = null + }, + placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualPortInput, + onValueChange = { + manualPortInput = it + validationText = null + }, + placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = mobileHeadline, color = mobileText) + Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary) + } + Switch( + checked = manualTlsInput, + onCheckedChange = { + manualTlsInput = it + validationText = null + }, + colors = + SwitchDefaults.colors( + checkedTrackColor = mobileAccent, + uncheckedTrackColor = mobileBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = { viewModel.setGatewayToken(it) }, + placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = manualResolvedEndpoint) + } + } + + HorizontalDivider(color = mobileBorder) + + Text( + "Debug snapshot: mode=${if (inputMode == ConnectInputMode.SetupCode) "setup" else "manual"}, manualEnabled=$manualEnabled, tokenLen=${gatewayToken.trim().length}", + style = mobileCaption1, + color = mobileTextSecondary, + ) + TextButton(onClick = { viewModel.logGatewayDebugSnapshot(source = "connect_tab") }) { + Text("Log gateway debug snapshot", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { + Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + } + } + } + + if (!validationText.isNullOrBlank()) { + Text(validationText!!, style = mobileCaption1, color = mobileWarning) + } + } +} + +@Composable +private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) mobileAccent else mobileSurface, + contentColor = if (active) Color.White else mobileText, + ), + border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold)) + } +} + +@Composable +private fun QuickFillChip(label: String, onClick: () -> Unit) { + Button( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccentSoft, + contentColor = mobileAccent, + ), + elevation = null, + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun CommandBlock(command: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A))) + Text( + text = command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = mobileCodeText, + ) + } + } +} + +@Composable +private fun EndpointPreview(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = mobileBorder) + Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText) + HorizontalDivider(color = mobileBorder) + } +} + +@Composable +private fun outlinedColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt new file mode 100644 index 000000000000..5036c6290d3f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -0,0 +1,115 @@ +package ai.openclaw.android.ui + +import android.util.Base64 +import androidx.core.net.toUri +import java.util.Locale +import org.json.JSONObject + +internal data class GatewayEndpointConfig( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +internal data class GatewaySetupCode( + val url: String, + val token: String?, + val password: String?, +) + +internal data class GatewayConnectConfig( + val host: String, + val port: Int, + val tls: Boolean, + val token: String, + val password: String, +) + +internal fun resolveGatewayConnectConfig( + useSetupCode: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + fallbackToken: String, + fallbackPassword: String, +): GatewayConnectConfig? { + if (useSetupCode) { + val setup = decodeGatewaySetupCode(setupCode) ?: return null + val parsed = parseGatewayEndpoint(setup.url) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = setup.token ?: fallbackToken.trim(), + password = setup.password ?: fallbackPassword.trim(), + ) + } + + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null + val parsed = parseGatewayEndpoint(manualUrl) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = fallbackToken.trim(), + password = fallbackPassword.trim(), + ) +} + +internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) + val obj = JSONObject(decoded) + val url = obj.optString("url").trim() + if (url.isEmpty()) return null + val token = obj.optString("token").trim().ifEmpty { null } + val password = obj.optString("password").trim().ifEmpty { null } + GatewaySetupCode(url = url, token = token, password = password) + } catch (_: Throwable) { + null + } +} + +internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt new file mode 100644 index 000000000000..eb4f95775e72 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt @@ -0,0 +1,106 @@ +package ai.openclaw.android.ui + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ai.openclaw.android.R + +internal val mobileBackgroundGradient = + Brush.verticalGradient( + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ), + ) + +internal val mobileSurface = Color(0xFFF6F7FA) +internal val mobileSurfaceStrong = Color(0xFFECEEF3) +internal val mobileBorder = Color(0xFFE5E7EC) +internal val mobileBorderStrong = Color(0xFFD6DAE2) +internal val mobileText = Color(0xFF17181C) +internal val mobileTextSecondary = Color(0xFF5D6472) +internal val mobileTextTertiary = Color(0xFF99A0AE) +internal val mobileAccent = Color(0xFF1D5DD8) +internal val mobileAccentSoft = Color(0xFFECF3FF) +internal val mobileSuccess = Color(0xFF2F8C5A) +internal val mobileSuccessSoft = Color(0xFFEEF9F3) +internal val mobileWarning = Color(0xFFC8841A) +internal val mobileWarningSoft = Color(0xFFFFF8EC) +internal val mobileDanger = Color(0xFFD04B4B) +internal val mobileDangerSoft = Color(0xFFFFF2F2) +internal val mobileCodeBg = Color(0xFF15171B) +internal val mobileCodeText = Color(0xFFE8EAEE) + +internal val mobileFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +internal val mobileTitle1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +internal val mobileTitle2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = (-0.3).sp, + ) + +internal val mobileHeadline = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +internal val mobileBody = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +internal val mobileCallout = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +internal val mobileCaption1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +internal val mobileCaption2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt new file mode 100644 index 000000000000..8c732d9c3603 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -0,0 +1,1120 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import ai.openclaw.android.LocationMode +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.R + +private enum class OnboardingStep(val index: Int, val label: String) { + Welcome(1, "Welcome"), + Gateway(2, "Gateway"), + Permissions(3, "Permissions"), + FinalCheck(4, "Connect"), +} + +private enum class GatewayInputMode { + SetupCode, + Manual, +} + +private val onboardingBackgroundGradient = + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ) +private val onboardingSurface = Color(0xFFF6F7FA) +private val onboardingBorder = Color(0xFFE5E7EC) +private val onboardingBorderStrong = Color(0xFFD6DAE2) +private val onboardingText = Color(0xFF17181C) +private val onboardingTextSecondary = Color(0xFF4D5563) +private val onboardingTextTertiary = Color(0xFF8A92A2) +private val onboardingAccent = Color(0xFF1D5DD8) +private val onboardingAccentSoft = Color(0xFFECF3FF) +private val onboardingSuccess = Color(0xFF2F8C5A) +private val onboardingWarning = Color(0xFFC8841A) +private val onboardingCommandBg = Color(0xFF15171B) +private val onboardingCommandBorder = Color(0xFF2B2E35) +private val onboardingCommandAccent = Color(0xFF3FC97A) +private val onboardingCommandText = Color(0xFFE8EAEE) + +private val onboardingFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +private val onboardingDisplayStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.8).sp, + ) + +private val onboardingTitle1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +private val onboardingHeadlineStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +private val onboardingBodyStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +private val onboardingCalloutStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +private val onboardingCaption1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +private val onboardingCaption2Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) + +@Composable +fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val serverName by viewModel.serverName.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val persistedGatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } + var setupCode by rememberSaveable { mutableStateOf("") } + var gatewayUrl by rememberSaveable { mutableStateOf("") } + var gatewayPassword by rememberSaveable { mutableStateOf("") } + var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) } + var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") } + var manualPort by rememberSaveable { mutableStateOf("18789") } + var manualTls by rememberSaveable { mutableStateOf(false) } + var gatewayError by rememberSaveable { mutableStateOf(null) } + var attemptedConnect by rememberSaveable { mutableStateOf(false) } + + var enableDiscovery by rememberSaveable { mutableStateOf(true) } + var enableNotifications by rememberSaveable { mutableStateOf(true) } + var enableMicrophone by rememberSaveable { mutableStateOf(false) } + var enableCamera by rememberSaveable { mutableStateOf(false) } + var enableSms by rememberSaveable { mutableStateOf(false) } + + val smsAvailable = + remember(context) { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + val selectedPermissions = + remember( + context, + enableDiscovery, + enableNotifications, + enableMicrophone, + enableCamera, + enableSms, + smsAvailable, + ) { + val requested = mutableListOf() + if (enableDiscovery) { + requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + } + if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS + if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO + if (enableCamera) requested += Manifest.permission.CAMERA + if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS + requested.filterNot { isPermissionGranted(context, it) } + } + + val enabledPermissionSummary = + remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) { + val enabled = mutableListOf() + if (enableDiscovery) enabled += "Gateway discovery" + if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications" + if (enableMicrophone) enabled += "Microphone" + if (enableCamera) enabled += "Camera" + if (smsAvailable && enableSms) enabled += "SMS" + if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + step = OnboardingStep.FinalCheck + } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Brush.verticalGradient(onboardingBackgroundGradient)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "FIRST RUN", + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), + color = onboardingAccent, + ) + Text( + "OpenClaw\nMobile Setup", + style = onboardingDisplayStyle.copy(lineHeight = 38.sp), + color = onboardingText, + ) + Text( + "Step ${step.index} of 4", + style = onboardingCaption1Style, + color = onboardingAccent, + ) + } + StepRailWrap(current = step) + + when (step) { + OnboardingStep.Welcome -> WelcomeStep() + OnboardingStep.Gateway -> + GatewayStep( + inputMode = gatewayInputMode, + setupCode = setupCode, + manualHost = manualHost, + manualPort = manualPort, + manualTls = manualTls, + gatewayToken = persistedGatewayToken, + gatewayPassword = gatewayPassword, + gatewayError = gatewayError, + onInputModeChange = { + gatewayInputMode = it + gatewayError = null + }, + onSetupCodeChange = { + setupCode = it + gatewayError = null + }, + onManualHostChange = { + manualHost = it + gatewayError = null + }, + onManualPortChange = { + manualPort = it + gatewayError = null + }, + onManualTlsChange = { manualTls = it }, + onTokenChange = viewModel::setGatewayToken, + onPasswordChange = { gatewayPassword = it }, + ) + OnboardingStep.Permissions -> + PermissionsStep( + enableDiscovery = enableDiscovery, + enableNotifications = enableNotifications, + enableMicrophone = enableMicrophone, + enableCamera = enableCamera, + enableSms = enableSms, + smsAvailable = smsAvailable, + context = context, + onDiscoveryChange = { enableDiscovery = it }, + onNotificationsChange = { enableNotifications = it }, + onMicrophoneChange = { enableMicrophone = it }, + onCameraChange = { enableCamera = it }, + onSmsChange = { enableSms = it }, + ) + OnboardingStep.FinalCheck -> + FinalStep( + parsedGateway = parseGatewayEndpoint(gatewayUrl), + statusText = statusText, + isConnected = isConnected, + serverName = serverName, + remoteAddress = remoteAddress, + attemptedConnect = attemptedConnect, + enabledPermissions = enabledPermissionSummary, + methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual", + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val backEnabled = step != OnboardingStep.Welcome + Surface( + modifier = Modifier.size(52.dp), + shape = RoundedCornerShape(14.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, if (backEnabled) onboardingBorderStrong else onboardingBorder), + ) { + IconButton( + onClick = { + step = + when (step) { + OnboardingStep.Welcome -> OnboardingStep.Welcome + OnboardingStep.Gateway -> OnboardingStep.Welcome + OnboardingStep.Permissions -> OnboardingStep.Gateway + OnboardingStep.FinalCheck -> OnboardingStep.Permissions + } + }, + enabled = backEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (backEnabled) onboardingTextSecondary else onboardingTextTertiary, + ) + } + } + + when (step) { + OnboardingStep.Welcome -> { + Button( + onClick = { step = OnboardingStep.Gateway }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Gateway -> { + Button( + onClick = { + if (gatewayInputMode == GatewayInputMode.SetupCode) { + val parsedSetup = decodeGatewaySetupCode(setupCode) + if (parsedSetup == null) { + gatewayError = "Invalid setup code." + return@Button + } + val parsedGateway = parseGatewayEndpoint(parsedSetup.url) + if (parsedGateway == null) { + gatewayError = "Setup code has invalid gateway URL." + return@Button + } + gatewayUrl = parsedSetup.url + parsedSetup.token?.let { viewModel.setGatewayToken(it) } + gatewayPassword = parsedSetup.password.orEmpty() + } else { + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) + val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) + if (parsedGateway == null) { + gatewayError = "Manual endpoint is invalid." + return@Button + } + gatewayUrl = parsedGateway.displayUrl + } + step = OnboardingStep.Permissions + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Permissions -> { + Button( + onClick = { + viewModel.setCameraEnabled(enableCamera) + viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off) + if (selectedPermissions.isEmpty()) { + step = OnboardingStep.FinalCheck + } else { + permissionLauncher.launch(selectedPermissions.toTypedArray()) + } + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.FinalCheck -> { + if (isConnected) { + Button( + onClick = { viewModel.setOnboardingCompleted(true) }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } else { + Button( + onClick = { + val parsed = parseGatewayEndpoint(gatewayUrl) + if (parsed == null) { + step = OnboardingStep.Gateway + gatewayError = "Invalid gateway URL." + return@Button + } + val token = persistedGatewayToken.trim() + val password = gatewayPassword.trim() + attemptedConnect = true + viewModel.setManualEnabled(true) + viewModel.setManualHost(parsed.host) + viewModel.setManualPort(parsed.port) + viewModel.setManualTls(parsed.tls) + if (token.isNotEmpty()) { + viewModel.setGatewayToken(token) + } + viewModel.setGatewayPassword(password) + viewModel.connectManual() + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + } + } + } + } + } +} + +@Composable +private fun StepRailWrap(current: OnboardingStep) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(color = onboardingBorder) + StepRail(current = current) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepRail(current: OnboardingStep) { + val steps = OnboardingStep.entries + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + steps.forEach { step -> + val complete = step.index < current.index + val active = step.index == current.index + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + color = + when { + complete -> onboardingSuccess + active -> onboardingAccent + else -> onboardingBorder + }, + shape = RoundedCornerShape(999.dp), + ), + ) + Text( + text = step.label, + style = onboardingCaption2Style.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) onboardingAccent else onboardingTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun WelcomeStep() { + StepShell(title = "What You Get") { + Bullet("Control the gateway and operator chat from one mobile surface.") + Bullet("Connect with setup code and recover pairing with CLI commands.") + Bullet("Enable only the permissions and capabilities you want.") + Bullet("Finish with a real connection check before entering the app.") + } +} + +@Composable +private fun GatewayStep( + inputMode: GatewayInputMode, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + gatewayToken: String, + gatewayPassword: String, + gatewayError: String?, + onInputModeChange: (GatewayInputMode) -> Unit, + onSetupCodeChange: (String) -> Unit, + onManualHostChange: (String) -> Unit, + onManualPortChange: (String) -> Unit, + onManualTlsChange: (Boolean) -> Unit, + onTokenChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, +) { + val resolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } } + + StepShell(title = "Gateway Connection") { + GuideBlock(title = "Get setup code + gateway URL") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + Text( + "`--json` prints `setupCode` and `gatewayUrl`.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + Text( + "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + } + GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange) + + if (inputMode == GatewayInputMode.SetupCode) { + Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = onSetupCodeChange, + placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + if (!resolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip(label = "Android Emulator", onClick = { + onManualHostChange("10.0.2.2") + onManualPortChange("18789") + onManualTlsChange(false) + }) + QuickFillChip(label = "Localhost", onClick = { + onManualHostChange("127.0.0.1") + onManualPortChange("18789") + onManualTlsChange(false) + }) + } + + Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualHost, + onValueChange = onManualHostChange, + placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualPort, + onValueChange = onManualPortChange, + placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText) + Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + } + Switch( + checked = manualTls, + onCheckedChange = onManualTlsChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = onTokenChange, + placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayPassword, + onValueChange = onPasswordChange, + placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = manualResolvedEndpoint) + } + } + + if (!gatewayError.isNullOrBlank()) { + Text(gatewayError, color = onboardingWarning, style = onboardingCaption1Style) + } + } +} + +@Composable +private fun GuideBlock( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(onboardingAccent.copy(alpha = 0.4f))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + content() + } + } +} + +@Composable +private fun GatewayModeToggle( + inputMode: GatewayInputMode, + onInputModeChange: (GatewayInputMode) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + GatewayModeChip( + label = "Setup Code", + active = inputMode == GatewayInputMode.SetupCode, + onClick = { onInputModeChange(GatewayInputMode.SetupCode) }, + modifier = Modifier.weight(1f), + ) + GatewayModeChip( + label = "Manual", + active = inputMode == GatewayInputMode.Manual, + onClick = { onInputModeChange(GatewayInputMode.Manual) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun GatewayModeChip( + label: String, + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) onboardingAccent else onboardingSurface, + contentColor = if (active) Color.White else onboardingText, + ), + border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong), + ) { + Text( + text = label, + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), + ) + } +} + +@Composable +private fun QuickFillChip( + label: String, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + colors = + ButtonDefaults.textButtonColors( + containerColor = onboardingAccentSoft, + contentColor = onboardingAccent, + ), + ) { + Text(label, style = onboardingCaption1Style.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun ResolvedEndpoint(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = onboardingBorder) + Text( + "RESOLVED ENDPOINT", + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.7.sp), + color = onboardingTextSecondary, + ) + Text( + endpoint, + style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace), + color = onboardingText, + ) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepShell( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + HorizontalDivider(color = onboardingBorder) + Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = onboardingTitle1Style, color = onboardingText) + content() + } + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun InlineDivider() { + HorizontalDivider(color = onboardingBorder) +} + +@Composable +private fun PermissionsStep( + enableDiscovery: Boolean, + enableNotifications: Boolean, + enableMicrophone: Boolean, + enableCamera: Boolean, + enableSms: Boolean, + smsAvailable: Boolean, + context: Context, + onDiscoveryChange: (Boolean) -> Unit, + onNotificationsChange: (Boolean) -> Unit, + onMicrophoneChange: (Boolean) -> Unit, + onCameraChange: (Boolean) -> Unit, + onSmsChange: (Boolean) -> Unit, +) { + val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + StepShell(title = "Permissions") { + Text( + "Enable only what you need now. You can change everything later in Settings.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + PermissionToggleRow( + title = "Gateway discovery", + subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)", + checked = enableDiscovery, + granted = isPermissionGranted(context, discoveryPermission), + onCheckedChange = onDiscoveryChange, + ) + InlineDivider() + if (Build.VERSION.SDK_INT >= 33) { + PermissionToggleRow( + title = "Notifications", + subtitle = "Foreground service + alerts", + checked = enableNotifications, + granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), + onCheckedChange = onNotificationsChange, + ) + InlineDivider() + } + PermissionToggleRow( + title = "Microphone", + subtitle = "Talk mode + voice features", + checked = enableMicrophone, + granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), + onCheckedChange = onMicrophoneChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Camera", + subtitle = "camera.snap and camera.clip", + checked = enableCamera, + granted = isPermissionGranted(context, Manifest.permission.CAMERA), + onCheckedChange = onCameraChange, + ) + if (smsAvailable) { + InlineDivider() + PermissionToggleRow( + title = "SMS", + subtitle = "Allow gateway-triggered SMS sending", + checked = enableSms, + granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), + onCheckedChange = onSmsChange, + ) + } + Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } +} + +@Composable +private fun PermissionToggleRow( + title: String, + subtitle: String, + checked: Boolean, + granted: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + Text( + if (granted) "Granted" else "Not granted", + style = onboardingCaption1Style, + color = if (granted) onboardingSuccess else onboardingTextSecondary, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } +} + +@Composable +private fun FinalStep( + parsedGateway: GatewayEndpointConfig?, + statusText: String, + isConnected: Boolean, + serverName: String?, + remoteAddress: String?, + attemptedConnect: Boolean, + enabledPermissions: String, + methodLabel: String, +) { + StepShell(title = "Review") { + SummaryField(label = "Method", value = methodLabel) + SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL") + SummaryField(label = "Enabled Permissions", value = enabledPermissions) + + if (!attemptedConnect) { + Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } else { + Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) + if (isConnected) { + Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) + } else { + GuideBlock(title = "Pairing Required") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw nodes pending") + CommandBlock("openclaw nodes approve ") + Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + } + } + } +} + +@Composable +private fun SummaryField(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + label, + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = onboardingTextSecondary, + ) + Text(value, style = onboardingHeadlineStyle, color = onboardingText) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun CommandBlock(command: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(onboardingCommandBg, RoundedCornerShape(12.dp)) + .border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)), + ) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) + Text( + command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = onboardingCalloutStyle, + fontFamily = FontFamily.Monospace, + color = onboardingCommandText, + ) + } +} + +@Composable +private fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier + .padding(top = 7.dp) + .size(8.dp) + .background(onboardingAccentSoft, CircleShape), + ) + Box( + modifier = + Modifier + .padding(top = 9.dp) + .size(4.dp) + .background(onboardingAccent, CircleShape), + ) + Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f)) + } +} + +private fun isPermissionGranted(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt new file mode 100644 index 000000000000..b68c06ff2ff9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,333 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class HomeTab( + val label: String, + val icon: ImageVector, +) { + Connect(label = "Connect", icon = Icons.Default.CheckCircle), + Chat(label = "Chat", icon = Icons.Default.ChatBubble), + Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver), + Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare), + Settings(label = "Settings", icon = Icons.Default.Settings), +} + +private enum class StatusVisual { + Connected, + Connecting, + Warning, + Error, + Offline, +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + val imeVisible = WindowInsets.isImeVisible + + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + val statusVisual = + remember(statusText, isConnected) { + val lower = statusText.lowercase() + when { + isConnected -> StatusVisual.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting + lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning + lower.contains("error") || lower.contains("failed") -> StatusVisual.Error + else -> StatusVisual.Offline + } + } + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + if (!imeVisible) { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + } + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.") + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val isConnected by viewModel.isConnected.collectAsState() + val isNodeConnected by viewModel.isNodeConnected.collectAsState() + val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() + val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() + val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() + val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() + val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true + val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) + val restoreCtaText = + when { + canvasRehydratePending -> "Restore requested. Waiting for agent…" + !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! + else -> "Canvas reset. Tap to restore dashboard." + } + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + + if (showRestoreCta) { + Surface( + onClick = { + if (canvasRehydratePending) return@Surface + viewModel.requestCanvasRehydrate(source = "screen_tab_cta") + }, + modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), + shape = RoundedCornerShape(12.dp), + color = mobileSurface.copy(alpha = 0.9f), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 4.dp, + ) { + Text( + text = restoreCtaText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontWeight = FontWeight.Medium), + color = mobileText, + ) + } + } + } +} + +@Composable +private fun TopStatusBar( + statusText: String, + statusVisual: StatusVisual, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + + val (chipBg, chipDot, chipText, chipBorder) = + when (statusVisual) { + StatusVisual.Connected -> + listOf( + mobileSuccessSoft, + mobileSuccess, + mobileSuccess, + Color(0xFFCFEBD8), + ) + StatusVisual.Connecting -> + listOf( + mobileAccentSoft, + mobileAccent, + mobileAccent, + Color(0xFFD5E2FA), + ) + StatusVisual.Warning -> + listOf( + mobileWarningSoft, + mobileWarning, + mobileWarning, + Color(0xFFEED8B8), + ) + StatusVisual.Error -> + listOf( + mobileDangerSoft, + mobileDanger, + mobileDanger, + Color(0xFFF3C8C8), + ) + StatusVisual.Offline -> + listOf( + mobileSurface, + mobileTextTertiary, + mobileTextSecondary, + mobileBorder, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets), + color = Color.Transparent, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OpenClaw", + style = mobileTitle2, + color = mobileText, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = chipBg, + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.padding(top = 1.dp), + color = chipDot, + shape = RoundedCornerShape(999.dp), + ) { + Box(modifier = Modifier.padding(4.dp)) + } + Text( + text = statusText.trim().ifEmpty { "Offline" }, + style = mobileCaption1, + color = chipText, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun BottomTabBar( + activeTab: HomeTab, + onSelect: (HomeTab) -> Unit, +) { + val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(safeInsets), + ) { + Surface( + modifier = Modifier.fillMaxWidth().offset(y = (-4).dp), + color = Color.White.copy(alpha = 0.97f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeTab.entries.forEach { tab -> + val active = tab == activeTab + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f).heightIn(min = 58.dp), + shape = RoundedCornerShape(16.dp), + color = if (active) mobileAccentSoft else Color.Transparent, + border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = if (active) mobileAccent else mobileTextTertiary, + ) + Text( + text = tab.label, + color = if (active) mobileAccent else mobileTextSecondary, + style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium), + ) + } + } + } + } + } + } +} + +@Composable +private fun ComingSoonTabScreen( + label: String, + title: String, + description: String, +) { + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text(title, style = mobileTitle1, color = mobileText) + Text(description, style = mobileBody, color = mobileTextSecondary) + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt index af0cfe628ac0..e50a03cc5bf7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -1,429 +1,20 @@ package ai.openclaw.android.ui -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import ai.openclaw.android.CameraHudKind import ai.openclaw.android.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } + val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } + if (!onboardingCompleted) { + OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + return } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "OpenClawWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("OpenClawWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "OpenClawWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "OpenClawWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "openclawCanvasA2UIAction" - } + PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt index bb04c30108ce..2a6219578c70 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -10,9 +10,12 @@ import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn @@ -30,20 +34,19 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -56,14 +59,16 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import ai.openclaw.android.BuildConfig import ai.openclaw.android.LocationMode import ai.openclaw.android.MainViewModel -import ai.openclaw.android.NodeForegroundService import ai.openclaw.android.VoiceWakeMode import ai.openclaw.android.WakeWords @@ -80,22 +85,10 @@ fun SettingsSheet(viewModel: MainViewModel) { val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() val isConnected by viewModel.isConnected.collectAsState() - val manualEnabled by viewModel.manualEnabled.collectAsState() - val manualHost by viewModel.manualHost.collectAsState() - val manualPort by viewModel.manualPort.collectAsState() - val manualTls by viewModel.manualTls.collectAsState() - val gatewayToken by viewModel.gatewayToken.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() - val gateways by viewModel.gateways.collectAsState() - val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() - val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } - val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = @@ -114,31 +107,14 @@ fun SettingsSheet(viewModel: MainViewModel) { versionName } } - - if (pendingTrust != null) { - val prompt = pendingTrust!! - AlertDialog( - onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, - title = { Text("Trust this gateway?") }, - text = { - Text( - "First-time TLS connection.\n\n" + - "Verify this SHA-256 fingerprint out-of-band before trusting:\n" + - prompt.fingerprintSha256, - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { - Text("Trust and connect") - } - }, - dismissButton = { - TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { - Text("Cancel") - } - }, + val listItemColors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = mobileText, + supportingColor = mobileTextSecondary, + trailingIconColor = mobileTextSecondary, + leadingIconColor = mobileTextSecondary, ) - } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } val commitWakeWords = { @@ -268,209 +244,79 @@ fun SettingsSheet(viewModel: MainViewModel) { } } - val visibleGateways = - if (isConnected && remoteAddress != null) { - gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } - } else { - gateways - } - - val gatewayDiscoveryFooterText = - if (visibleGateways.isEmpty()) { - discoveryStatusText - } else if (isConnected) { - "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" - } else { - "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" - } - - LazyColumn( - state = listState, + Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .fillMaxSize() + .background(mobileBackgroundGradient), ) { - // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. - item { Text("Node", style = MaterialTheme.typography.titleSmall) } - item { - OutlinedTextField( - value = displayName, - onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - ) - } - item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } - - item { HorizontalDivider() } - - // Gateway - item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } - item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } - if (serverName != null) { - item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } - } - if (remoteAddress != null) { - item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } - } - item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - ) { - Text("Disconnect") - } - } - } - - item { HorizontalDivider() } - - if (!isConnected || visibleGateways.isNotEmpty()) { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { item { - Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, - ) - } - if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } - } else { - items(items = visibleGateways, key = { it.stableId }) { gateway -> - val detailLines = - buildList { - add("IP: ${gateway.host}:${gateway.port}") - gateway.lanHost?.let { add("LAN: $it") } - gateway.tailnetDns?.let { add("Tailnet: $it") } - if (gateway.gatewayPort != null || gateway.canvasPort != null) { - val gw = (gateway.gatewayPort ?: gateway.port).toString() - val canvas = gateway.canvasPort?.toString() ?: "—" - add("Ports: gw $gw · canvas $canvas") - } - } - ListItem( - headlineContent = { Text(gateway.name) }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - detailLines.forEach { line -> - Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - trailingContent = { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connect(gateway) - }, - ) { - Text("Connect") - } - }, + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "SETTINGS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Device Configuration", style = mobileTitle2, color = mobileText) + Text( + "Manage capabilities, permissions, and diagnostics.", + style = mobileCallout, + color = mobileTextSecondary, ) } } + item { HorizontalDivider(color = mobileBorder) } + + // Order parity: Node → Voice → Camera → Messaging → Location → Screen. item { Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "NODE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - } - - item { HorizontalDivider() } - item { - ListItem( - headlineContent = { Text("Advanced") }, - supportingContent = { Text("Manual gateway connection") }, - trailingContent = { - Icon( - imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (advancedExpanded) "Collapse" else "Expand", - ) - }, - modifier = - Modifier.clickable { - setAdvancedExpanded(!advancedExpanded) - }, + OutlinedTextField( + value = displayName, + onValueChange = viewModel::setDisplayName, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, + modifier = Modifier.fillMaxWidth(), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - item { - AnimatedVisibility(visible = advancedExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Use Manual Gateway") }, - supportingContent = { Text("Use this when discovery is blocked.") }, - trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, - ) + item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } + item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } + item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = gatewayToken, - onValueChange = viewModel::setGatewayToken, - label = { Text("Gateway Token") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - singleLine = true, - ) - ListItem( - headlineContent = { Text("Require TLS") }, - supportingContent = { Text("Pin the gateway certificate on first connect.") }, - trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), - ) - - val hostOk = manualHost.trim().isNotEmpty() - val portOk = manualPort in 1..65535 - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled && hostOk && portOk, - ) { - Text("Connect (Manual)") - } - } - } - } - - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { val enabled = voiceWakeMode != VoiceWakeMode.Off ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Voice Wake", style = mobileHeadline) }, + supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) }, trailingContent = { Switch( checked = enabled, @@ -493,8 +339,10 @@ fun SettingsSheet(viewModel: MainViewModel) { AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { ListItem( - headlineContent = { Text("Foreground Only") }, - supportingContent = { Text("Listens only while OpenClaw is open.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Foreground Only", style = mobileHeadline) }, + supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) }, trailingContent = { RadioButton( selected = voiceWakeMode == VoiceWakeMode.Foreground, @@ -509,8 +357,10 @@ fun SettingsSheet(viewModel: MainViewModel) { }, ) ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) }, trailingContent = { RadioButton( selected = voiceWakeMode == VoiceWakeMode.Always, @@ -531,7 +381,7 @@ fun SettingsSheet(viewModel: MainViewModel) { OutlinedTextField( value = wakeWordsText, onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, + label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> if (focusState.isFocused) { @@ -550,9 +400,19 @@ fun SettingsSheet(viewModel: MainViewModel) { focusManager.clearFocus() }, ), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } + item { + Button( + onClick = viewModel::resetWakeWordsDefaults, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + } item { Text( if (isConnected) { @@ -560,32 +420,48 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "Connect to a gateway to sync wake words globally." }, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Camera - item { Text("Camera", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "CAMERA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Allow Camera") }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Allow Camera", style = mobileHeadline) }, + supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, ) } item { Text( "Tip: grant Microphone permission for video clips with audio.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Messaging - item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "MESSAGING", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { val buttonLabel = when { @@ -594,7 +470,9 @@ fun SettingsSheet(viewModel: MainViewModel) { else -> "Grant" } ListItem( - headlineContent = { Text("SMS Permission") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("SMS Permission", style = mobileHeadline) }, supportingContent = { Text( if (smsPermissionAvailable) { @@ -602,6 +480,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "SMS requires a device with telephony hardware." }, + style = mobileCallout, ) }, trailingContent = { @@ -615,91 +494,125 @@ fun SettingsSheet(viewModel: MainViewModel) { } }, enabled = smsPermissionAvailable, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), ) { - Text(buttonLabel) + Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) } }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Location - item { Text("Location", style = MaterialTheme.typography.titleSmall) } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Off") }, - supportingContent = { Text("Disable location sharing.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Off, - onClick = { viewModel.setLocationMode(LocationMode.Off) }, - ) - }, - ) - ListItem( - headlineContent = { Text("While Using") }, - supportingContent = { Text("Only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.WhileUsing, - onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Allow background location (requires system permission).") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Always, - onClick = { requestLocationPermissions(LocationMode.Always) }, - ) - }, + item { + Text( + "LOCATION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - } - item { - ListItem( - headlineContent = { Text("Precise Location") }, - supportingContent = { Text("Use precise GPS when available.") }, - trailingContent = { - Switch( - checked = locationPreciseEnabled, - onCheckedChange = ::setPreciseLocationChecked, - enabled = locationMode != LocationMode.Off, + item { + Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Off", style = mobileHeadline) }, + supportingContent = { Text("Disable location sharing.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, ) - }, - ) - } + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("While Using", style = mobileHeadline) }, + supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Precise Location", style = mobileHeadline) }, + supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + } item { Text( "Always may require Android Settings to allow background location.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCallout, + color = mobileTextSecondary, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Screen - item { Text("Screen", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "SCREEN", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Prevent Sleep") }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, ) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } // Debug - item { Text("Debug", style = MaterialTheme.typography.titleSmall) } + item { + Text( + "DEBUG", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { ListItem( - headlineContent = { Text("Debug Canvas Status") }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, + supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, trailingContent = { Switch( checked = canvasDebugStatusEnabled, @@ -709,10 +622,47 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } - item { Spacer(modifier = Modifier.height(20.dp)) } + item { Spacer(modifier = Modifier.height(24.dp)) } + } } } +@Composable +private fun settingsTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +private fun settingsRowModifier() = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) + .background(Color.White, RoundedCornerShape(14.dp)) + +@Composable +private fun settingsPrimaryButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + +@Composable +private fun settingsDangerButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileDanger, + contentColor = Color.White, + disabledContainerColor = mobileDanger.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + private fun openAppSettings(context: Context) { val intent = Intent( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt deleted file mode 100644 index d608fc38a7bb..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun StatusPill( - gateway: GatewayState, - voiceEnabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - activity: StatusActivity? = null, -) { - Surface( - onClick = onClick, - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = overlayContainerColor(), - tonalElevation = 3.dp, - shadowElevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(9.dp), - shape = CircleShape, - color = gateway.color, - ) {} - - Text( - text = gateway.title, - style = MaterialTheme.typography.labelLarge, - ) - } - - VerticalDivider( - modifier = Modifier.height(14.dp).alpha(0.35f), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (activity != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = activity.icon, - contentDescription = activity.contentDescription, - tint = activity.tint ?: overlayIconColor(), - modifier = Modifier.size(18.dp), - ) - Text( - text = activity.title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - ) - } - } else { - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -data class StatusActivity( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val tint: Color? = null, -) - -enum class GatewayState(val title: String, val color: Color) { - Connected("Connected", Color(0xFF2ECC71)), - Connecting("Connecting…", Color(0xFFF1C40F)), - Error("Error", Color(0xFFE74C3C)), - Disconnected("Offline", Color(0xFF9E9E9E)), -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt index 07ba769697df..7f71995906bd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt @@ -1,31 +1,35 @@ package ai.openclaw.android.ui.chat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,178 +41,196 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileSurface +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileTextTertiary @Composable fun ChatComposer( - sessionKey: String, - sessions: List, - mainSessionKey: String, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, - errorText: String?, attachments: List, onPickImages: () -> Unit, onRemoveAttachment: (id: String) -> Unit, onSetThinkingLevel: (level: String) -> Unit, - onSelectSession: (sessionKey: String) -> Unit, onRefresh: () -> Unit, onAbort: () -> Unit, onSend: (text: String) -> Unit, ) { var input by rememberSaveable { mutableStateOf("") } var showThinkingMenu by remember { mutableStateOf(false) } - var showSessionMenu by remember { mutableStateOf(false) } - - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = friendlySessionName( - sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey - ) val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + val sendBusy = pendingRunCount > 0 - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - FilledTonalButton( - onClick = { showSessionMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = "Thinking: ${thinkingLabel(thinkingLevel)}", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) } + } - DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { - for (entry in sessionOptions) { - DropdownMenuItem( - text = { Text(friendlySessionName(entry.displayName ?: entry.key)) }, - onClick = { - onSelectSession(entry.key) - showSessionMenu = false - }, - trailingIcon = { - if (entry.key == sessionKey) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) - } - } + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } } + } - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1) - } + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + onClick = onPickImages, + ) + } - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } + HorizontalDivider(color = mobileBorder) - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") - } - } + Text( + text = "MESSAGE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), + color = mobileTextSecondary, + ) - if (attachments.isNotEmpty()) { - AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) - } + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(92.dp), + placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + minLines = 2, + maxLines = 5, + textStyle = mobileBodyStyle().copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = chatTextFieldColors(), + ) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message OpenClaw…") }, - minLines = 2, - maxLines = 6, + if (!healthOk) { + Text( + text = "Gateway is offline. Connect first in the Connect tab.", + style = mobileCallout, + color = ai.openclaw.android.ui.mobileWarning, ) + } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + onClick = onRefresh, + ) - if (pendingRunCount > 0) { - FilledTonalIconButton( - onClick = onAbort, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = Color(0x33E74C3C), - contentColor = Color(0xFFE74C3C), - ), - ) { - Icon(Icons.Default.Stop, contentDescription = "Abort") - } - } else { - FilledTonalIconButton(onClick = { - val text = input - input = "" - onSend(text) - }, enabled = canSend) { - Icon(Icons.Default.ArrowUpward, contentDescription = "Send") - } - } + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + onClick = onAbort, + ) } - if (!errorText.isNullOrBlank()) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - ) + Button( + onClick = { + val text = input + input = "" + onSend(text) + }, + enabled = canSend, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileBorderStrong, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong), + ) { + if (sendBusy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text("Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) } } } } @Composable -private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, +private fun SecondaryActionButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.height(44.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileTextSecondary, + disabledContainerColor = Color.White, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, mobileBorderStrong), + contentPadding = ButtonDefaults.ContentPadding, ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionLabel, style = MaterialTheme.typography.labelSmall) - Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = label, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = if (enabled) mobileTextSecondary else mobileTextTertiary, + ) } } @@ -220,14 +242,14 @@ private fun ThinkingMenuItem( onDismiss: () -> Unit, ) { DropdownMenuItem( - text = { Text(thinkingLabel(value)) }, + text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) }, onClick = { onSet(value) onDismiss() }, trailingIcon = { if (value == current.trim().lowercase()) { - Text("✓") + Text("✓", style = mobileCallout, color = mobileAccent) } else { Spacer(modifier = Modifier.width(10.dp)) } @@ -266,20 +288,55 @@ private fun AttachmentsStrip( private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { Surface( shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) - FilledTonalIconButton( + Text( + text = fileName, + style = mobileCaption1, + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( onClick = onRemove, - modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(999.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), ) { - Text("×") + Text( + text = "×", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold), + color = mobileTextSecondary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) } } } } + +@Composable +private fun chatTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +@Composable +private fun mobileBodyStyle() = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = ai.openclaw.android.ui.mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt index 77dba2275a41..e121212529a9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -3,12 +3,21 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,167 +25,534 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileTextSecondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.commonmark.Extension +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.ext.task.list.items.TaskListItemMarker +import org.commonmark.ext.task.list.items.TaskListItemsExtension +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.Heading +import org.commonmark.node.HardLineBreak +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image as MarkdownImage +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text as MarkdownTextNode +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +private const val LIST_INDENT_DP = 14 +private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$") + +private val markdownParser: Parser by lazy { + val extensions: List = + listOf( + AutolinkExtension.create(), + StrikethroughExtension.create(), + TablesExtension.create(), + TaskListItemsExtension.create(), + ) + Parser.builder() + .extensions(extensions) + .build() +} @Composable fun ChatMarkdown(text: String, textColor: Color) { - val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + val document = remember(text) { markdownParser.parse(text) as Document } + val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (b in blocks) { - when (b) { - is ChatMarkdownBlock.Text -> { - val trimmed = b.text.trimEnd() - if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, - color = textColor, - ) + RenderMarkdownBlocks( + start = document.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = 0, + ) + } +} + +@Composable +private fun RenderMarkdownBlocks( + start: Node?, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var node = start + while (node != null) { + val current = node + when (current) { + is Paragraph -> { + RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) + } + is Heading -> { + val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } + Text( + text = headingText, + style = headingStyle(current.level), + color = textColor, + ) + } + is FencedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null }) + } + } + is IndentedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = null) } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) + } + is BlockQuote -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(mobileTextSecondary.copy(alpha = 0.35f)), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RenderMarkdownBlocks( + start = current.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) } } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + is BulletList -> { + RenderBulletList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is OrderedList -> { + RenderOrderedList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is TableBlock -> { + RenderTableBlock( + table = current, + textColor = textColor, + inlineStyles = inlineStyles, + ) + } + is ThematicBreak -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(mobileTextSecondary.copy(alpha = 0.25f)), + ) + } + is HtmlBlock -> { + val literal = current.literal.orEmpty().trim() + if (literal.isNotEmpty()) { + Text( + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) } } } + node = current.next } } -private sealed interface ChatMarkdownBlock { - data class Text(val text: String) : ChatMarkdownBlock - data class Code(val code: String, val language: String?) : ChatMarkdownBlock - data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock -} +@Composable +private fun RenderParagraph( + paragraph: Paragraph, + textColor: Color, + inlineStyles: InlineStyles, +) { + val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) } + if (standaloneImage != null) { + InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType) + return + } -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() + val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) } + if (annotated.text.trimEnd().isEmpty()) { + return + } - val out = ArrayList() - var idx = 0 - while (idx < raw.length) { - val fenceStart = raw.indexOf("```", startIndex = idx) - if (fenceStart < 0) { - out.addAll(splitInlineImages(raw.substring(idx))) - break - } + Text( + text = annotated, + style = mobileCallout, + color = textColor, + ) +} - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) +@Composable +private fun RenderBulletList( + list: BulletList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "•", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + item = item.next } + } +} - val langLineStart = fenceStart + 3 - val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } - val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } - - val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd - val fenceEnd = raw.indexOf("```", startIndex = codeStart) - if (fenceEnd < 0) { - out.addAll(splitInlineImages(raw.substring(fenceStart))) - break +@Composable +private fun RenderOrderedList( + list: OrderedList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var index = list.markerStartNumber ?: 1 + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "$index.", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + index += 1 + } + item = item.next } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) + } +} - idx = fenceEnd + 3 +@Composable +private fun RenderListItem( + item: ListItem, + markerText: String, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var contentStart = item.firstChild + var marker = markerText + val task = contentStart as? TaskListItemMarker + if (task != null) { + marker = if (task.isChecked) "☑" else "☐" + contentStart = task.next } - return out + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = marker, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + modifier = Modifier.width(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + RenderMarkdownBlocks( + start = contentStart, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth + 1, + ) + } + } } -private fun splitInlineImages(text: String): List { - if (text.isEmpty()) return emptyList() - val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") - val out = ArrayList() +@Composable +private fun RenderTableBlock( + table: TableBlock, + textColor: Color, + inlineStyles: InlineStyles, +) { + val rows = remember(table) { buildTableRows(table, inlineStyles) } + if (rows.isEmpty()) return + + val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1) + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)), + ) { + for (row in rows) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + for (index in 0 until maxCols) { + val cell = row.cells.getOrNull(index) ?: AnnotatedString("") + Text( + text = cell, + style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout, + color = textColor, + modifier = Modifier + .border(1.dp, mobileTextSecondary.copy(alpha = 0.22f)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .width(160.dp), + ) + } + } + } + } +} - var idx = 0 - while (idx < text.length) { - val m = regex.find(text, startIndex = idx) ?: break - val start = m.range.first - val end = m.range.last + 1 - if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) +private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var child = table.firstChild + while (child != null) { + when (child) { + is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles)) + is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles)) + is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles)) + } + child = child.next + } + return rows +} - val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") - val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() - if (b64.isNotEmpty()) { - out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) +private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var row = section.firstChild + while (row != null) { + if (row is TableRow) { + rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles)) } - idx = end + row = row.next } + return rows +} - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out +private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow { + val cells = mutableListOf() + var cellNode = row.firstChild + while (cellNode != null) { + if (cellNode is TableCell) { + cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles)) + } + cellNode = cellNode.next + } + return TableRenderRow(isHeader = isHeader, cells = cells) } -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") +private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString { + return buildAnnotatedString { + appendInlineNode( + node = start, + inlineCodeBg = inlineStyles.inlineCodeBg, + inlineCodeColor = inlineStyles.inlineCodeColor, + ) + } +} - val out = buildAnnotatedString { - var i = 0 - while (i < text.length) { - if (text.startsWith("**", startIndex = i)) { - val end = text.indexOf("**", startIndex = i + 2) - if (end > i + 2) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - continue +private fun AnnotatedString.Builder.appendInlineNode( + node: Node?, + inlineCodeBg: Color, + inlineCodeColor: Color, +) { + var current = node + while (current != null) { + when (current) { + is MarkdownTextNode -> append(current.literal) + is SoftLineBreak -> append('\n') + is HardLineBreak -> append('\n') + is Code -> { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + color = inlineCodeColor, + ), + ) { + append(current.literal) } } - - if (text[i] == '`') { - val end = text.indexOf('`', startIndex = i + 1) - if (end > i + 1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = inlineCodeBg, - ), - ) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is Emphasis -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } - - if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { - val end = text.indexOf('*', startIndex = i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue + is StrongEmphasis -> { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Strikethrough -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Link -> { + withStyle( + SpanStyle( + color = mobileAccent, + textDecoration = TextDecoration.Underline, + ), + ) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) } } + is MarkdownImage -> { + val alt = buildPlainText(current.firstChild) + if (alt.isNotBlank()) { + append(alt) + } else { + append("image") + } + } + is HtmlInline -> { + if (!current.literal.isNullOrBlank()) { + append(current.literal) + } + } + else -> { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + current = current.next + } +} - append(text[i]) - i += 1 +private fun buildPlainText(start: Node?): String { + val sb = StringBuilder() + var node = start + while (node != null) { + when (node) { + is MarkdownTextNode -> sb.append(node.literal) + is SoftLineBreak, is HardLineBreak -> sb.append('\n') + else -> sb.append(buildPlainText(node.firstChild)) } + node = node.next + } + return sb.toString() +} + +private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? { + val only = paragraph.firstChild as? MarkdownImage ?: return null + if (only.next != null) return null + return parseDataImageDestination(only.destination) +} + +private fun parseDataImageDestination(destination: String?): ParsedDataImage? { + val raw = destination?.trim().orEmpty() + if (raw.isEmpty()) return null + val match = dataImageRegex.matchEntire(raw) ?: return null + val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png" + val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (base64.isEmpty()) return null + return ParsedDataImage(mimeType = "image/$subtype", base64 = base64) +} + +private fun headingStyle(level: Int): TextStyle { + return when (level.coerceIn(1, 6)) { + 1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + 2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + 3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold) + 4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold) + else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold) } - return out } +private data class InlineStyles( + val inlineCodeBg: Color, + val inlineCodeColor: Color, +) + +private data class TableRenderRow( + val isHeader: Boolean, + val cells: List, +) + +private data class ParsedDataImage( + val mimeType: String, + val base64: String, +) + @Composable private fun InlineBase64Image(base64: String, mimeType: String?) { var image by remember(base64) { mutableStateOf(null) } @@ -208,8 +584,8 @@ private fun InlineBase64Image(base64: String, mimeType: String?) { Text( text = "Image unavailable", modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = mobileCaption1, + color = mobileTextSecondary, ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt index bcec19a5fa25..889de006cb45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt @@ -2,26 +2,26 @@ package ai.openclaw.android.ui.chat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary @Composable fun ChatMessageListCard( @@ -29,6 +29,7 @@ fun ChatMessageListCard( pendingRunCount: Int, pendingToolCalls: List, streamingAssistantText: String?, + healthOk: Boolean, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -38,73 +39,70 @@ fun ChatMessageListCard( listState.animateScrollToItem(index = 0) } - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), - ) { - // With reverseLayout = true, index 0 renders at the BOTTOM. - // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp), + ) { + // With reverseLayout = true, index 0 renders at the BOTTOM. + // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - val stream = streamingAssistantText?.trim() - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) - } - } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) } + } - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) } + } - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> + ChatMessageBubble(message = messages[messages.size - 1 - idx]) } } + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk) + } } } @Composable -private fun EmptyChatHint(modifier: Modifier = Modifier) { - Row( - modifier = modifier.alpha(0.7f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder), ) { - Icon( - imageVector = Icons.Default.ArrowCircleDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Message OpenClaw…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("No messages yet", style = mobileHeadline, color = mobileText) + Text( + text = + if (healthOk) { + "Send the first prompt to start this session." + } else { + "Connect gateway first, then return to chat." + }, + style = mobileCallout, + color = mobileTextSecondary, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt index bf2943275517..3f4250c3dbbb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt @@ -2,7 +2,8 @@ package ai.openclaw.android.ui.chat import android.graphics.BitmapFactory import android.util.Base64 -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,55 +24,93 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.foundation.Image +import androidx.compose.ui.unit.sp import ai.openclaw.android.chat.ChatMessage import ai.openclaw.android.chat.ChatMessageContent import ai.openclaw.android.chat.ChatPendingToolCall import ai.openclaw.android.tools.ToolDisplayRegistry +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalContext + +private data class ChatBubbleStyle( + val alignEnd: Boolean, + val containerColor: Color, + val borderColor: Color, + val roleColor: Color, +) @Composable fun ChatMessageBubble(message: ChatMessage) { - val isUser = message.role.lowercase() == "user" + val role = message.role.trim().lowercase(Locale.US) + val style = bubbleStyle(role) - // Filter to only displayable content parts (text with content, or base64 images) - val displayableContent = message.content.filter { part -> - when (part.type) { - "text" -> !part.text.isNullOrBlank() - else -> part.base64 != null + // Filter to only displayable content parts (text with content, or base64 images). + val displayableContent = + message.content.filter { part -> + when (part.type) { + "text" -> !part.text.isNullOrBlank() + else -> part.base64 != null + } } - } - // Skip rendering entirely if no displayable content if (displayableContent.isEmpty()) return + ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) { + ChatMessageBody(content = displayableContent, textColor = mobileText) + } +} + +@Composable +private fun ChatBubbleContainer( + style: ChatBubbleStyle, + roleLabel: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start, ) { Surface( - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, style.borderColor), + color = style.containerColor, tonalElevation = 0.dp, shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), + modifier = Modifier.fillMaxWidth(0.90f), ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), + Column( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = displayableContent, textColor = textColor) + Text( + text = roleLabel, + style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = style.roleColor, + ) + content() } } } @@ -80,7 +118,7 @@ fun ChatMessageBubble(message: ChatMessage) { @Composable private fun ChatMessageBody(content: List, textColor: Color) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (part in content) { when (part.type) { "text" -> { @@ -98,19 +136,16 @@ private fun ChatMessageBody(content: List, textColor: Color) @Composable fun ChatTypingIndicatorBubble() { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = roleLabel("assistant"), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DotPulse() - Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + DotPulse(color = mobileTextSecondary) + Text("Thinking...", style = mobileCallout, color = mobileTextSecondary) } } } @@ -122,38 +157,37 @@ fun ChatPendingToolsBubble(toolCalls: List) { remember(toolCalls, context) { toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) - for (display in displays.take(6)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = "TOOLS", + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + for (display in displays.take(6)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "${display.emoji} ${display.label}", + style = mobileCallout, + color = mobileTextSecondary, + fontFamily = FontFamily.Monospace, + ) + display.detailLine?.let { detail -> Text( - "${display.emoji} ${display.label}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + detail, + style = mobileCaption1, + color = mobileTextSecondary, fontFamily = FontFamily.Monospace, ) - display.detailLine?.let { detail -> - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - } } } - if (toolCalls.size > 6) { - Text( - "… +${toolCalls.size - 6} more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + if (toolCalls.size > 6) { + Text( + text = "... +${toolCalls.size - 6} more", + style = mobileCaption1, + color = mobileTextSecondary, + ) } } } @@ -161,37 +195,47 @@ fun ChatPendingToolsBubble(toolCalls: List) { @Composable fun ChatStreamingAssistantBubble(text: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) - } - } + ChatBubbleContainer( + style = bubbleStyle("assistant").copy(borderColor = mobileAccent), + roleLabel = "ASSISTANT · LIVE", + ) { + ChatMarkdown(text = text, textColor = mobileText) } } -@Composable -private fun bubbleBackground(isUser: Boolean): Brush { - return if (isUser) { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), - ) - } else { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), - ) +private fun bubbleStyle(role: String): ChatBubbleStyle { + return when (role) { + "user" -> + ChatBubbleStyle( + alignEnd = true, + containerColor = mobileAccentSoft, + borderColor = mobileAccent, + roleColor = mobileAccent, + ) + + "system" -> + ChatBubbleStyle( + alignEnd = false, + containerColor = mobileWarningSoft, + borderColor = mobileWarning.copy(alpha = 0.45f), + roleColor = mobileWarning, + ) + + else -> + ChatBubbleStyle( + alignEnd = false, + containerColor = Color.White, + borderColor = mobileBorderStrong, + roleColor = mobileTextSecondary, + ) } } -@Composable -private fun textColorOverBubble(isUser: Boolean): Color { - return if (isUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface +private fun roleLabel(role: String): String { + return when (role) { + "user" -> "USER" + "system" -> "SYSTEM" + else -> "ASSISTANT" } } @@ -216,48 +260,64 @@ private fun ChatBase64Image(base64: String, mimeType: String?) { } if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "attachment", - contentScale = ContentScale.Fit, + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, mobileBorder), + color = Color.White, modifier = Modifier.fillMaxWidth(), - ) + ) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } } else if (failed) { - Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary) } } @Composable -private fun DotPulse() { +private fun DotPulse(color: Color) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f) - PulseDot(alpha = 0.62f) - PulseDot(alpha = 0.90f) + PulseDot(alpha = 0.38f, color = color) + PulseDot(alpha = 0.62f, color = color) + PulseDot(alpha = 0.90f, color = color) } } @Composable -private fun PulseDot(alpha: Float) { +private fun PulseDot(alpha: Float, color: Color) { Surface( modifier = Modifier.size(6.dp).alpha(alpha), shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = color, ) {} } @Composable fun ChatCodeBlock(code: String, language: String?) { Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = RoundedCornerShape(8.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = code.trimEnd(), - modifier = Modifier.padding(10.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!language.isNullOrBlank()) { + Text( + text = language.uppercase(Locale.US), + style = mobileCaption2.copy(letterSpacing = 0.4.sp), + color = mobileTextSecondary, + ) + } + Text( + text = code.trimEnd(), + fontFamily = FontFamily.Monospace, + style = mobileCallout, + color = mobileCodeText, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt deleted file mode 100644 index 56b5cfb1faf6..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry - -@Composable -fun ChatSessionsDialog( - currentSessionKey: String, - sessions: List, - onDismiss: () -> Unit, - onRefresh: () -> Unit, - onSelect: (sessionKey: String) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = {}, - title = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Sessions", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onRefresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - }, - text = { - if (sessions.isEmpty()) { - Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sessions, key = { it.key }) { entry -> - SessionRow( - entry = entry, - isCurrent = entry.key == currentSessionKey, - onClick = { onSelect(entry.key) }, - ) - } - } - } - }, - ) -} - -@Composable -private fun SessionRow( - entry: ChatSessionEntry, - isCurrent: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = - if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { - Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt index effee6708e0d..d1c2743ef041 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt @@ -5,10 +5,18 @@ import android.net.Uri import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -17,10 +25,28 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.openclaw.android.MainViewModel +import ai.openclaw.android.chat.ChatSessionEntry import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileSuccess +import ai.openclaw.android.ui.mobileSuccessSoft +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -72,30 +98,38 @@ fun ChatSheetContent(viewModel: MainViewModel) { modifier = Modifier .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + ChatThreadSelector( + sessionKey = sessionKey, + sessions = sessions, + mainSessionKey = mainSessionKey, + healthOk = healthOk, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + ) + + if (!errorText.isNullOrBlank()) { + ChatErrorRail(errorText = errorText!!) + } + ChatMessageListCard( messages = messages, pendingRunCount = pendingRunCount, pendingToolCalls = pendingToolCalls, streamingAssistantText = streamingAssistantText, + healthOk = healthOk, modifier = Modifier.weight(1f, fill = true), ) ChatComposer( - sessionKey = sessionKey, - sessions = sessions, - mainSessionKey = mainSessionKey, healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount, - errorText = errorText, attachments = attachments, onPickImages = { pickImages.launch("image/*") }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onSelectSession = { key -> viewModel.switchChatSession(key) }, onRefresh = { viewModel.refreshChat() viewModel.refreshChatSessions(limit = 200) @@ -118,6 +152,104 @@ fun ChatSheetContent(viewModel: MainViewModel) { } } +@Composable +private fun ChatThreadSelector( + sessionKey: String, + sessions: List, + mainSessionKey: String, + healthOk: Boolean, + onSelectSession: (String) -> Unit, +) { + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val currentSessionLabel = + friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = "SESSION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = mobileTextSecondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = currentSessionLabel, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ChatConnectionPill(healthOk = healthOk) + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ChatConnectionPill(healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, + border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), + ) { + Text( + text = if (healthOk) "Connected" else "Offline", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = if (healthOk) mobileSuccess else mobileWarning, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun ChatErrorRail(errorText: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = androidx.compose.ui.graphics.Color.White, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "CHAT ERROR", + style = mobileCaption2.copy(letterSpacing = 0.6.sp), + color = mobileDanger, + ) + Text(text = errorText, style = mobileCallout, color = mobileText) + } + } +} + data class PendingImageAttachment( val id: String, val fileName: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt index 04d18b622602..f00481982a33 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -54,6 +54,47 @@ class TalkModeManager( private const val tag = "TalkMode" private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" + private const val defaultTalkProvider = "elevenlabs" + + internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, + ) + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } + + internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + val providers = + rawProviders?.entries?.mapNotNull { (key, value) -> + val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null + val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null + providerId to providerConfig + }?.toMap().orEmpty() + val providerId = + normalizeTalkProviderId(rawProvider) + ?: providers.keys.sorted().firstOrNull() + ?: defaultTalkProvider + return TalkProviderConfigSelection( + provider = providerId, + config = providers[providerId] ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } } private val mainHandler = Handler(Looper.getMainLooper()) @@ -344,12 +385,12 @@ class TalkModeManager( val key = sessionKey.trim() if (key.isEmpty()) return if (chatSubscribedSessionKey == key) return - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + if (sent) { chatSubscribedSessionKey = key Log.d(tag, "chat.subscribe ok sessionKey=$key") - } catch (err: Throwable) { - Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") + } else { + Log.w(tag, "chat.subscribe failed sessionKey=$key") } } @@ -818,30 +859,49 @@ class TalkModeManager( val root = json.parseToJsonElement(res).asObjectOrNull() val config = root?.get("config").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull() + val selection = selectTalkProviderConfig(talk) + val activeProvider = selection?.provider ?: defaultTalkProvider + val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val aliases = - talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } }?.toMap().orEmpty() - val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val outputFormat = + activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() if (!isCanonicalMainSessionKey(mainSessionKey)) { mainSessionKey = mainKey } - defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + defaultVoiceId = + if (activeProvider == defaultTalkProvider) { + voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + } else { + voice + } voiceAliases = aliases if (!voiceOverrideActive) currentVoiceId = defaultVoiceId defaultModelId = model ?: defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } + apiKey = + if (activeProvider == defaultTalkProvider) { + key ?: envKey?.takeIf { it.isNotEmpty() } + } else { + null + } if (interrupt != null) interruptOnSpeech = interrupt + if (activeProvider != defaultTalkProvider) { + Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback") + } else if (selection?.normalizedPayload == true) { + Log.d(tag, "talk config provider=elevenlabs") + } } catch (_: Throwable) { defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback diff --git a/apps/android/app/src/main/res/font/manrope_400_regular.ttf b/apps/android/app/src/main/res/font/manrope_400_regular.ttf new file mode 100644 index 000000000000..9a108f1cee9d Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_400_regular.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_500_medium.ttf b/apps/android/app/src/main/res/font/manrope_500_medium.ttf new file mode 100644 index 000000000000..c6d28def6d56 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_500_medium.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_600_semibold.ttf b/apps/android/app/src/main/res/font/manrope_600_semibold.ttf new file mode 100644 index 000000000000..46a13d619899 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_600_semibold.ttf differ diff --git a/apps/android/app/src/main/res/font/manrope_700_bold.ttf b/apps/android/app/src/main/res/font/manrope_700_bold.ttf new file mode 100644 index 000000000000..62a618393905 Binary files /dev/null and b/apps/android/app/src/main/res/font/manrope_700_bold.ttf differ diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt new file mode 100644 index 000000000000..5daa62080d70 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkModeConfigParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun prefersNormalizedTalkProviderPayload() { + val talk = + json.parseToJsonElement( + """ + { + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == true) + assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + } + + @Test + fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + val talk = + json.parseToJsonElement( + """ + { + "voiceId": "voice-legacy", + "apiKey": "legacy-key" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == false) + assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content) + } +} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index f79902d5615f..bea7b46b2c21 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - id("com.android.application") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("com.android.application") version "9.0.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false } diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties index 5f84d966ee84..4134274afddb 100644 --- a/apps/android/gradle.properties +++ b/apps/android/gradle.properties @@ -3,3 +3,12 @@ org.gradle.warning.mode=none android.useAndroidX=true android.nonTransitiveRClass=true android.enableR8.fullMode=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.newDsl=true diff --git a/apps/android/gradle/gradle-daemon-jvm.properties b/apps/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000000..6c1139ec06ae --- /dev/null +++ b/apps/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/apps/android/style.md b/apps/android/style.md new file mode 100644 index 000000000000..f2b892ac6ff8 --- /dev/null +++ b/apps/android/style.md @@ -0,0 +1,113 @@ +# OpenClaw Android UI Style Guide + +Scope: all native Android UI in `apps/android` (Jetpack Compose). +Goal: one coherent visual system across onboarding, settings, and future screens. + +## 1. Design Direction + +- Clean, quiet surfaces. +- Strong readability first. +- One clear primary action per screen state. +- Progressive disclosure for advanced controls. +- Deterministic flows: validate early, fail clearly. + +## 2. Style Baseline + +The onboarding flow defines the current visual baseline. +New screens should match that language unless there is a strong product reason not to. + +Baseline traits: + +- Light neutral background with subtle depth. +- Clear blue accent for active/primary states. +- Strong border hierarchy for structure. +- Medium/semibold typography (no thin text). +- Divider-and-spacing layout over heavy card nesting. + +## 3. Core Tokens + +Use these as shared design tokens for new Compose UI. + +- Background gradient: `#FFFFFF`, `#F7F8FA`, `#EFF1F5` +- Surface: `#F6F7FA` +- Border: `#E5E7EC` +- Border strong: `#D6DAE2` +- Text primary: `#17181C` +- Text secondary: `#4D5563` +- Text tertiary: `#8A92A2` +- Accent primary: `#1D5DD8` +- Accent soft: `#ECF3FF` +- Success: `#2F8C5A` +- Warning: `#C8841A` + +Rule: do not introduce random per-screen colors when an existing token fits. + +## 4. Typography + +Primary type family: Manrope (`400/500/600/700`). + +Recommended scale: + +- Display: `34sp / 40sp`, bold +- Section title: `24sp / 30sp`, semibold +- Headline/action: `16sp / 22sp`, semibold +- Body: `15sp / 22sp`, medium +- Callout/helper: `14sp / 20sp`, medium +- Caption 1: `12sp / 16sp`, medium +- Caption 2: `11sp / 14sp`, medium + +Use monospace only for commands, setup codes, endpoint-like values. +Hard rule: avoid ultra-thin weights on light backgrounds. + +## 5. Layout And Spacing + +- Respect safe drawing insets. +- Keep content hierarchy mostly via spacing + dividers. +- Prefer vertical rhythm from `8/10/12/14/20dp`. +- Use pinned bottom actions for multi-step or high-importance flows. +- Avoid unnecessary container nesting. + +## 6. Buttons And Actions + +- Primary action: filled accent button, visually dominant. +- Secondary action: lower emphasis (outlined/text/surface button). +- Icon-only buttons must remain legible and >=44dp target. +- Back buttons in action rows use rounded-square shape, not circular by default. + +## 7. Inputs And Forms + +- Always show explicit label or clear context title. +- Keep helper copy short and actionable. +- Validate before advancing steps. +- Prefer immediate inline errors over hidden failure states. +- Keep optional advanced fields explicit (`Manual`, `Advanced`, etc.). + +## 8. Progress And Multi-Step Flows + +- Use clear step count (`Step X of N`). +- Use labeled progress rail/indicator when steps are discrete. +- Keep navigation predictable: back/next behavior should never surprise. + +## 9. Accessibility + +- Minimum practical touch target: `44dp`. +- Do not rely on color alone for status. +- Preserve high contrast for all text tiers. +- Add meaningful `contentDescription` for icon-only controls. + +## 10. Architecture Rules + +- Durable UI state in `MainViewModel`. +- Composables: state in, callbacks out. +- No business/network logic in composables. +- Keep side effects explicit (`LaunchedEffect`, activity result APIs). + +## 11. Source Of Truth + +- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt` +- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` +- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` + +If style and implementation diverge, update both in the same change. diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 000000000000..eeed54c46526 --- /dev/null +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +import Darwin + +/// Shared device and platform info for Settings, gateway node payloads, and device status. +enum DeviceInfoHelper { + /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + let name = switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPadOS" + case .phone: + "iOS" + default: + "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. + static func platformStringForDisplay() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Device family for display: "iPad", "iPhone", or "iOS". + static func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPad" + case .phone: + "iPhone" + default: + "iOS" + } + } + + /// Machine model identifier from uname (e.g. "iPhone17,1"). + static func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "unknown" : trimmed + } + + /// App marketing version only, e.g. "2026.2.0" or "dev". + static func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + /// App build string, e.g. "123" or "". + static func appBuild() -> String { + let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. + static func openClawVersionString() -> String { + let version = appVersion() + let build = appBuild() + if build.isEmpty || build == version { + return version + } + return "\(version) (\(build))" + } +} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift index fed2716b5b8a..a80a98101fae 100644 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ b/apps/ios/Sources/Device/DeviceStatusService.swift @@ -26,12 +26,12 @@ final class DeviceStatusService: DeviceStatusServicing { func info() -> OpenClawDeviceInfoPayload { let device = UIDevice.current - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + let appVersion = DeviceInfoHelper.appVersion() + let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild()) let locale = Locale.preferredLanguages.first ?? Locale.current.identifier return OpenClawDeviceInfoPayload( deviceName: device.name, - modelIdentifier: Self.modelIdentifier(), + modelIdentifier: DeviceInfoHelper.modelIdentifier(), systemName: device.systemName, systemVersion: device.systemVersion, appVersion: appVersion, @@ -75,13 +75,8 @@ final class DeviceStatusService: DeviceStatusServicing { return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) } - private static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed + /// Fallback for payloads that require a non-empty build (e.g. "0"). + private static func fallbackAppBuild(_ build: String) -> String { + build.isEmpty ? "0" : build } } diff --git a/apps/ios/Sources/Device/NetworkStatusService.swift b/apps/ios/Sources/Device/NetworkStatusService.swift index 7d92d1cc1ca1..bc27eb19791f 100644 --- a/apps/ios/Sources/Device/NetworkStatusService.swift +++ b/apps/ios/Sources/Device/NetworkStatusService.swift @@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable { func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload { await withCheckedContinuation { cont in let monitor = NWPathMonitor() - let queue = DispatchQueue(label: "bot.molt.ios.network-status") + let queue = DispatchQueue(label: "ai.openclaw.ios.network-status") let state = NetworkStatusState() monitor.pathUpdateHandler = { path in diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 2b7f94ba4532..a770fcb2c6f8 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -921,44 +921,6 @@ final class GatewayConnectionController { private static func motionAvailable() -> Bool { CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() } - - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - let name = switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } } #if DEBUG @@ -980,19 +942,19 @@ extension GatewayConnectionController { } func _test_platformString() -> String { - self.platformString() + DeviceInfoHelper.platformString() } func _test_deviceFamily() -> String { - self.deviceFamily() + DeviceInfoHelper.deviceFamily() } func _test_modelIdentifier() -> String { - self.modelIdentifier() + DeviceInfoHelper.modelIdentifier() } func _test_appVersion() -> String { - self.appVersion() + DeviceInfoHelper.appVersion() } func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index ce1ba4bf2cb6..04bb220d5f36 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -104,7 +104,7 @@ final class GatewayDiscoveryModel { } self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)")) + browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)")) } } diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 3ff57ad2e674..49db9bb1bfc6 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -25,7 +25,7 @@ enum GatewaySettingsStore { private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" - private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey" + private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." static func bootstrapPersistence() { self.ensureStableInstanceID() @@ -145,25 +145,26 @@ enum GatewaySettingsStore { case discovered } - static func loadTalkElevenLabsApiKey() -> String? { + static func loadTalkProviderApiKey(provider: String) -> String? { + guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let value = KeychainStore.loadString( service: self.talkService, - account: self.talkElevenLabsApiKeyAccount)? + account: account)? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { return value } return nil } - static func saveTalkElevenLabsApiKey(_ apiKey: String?) { + static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) { + guard let providerId = self.normalizedTalkProviderID(provider) else { return } + let account = self.talkProviderApiKeyAccount(providerId: providerId) let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.delete(service: self.talkService, account: account) return } - _ = KeychainStore.saveString( - trimmed, - service: self.talkService, - account: self.talkElevenLabsApiKeyAccount) + _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) } static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { @@ -278,6 +279,15 @@ enum GatewaySettingsStore { "gateway-password.\(instanceId)" } + private static func talkProviderApiKeyAccount(providerId: String) -> String { + self.talkProviderApiKeyAccountPrefix + providerId + } + + private static func normalizedTalkProviderID(_ provider: String) -> String? { + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + private static func ensureStableInstanceID() { let defaults = UserDefaults.standard diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c34fccb5052d..bcb8c251a02a 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.23 + 2026.2.25 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260223 + 20260225 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift new file mode 100644 index 000000000000..08ef81e0cced --- /dev/null +++ b/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift @@ -0,0 +1,103 @@ +import Foundation +import OpenClawKit + +extension NodeAppModel { + static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { + var normalized = params + normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.promptId = self.trimmedOrNil(params.promptId) + normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.kind = self.trimmedOrNil(params.kind) + normalized.details = self.trimmedOrNil(params.details) + normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) + normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) + + let normalizedActions = self.normalizeWatchActions( + params.actions, + kind: normalized.kind, + promptId: normalized.promptId) + normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions + return normalized + } + + static func normalizeWatchActions( + _ actions: [OpenClawWatchAction]?, + kind: String?, + promptId: String?) -> [OpenClawWatchAction] + { + let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction( + id: id, + label: label, + style: self.trimmedOrNil(action.style)) + } + if !provided.isEmpty { + return Array(provided.prefix(4)) + } + + // Only auto-insert quick actions when this is a prompt/decision flow. + guard promptId?.isEmpty == false else { + return [] + } + + let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if normalizedKind.contains("approval") || normalizedKind.contains("approve") { + return [ + OpenClawWatchAction(id: "approve", label: "Approve"), + OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + return [ + OpenClawWatchAction(id: "done", label: "Done"), + OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + static func normalizedWatchRisk( + _ risk: OpenClawWatchRisk?, + priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? + { + if let risk { return risk } + switch priority { + case .passive: + return .low + case .active: + return .medium + case .timeSensitive: + return .high + case nil: + return nil + } + } + + static func normalizedWatchPriority( + _ priority: OpenClawNotificationPriority?, + risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? + { + if let priority { return priority } + switch risk { + case .low: + return .passive + case .medium: + return .active + case .high: + return .timeSensitive + case nil: + return nil + } + } + + static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index fc5e6097b18a..d763a3b908f9 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1490,8 +1490,9 @@ private extension NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case OpenClawWatchCommand.notify.rawValue: let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedParams = Self.normalizeWatchNotifyParams(params) + let title = normalizedParams.title + let body = normalizedParams.body if title.isEmpty && body.isEmpty { return BridgeInvokeResponse( id: req.id, @@ -1503,13 +1504,13 @@ private extension NodeAppModel { do { let result = try await self.watchMessagingService.sendNotification( id: req.id, - params: params) + params: normalizedParams) if result.queuedForDelivery || !result.deliveredImmediately { let invokeID = req.id Task { @MainActor in await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( invokeID: invokeID, - params: params, + params: normalizedParams, sendResult: result) } } diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 335e09fd986c..0dc0c4cac26f 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -182,8 +182,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc actionLabel: actionLabel, sessionKey: sessionKey) default: + break + } + + guard response.actionIdentifier.hasPrefix(WatchPromptNotificationBridge.actionIdentifierPrefix) else { + return nil + } + let indexString = String( + response.actionIdentifier.dropFirst(WatchPromptNotificationBridge.actionIdentifierPrefix.count)) + guard let actionIndex = Int(indexString), actionIndex >= 0 else { return nil } + let actionIdKey = WatchPromptNotificationBridge.actionIDKey(index: actionIndex) + let actionLabelKey = WatchPromptNotificationBridge.actionLabelKey(index: actionIndex) + let actionId = (userInfo[actionIdKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { + return nil + } + let actionLabel = userInfo[actionLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) } private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { @@ -243,6 +265,9 @@ enum WatchPromptNotificationBridge { static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" static let actionPrimaryIdentifier = "openclaw.watch.action.primary" static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let actionIdentifierPrefix = "openclaw.watch.action." + static let actionIDKeyPrefix = "openclaw.watch.action.id." + static let actionLabelKeyPrefix = "openclaw.watch.action.label." static let categoryPrefix = "openclaw.watch.prompt.category." @MainActor @@ -264,16 +289,15 @@ enum WatchPromptNotificationBridge { guard !id.isEmpty, !label.isEmpty else { return nil } return OpenClawWatchAction(id: id, label: label, style: action.style) } - let primaryAction = normalizedActions.first - let secondaryAction = normalizedActions.dropFirst().first + let displayedActions = Array(normalizedActions.prefix(4)) let center = UNUserNotificationCenter.current() var categoryIdentifier = "" - if let primaryAction { + if !displayedActions.isEmpty { let categoryID = "\(self.categoryPrefix)\(invokeID)" let category = UNNotificationCategory( identifier: categoryID, - actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), + actions: self.categoryActions(displayedActions), intentIdentifiers: [], options: []) await self.upsertNotificationCategory(category, center: center) @@ -289,13 +313,16 @@ enum WatchPromptNotificationBridge { if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { userInfo[self.sessionKeyKey] = sessionKey } - if let primaryAction { - userInfo[self.actionPrimaryIDKey] = primaryAction.id - userInfo[self.actionPrimaryLabelKey] = primaryAction.label - } - if let secondaryAction { - userInfo[self.actionSecondaryIDKey] = secondaryAction.id - userInfo[self.actionSecondaryLabelKey] = secondaryAction.label + for (index, action) in displayedActions.enumerated() { + userInfo[self.actionIDKey(index: index)] = action.id + userInfo[self.actionLabelKey(index: index)] = action.label + if index == 0 { + userInfo[self.actionPrimaryIDKey] = action.id + userInfo[self.actionPrimaryLabelKey] = action.label + } else if index == 1 { + userInfo[self.actionSecondaryIDKey] = action.id + userInfo[self.actionSecondaryLabelKey] = action.label + } } let content = UNMutableNotificationContent() @@ -324,24 +351,30 @@ enum WatchPromptNotificationBridge { try? await self.addNotificationRequest(request, center: center) } - private static func categoryActions( - primaryAction: OpenClawWatchAction, - secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] - { - var actions: [UNNotificationAction] = [ - UNNotificationAction( - identifier: self.actionPrimaryIdentifier, - title: primaryAction.label, - options: self.notificationActionOptions(style: primaryAction.style)) - ] - if let secondaryAction { - actions.append( - UNNotificationAction( - identifier: self.actionSecondaryIdentifier, - title: secondaryAction.label, - options: self.notificationActionOptions(style: secondaryAction.style))) - } - return actions + static func actionIDKey(index: Int) -> String { + "\(self.actionIDKeyPrefix)\(index)" + } + + static func actionLabelKey(index: Int) -> String { + "\(self.actionLabelKeyPrefix)\(index)" + } + + private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { + actions.enumerated().map { index, action in + let identifier: String + switch index { + case 0: + identifier = self.actionPrimaryIdentifier + case 1: + identifier = self.actionSecondaryIdentifier + default: + identifier = "\(self.actionIdentifierPrefix)\(index)" + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: self.notificationActionOptions(style: action.style)) + } } private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index 11052f235432..c353d86f22d7 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable { outPath: outPath) let state = CaptureState() - let recordQueue = DispatchQueue(label: "bot.molt.screenrecord") + let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord") try await self.startCapture(state: state, config: config, recordQueue: recordQueue) try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 024a4cbf42b1..3ff2ed465c31 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -374,9 +374,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - LabeledContent("Device", value: self.deviceFamily()) - LabeledContent("Platform", value: self.platformString()) - LabeledContent("OpenClaw", value: self.openClawVersionString()) + LabeledContent("Device", value: DeviceInfoHelper.deviceFamily()) + LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay()) + LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString()) } } } @@ -584,32 +584,6 @@ struct SettingsTab: View { return trimmed.isEmpty ? "Not connected" : trimmed } - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func openClawVersionString() -> String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedBuild.isEmpty || trimmedBuild == version { - return version - } - return "\(version) (\(trimmedBuild))" - } - private func featureToggle( _ title: String, isOn: Binding, diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 8f208c66d505..0f8a7e6461b6 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -16,6 +16,7 @@ import Speech final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false @@ -94,7 +95,7 @@ final class TalkModeManager: NSObject { private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? private var incrementalSpeechPrefetchMonitorTask: Task? - private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") + private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode") init(allowSimulatorCapture: Bool = false) { self.allowSimulatorCapture = allowSimulatorCapture @@ -1885,6 +1886,38 @@ extension TalkModeManager { return trimmed } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: Any] + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? { + guard let talk else { return nil } + let rawProvider = talk["provider"] as? String + let rawProviders = talk["providers"] as? [String: Any] + guard rawProvider != nil || rawProviders != nil else { return nil } + let providers = rawProviders ?? [:] + let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let config = entry.value as? [String: Any] + else { return } + acc[providerID] = config + } + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:]) + } + func reloadConfig() async { guard let gateway else { return } do { @@ -1892,8 +1925,16 @@ extension TalkModeManager { guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] - self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = talk?["voiceAliases"] as? [String: Any] { + let selection = Self.selectTalkProviderConfig(talk) + if talk != nil, selection == nil { + GatewayDiagnostics.log( + "talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers") + } + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config + self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { var resolved: [String: String] = [:] for (key, value) in aliases { guard let id = value as? String else { continue } @@ -1909,22 +1950,28 @@ extension TalkModeManager { if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId } - let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } - self.defaultOutputFormat = (talk?["outputFormat"] as? String)? + self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) - let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey()) + let localApiKey = Self.normalizedTalkApiKey( + GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) if rawConfigApiKey == Self.redactedConfigSentinel { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") } else { self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey } + if activeProvider != Self.defaultTalkProvider { + self.apiKey = nil + GatewayDiagnostics.log( + "talk provider '\(activeProvider)' not yet supported on iOS; using system voice fallback") + } self.gatewayTalkDefaultVoiceId = self.defaultVoiceId self.gatewayTalkDefaultModelId = self.defaultModelId self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) @@ -1932,6 +1979,9 @@ extension TalkModeManager { if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } + if selection != nil { + GatewayDiagnostics.log("talk config provider=\(activeProvider)") + } } catch { self.defaultModelId = Self.defaultModelIdFallback if !self.modelOverrideActive { diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 5b1ba7d70e63..514ca7326736 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -4,6 +4,9 @@ Sources/Gateway/GatewayDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift Sources/Gateway/KeychainStore.swift Sources/Camera/CameraController.swift +Sources/Device/DeviceInfoHelper.swift +Sources/Device/DeviceStatusService.swift +Sources/Device/NetworkStatusService.swift Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/OpenClawApp.swift diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index b82ae7161687..3c1b25bce077 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -1,5 +1,6 @@ import Foundation import Network +import OpenClawKit import Testing @testable import OpenClaw diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 7e67ab84a972..0bac40152361 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -9,9 +9,11 @@ private struct KeychainEntry: Hashable { private let gatewayService = "ai.openclaw.gateway" private let nodeService = "ai.openclaw.node" +private let talkService = "ai.openclaw.talk" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") +private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -196,4 +198,17 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { let loaded = GatewaySettingsStore.loadLastGatewayConnection() #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) } + + @Test func talkProviderApiKey_genericRoundTrip() { + let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry]) + defer { restoreKeychain(keychainSnapshot) } + + _ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account) + + GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key") + + GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) + } } diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index a3420e273216..c273b1923d13 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.23 + 2026.2.25 CFBundleVersion - 20260223 + 20260225 diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift index 827be250ed7f..e56f4aa35b5a 100644 --- a/apps/ios/Tests/KeychainStoreTests.swift +++ b/apps/ios/Tests/KeychainStoreTests.swift @@ -4,7 +4,7 @@ import Testing @Suite struct KeychainStoreTests { @Test func saveLoadUpdateDeleteRoundTrip() { - let service = "bot.molt.tests.\(UUID().uuidString)" + let service = "ai.openclaw.tests.\(UUID().uuidString)" let account = "value" #expect(KeychainStore.delete(service: service, account: account)) diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 24bc4ba06391..dbeee118a4a4 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -302,6 +302,79 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(watchService.lastSent == nil) } + @Test @MainActor func handleInvokeWatchNotifyAddsDefaultActionsForPrompt() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Task", + body: "Action needed", + priority: .passive, + promptId: "prompt-123") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-default-actions", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.risk == .low) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) + } + + @Test @MainActor func handleInvokeWatchNotifyAddsApprovalDefaults() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Approval", + body: "Allow command?", + promptId: "prompt-approval", + kind: "approval") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-approval-defaults", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["approve", "decline", "open_phone", "escalate"]) + #expect(watchService.lastSent?.params.actions?[1].style == "destructive") + } + + @Test @MainActor func handleInvokeWatchNotifyDerivesPriorityFromRiskAndCapsActions() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Urgent", + body: "Check now", + risk: .high, + actions: [ + OpenClawWatchAction(id: "a1", label: "A1"), + OpenClawWatchAction(id: "a2", label: "A2"), + OpenClawWatchAction(id: "a3", label: "A3"), + OpenClawWatchAction(id: "a4", label: "A4"), + OpenClawWatchAction(id: "a5", label: "A5"), + ]) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-derive-priority", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.priority == .timeSensitive) + #expect(watchService.lastSent?.params.risk == .high) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["a1", "a2", "a3", "a4"]) + } + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { let watchService = MockWatchMessagingService() watchService.sendError = NSError( diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..fd6b535f8a3b --- /dev/null +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import OpenClaw + +@MainActor +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + "voiceId": "voice-legacy", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.config["voiceId"] as? String == "voice-normalized") + } + + @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: Any] = [ + "voiceId": "voice-legacy", + "apiKey": "legacy-key", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection == nil) + } +} diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index adaa3fc29fb6..8dbb75a8c262 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("bot.molt.ios") +app_identifier("ai.openclaw.ios") # Auth is expected via App Store Connect API key. # Provide either: diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 1028876e5101..a4d5928d8206 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -210,6 +210,9 @@ targets: OpenClawTests: type: bundle.unit-test platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig sources: - path: Tests dependencies: @@ -219,6 +222,9 @@ targets: - sdk: AppIntents.framework settings: base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b1..89bbefc5b025 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift index 57164ebb892d..6340dee2ca52 100644 --- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift +++ b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift @@ -17,9 +17,14 @@ enum AgentWorkspace { AgentWorkspace.userFilename, AgentWorkspace.bootstrapFilename, ] - enum BootstrapSafety: Equatable { - case safe - case unsafe (reason: String) + struct BootstrapSafety: Equatable { + let unsafeReason: String? + + static let safe = Self(unsafeReason: nil) + + static func blocked(_ reason: String) -> Self { + Self(unsafeReason: reason) + } } static func displayPath(for url: URL) -> String { @@ -71,9 +76,7 @@ enum AgentWorkspace { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return .safe } - if !isDir.boolValue { - return .unsafe (reason: "Workspace path points to a file.") - } + if !isDir.boolValue { return .blocked("Workspace path points to a file.") } let agentsURL = self.agentsURL(workspaceURL: workspaceURL) if fm.fileExists(atPath: agentsURL.path) { return .safe @@ -82,9 +85,9 @@ enum AgentWorkspace { let entries = try self.workspaceEntries(workspaceURL: workspaceURL) return entries.isEmpty ? .safe - : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.") } catch { - return .unsafe (reason: "Couldn't inspect the workspace folder.") + return .blocked("Couldn't inspect the workspace folder.") } } diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index e9ca6c353597..ef4917e7768f 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -356,6 +356,70 @@ final class AppState { return trimmed } + private static func updateGatewayString( + _ dictionary: inout [String: Any], + key: String, + value: String?) -> Bool + { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard dictionary[key] != nil else { return false } + dictionary.removeValue(forKey: key) + return true + } + if (dictionary[key] as? String) != trimmed { + dictionary[key] = trimmed + return true + } + return false + } + + private static func updatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + { + var remote = current + var changed = false + + switch transport { + case .direct: + changed = Self.updateGatewayString( + &remote, + key: "transport", + value: RemoteTransport.direct.rawValue) || changed + + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { + changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed + } + + case .ssh: + changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed + changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed + } + + return (remote, changed) + } + private func startConfigWatcher() { let configUrl = OpenClawConfigFile.url() self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in @@ -470,69 +534,16 @@ final class AppState { } if connectionMode == .remote { - var remote = gateway["remote"] as? [String: Any] ?? [:] - var remoteChanged = false - - if remoteTransport == .direct { - let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedUrl.isEmpty { - if remote["url"] != nil { - remote.removeValue(forKey: "url") - remoteChanged = true - } - } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { - if (remote["url"] as? String) != normalizedUrl { - remote["url"] = normalizedUrl - remoteChanged = true - } - } - if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { - remote["transport"] = RemoteTransport.direct.rawValue - remoteChanged = true - } - } else { - if remote["transport"] != nil { - remote.removeValue(forKey: "transport") - remoteChanged = true - } - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl - remoteChanged = true - } - } - - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget - remoteChanged = true - } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity - remoteChanged = true - } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true - } - } - - if remoteChanged { - gateway["remote"] = remote + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity) + if updated.changed { + gateway["remote"] = updated.remote changed = true } } diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift index abbddb245887..6c01628144b0 100644 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -53,6 +53,15 @@ final class AudioInputDeviceObserver { return output } + /// Returns true when the system default input device exists and is alive with input channels. + /// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs + /// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic + /// is disconnected. + static func hasUsableDefaultInputDevice() -> Bool { + guard let uid = self.defaultInputDeviceUID() else { return false } + return self.aliveInputDeviceUIDs().contains(uid) + } + static func defaultInputDeviceSummary() -> String { let systemObject = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index c17f64e30e73..cacfac2f0684 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -246,15 +246,17 @@ enum CommandResolver { return ssh } - let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) + let root = self.projectRoot() + if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { + return [openclawPath, subcommand] + extraArgs + } + if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { + return [openclawPath, subcommand] + extraArgs + } + let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) switch runtimeResult { case let .success(runtime): - let root = self.projectRoot() - if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { - return [openclawPath, subcommand] + extraArgs - } - if let entry = self.gatewayEntrypoint(in: root) { return self.makeRuntimeCommand( runtime: runtime, @@ -262,19 +264,21 @@ enum CommandResolver { subcommand: subcommand, extraArgs: extraArgs) } - if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { - // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. - return [pnpm, "--silent", "openclaw", subcommand] + extraArgs - } - if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { - return [openclawPath, subcommand] + extraArgs - } + case .failure: + break + } + if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { + // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. + return [pnpm, "--silent", "openclaw", subcommand] + extraArgs + } + + switch runtimeResult { + case .success: let missingEntry = """ openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. """ return self.errorCommand(with: missingEntry) - case let .failure(error): return self.runtimeErrorCommand(error) } diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift index 2dd720741bbb..ad40d2c38037 100644 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -8,7 +8,7 @@ enum ExecAllowlistMatcher { for entry in entries { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { - case .valid(let pattern): + case let .valid(pattern): let target = resolvedPath ?? rawExecutable if self.matches(pattern: pattern, target: target) { return entry } case .invalid: diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 08567cd0b09a..73aa3899d824 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -439,9 +439,9 @@ enum ExecApprovalsStore { static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { let normalizedPattern: String switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let validPattern): + case let .valid(validPattern): normalizedPattern = validPattern - case .invalid(let reason): + case let .invalid(reason): return reason } @@ -571,7 +571,7 @@ enum ExecApprovalsStore { private static func normalizedPattern(_ pattern: String?) -> String? { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalized): + case let .valid(normalized): return normalized.lowercased() case .invalid(.empty): return nil @@ -587,7 +587,7 @@ enum ExecApprovalsStore { let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): return ExecAllowlistEntry( id: entry.id, pattern: pattern, @@ -596,7 +596,7 @@ enum ExecApprovalsStore { lastResolvedPath: normalizedResolved) case .invalid: switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { - case .valid(let migratedPattern): + case let .valid(migratedPattern): return ExecAllowlistEntry( id: entry.id, pattern: migratedPattern, @@ -629,7 +629,7 @@ enum ExecApprovalsStore { let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): + case let .valid(pattern): normalized.append( ExecAllowlistEntry( id: migrated.id, @@ -637,7 +637,7 @@ enum ExecApprovalsStore { lastUsedAt: migrated.lastUsedAt, lastUsedCommand: migrated.lastUsedCommand, lastResolvedPath: normalizedResolvedPath)) - case .invalid(let reason): + case let .invalid(reason): if dropInvalid { rejected.append( ExecAllowlistRejectedEntry( diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 362a7da01d88..1417589ae4a5 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -38,7 +38,7 @@ private struct ExecHostSocketRequest: Codable { var requestJson: String } -private struct ExecHostRequest: Codable { +struct ExecHostRequest: Codable { var command: [String] var rawCommand: String? var cwd: String? @@ -59,7 +59,7 @@ private struct ExecHostRunResult: Codable { var error: String? } -private struct ExecHostError: Codable { +struct ExecHostError: Codable, Error { var code: String var message: String var reason: String? @@ -353,38 +353,28 @@ private enum ExecHostExecutor { private typealias ExecApprovalContext = ExecApprovalEvaluation static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { - let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !command.isEmpty else { - return self.errorResponse( - code: "INVALID_REQUEST", - message: "command required", - reason: "invalid") - } - - let context = await self.buildContext(request: request, command: command) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } - - let approvalDecision = request.approvalDecision - if approvalDecision == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - } - - var approvedByAsk = approvalDecision != nil - if ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow), - approvalDecision == nil + let validatedRequest: ExecHostValidatedRequest + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success(let request): + validatedRequest = request + case .failure(let error): + return self.errorResponse(error) + } + + let context = await self.buildContext( + request: request, + command: validatedRequest.command, + rawCommand: validatedRequest.displayCommand) + + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: request.approvalDecision) { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( command: context.displayCommand, @@ -396,33 +386,35 @@ private enum ExecHostExecutor { resolvedPath: context.resolution?.resolvedPath, sessionKey: request.sessionKey)) + let followupDecision: ExecApprovalDecision switch decision { case .deny: - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") + followupDecision = .deny case .allowAlways: - approvedByAsk = true + followupDecision = .allowAlways self.persistAllowlistEntry(decision: decision, context: context) case .allowOnce: - approvedByAsk = true + followupDecision = .allowOnce } - } - - self.persistAllowlistEntry(decision: approvalDecision, context: context) - if context.security == .allowlist, - !context.allowlistSatisfied, - !context.skillAllow, - !approvedByAsk - { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: allowlist miss", - reason: "allowlist-miss") + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: followupDecision) + { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: + return self.errorResponse( + code: "INVALID_REQUEST", + message: "unexpected approval state", + reason: "invalid") + } } + self.persistAllowlistEntry(decision: request.approvalDecision, context: context) + if context.allowlistSatisfied { var seenPatterns = Set() for (idx, match) in context.allowlistMatches.enumerated() { @@ -445,16 +437,20 @@ private enum ExecHostExecutor { } return await self.runCommand( - command: command, + command: validatedRequest.command, cwd: request.cwd, env: context.env, timeoutMs: request.timeoutMs) } - private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { + private static func buildContext( + request: ExecHostRequest, + command: [String], + rawCommand: String?) async -> ExecApprovalContext + { await ExecApprovalEvaluator.evaluate( command: command, - rawCommand: request.rawCommand, + rawCommand: rawCommand, cwd: request.cwd, envOverrides: request.env, agentId: request.agentId) @@ -514,6 +510,17 @@ private enum ExecHostExecutor { return self.successResponse(payload) } + private static func errorResponse( + _ error: ExecHostError) -> ExecHostResponse + { + ExecHostResponse( + type: "response", + id: UUID().uuidString, + ok: false, + payload: nil, + error: error) + } + private static func errorResponse( code: String, message: String, diff --git a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift new file mode 100644 index 000000000000..fe38d7ea18f2 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -0,0 +1,84 @@ +import Foundation + +struct ExecHostValidatedRequest { + let command: [String] + let displayCommand: String +} + +enum ExecHostPolicyDecision { + case deny(ExecHostError) + case requiresPrompt + case allow(approvedByAsk: Bool) +} + +enum ExecHostRequestEvaluator { + static func validateRequest(_ request: ExecHostRequest) -> Result { + let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !command.isEmpty else { + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: "command required", + reason: "invalid")) + } + + let validatedCommand = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: request.rawCommand) + switch validatedCommand { + case .ok(let resolved): + return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand)) + case .invalid(let message): + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: message, + reason: "invalid")) + } + } + + static func evaluate( + context: ExecApprovalEvaluation, + approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision + { + if context.security == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DISABLED: security=deny", + reason: "security=deny")) + } + + if approvalDecision == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied")) + } + + let approvedByAsk = approvalDecision != nil + let requiresPrompt = ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow) && approvalDecision == nil + if requiresPrompt { + return .requiresPrompt + } + + if context.security == .allowlist, + !context.allowlistSatisfied, + !context.skillAllow, + !approvedByAsk + { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: allowlist miss", + reason: "allowlist-miss")) + } + + return .allow(approvedByAsk: approvedByAsk) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift index ca6a934adb51..06851a7d0657 100644 --- a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -63,11 +63,11 @@ enum ExecShellWrapperParser { private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { switch spec.kind { case .posix: - return self.extractPosixInlineCommand(command) + self.extractPosixInlineCommand(command) case .cmd: - return self.extractCmdInlineCommand(command) + self.extractCmdInlineCommand(command) case .powershell: - return self.extractPowerShellInlineCommand(command) + self.extractPowerShellInlineCommand(command) } } @@ -81,7 +81,9 @@ enum ExecShellWrapperParser { } private static func extractCmdInlineCommand(_ command: [String]) -> String? { - guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else { + guard let idx = command + .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) + else { return nil } let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift new file mode 100644 index 000000000000..707a46322d8f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -0,0 +1,414 @@ +import Foundation + +enum ExecSystemRunCommandValidator { + struct ResolvedCommand { + let displayCommand: String + } + + enum ValidationResult { + case ok(ResolvedCommand) + case invalid(message: String) + } + + private static let shellWrapperNames = Set([ + "ash", + "bash", + "cmd", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + + private static let posixOrPowerShellInlineWrapperNames = Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + + private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"]) + private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"]) + + private static let envOptionsWithValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) + private static let envInlineValuePrefixes = [ + "-u", + "-c", + "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", + ] + + private struct EnvUnwrapResult { + let argv: [String] + let usesModifiers: Bool + } + + static func resolve(command: [String], rawCommand: String?) -> ValidationResult { + let normalizedRaw = self.normalizeRaw(rawCommand) + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil + + let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command) + let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) + let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv + + let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv { + shellCommand + } else { + ExecCommandFormatter.displayString(for: command) + } + + if let raw = normalizedRaw, raw != inferred { + return .invalid(message: "INVALID_REQUEST: rawCommand does not match command") + } + + return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred)) + } + + private static func normalizeRaw(_ rawCommand: String?) -> String? { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizeExecutableToken(_ token: String) -> String { + let base = ExecCommandToken.basenameLower(token) + if base.hasSuffix(".exe") { + return String(base.dropLast(4)) + } + return base + } + + private static func isEnvAssignment(_ token: String) -> Bool { + token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil + } + + private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { + self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) } + } + + private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { + var idx = 1 + var expectsOptionValue = false + var usesModifiers = false + + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + usesModifiers = true + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + usesModifiers = true + idx += 1 + continue + } + if !token.hasPrefix("-") || token == "-" { + break + } + + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.envFlagOptions.contains(flag) { + usesModifiers = true + idx += 1 + continue + } + if self.envOptionsWithValue.contains(flag) { + usesModifiers = true + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if self.hasEnvInlineValuePrefix(lower) { + usesModifiers = true + idx += 1 + continue + } + return nil + } + + if expectsOptionValue { + return nil + } + guard idx < argv.count else { + return nil + } + return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers) + } + + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return nil + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.shellMultiplexerWrapperNames.contains(wrapper) else { + return nil + } + + var appletIndex = 1 + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return nil + } + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty else { + return nil + } + let normalizedApplet = self.normalizeExecutableToken(applet) + guard self.shellWrapperNames.contains(normalizedApplet) else { + return nil + } + return Array(argv[appletIndex...]) + } + + private static func hasEnvManipulationBeforeShellWrapper( + _ argv: [String], + depth: Int = 0, + envManipulationSeen: Bool = false) -> Bool + { + if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth { + return false + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return false + } + + let normalized = self.normalizeExecutableToken(token0) + if normalized == "env" { + guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else { + return false + } + return self.hasEnvManipulationBeforeShellWrapper( + envUnwrap.argv, + depth: depth + 1, + envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers) + } + + if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) { + return self.hasEnvManipulationBeforeShellWrapper( + shellMultiplexer, + depth: depth + 1, + envManipulationSeen: envManipulationSeen) + } + + guard self.shellWrapperNames.contains(normalized) else { + return false + } + guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else { + return false + } + return envManipulationSeen + } + + private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool { + let wrapperArgv = self.unwrapShellWrapperArgv(argv) + guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else { + return false + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else { + return false + } + + let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } else { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + guard let inlineCommandIndex else { + return false + } + let start = inlineCommandIndex + 1 + guard start < wrapperArgv.count else { + return false + } + return wrapperArgv[start...].contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] { + var current = argv + for _ in 0.., + allowCombinedC: Bool) -> Int? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return idx + 1 < argv.count ? idx + 1 : nil + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !inline.isEmpty { + return idx + } + return idx + 1 < argv.count ? idx + 1 : nil + } + idx += 1 + } + return nil + } + + private static func combinedCommandInlineOffset(_ token: String) -> Int? { + let chars = Array(token.lowercased()) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + if chars.dropFirst().contains("-") { + return nil + } + guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { + return nil + } + return commandIndex + 1 + } + + private static func extractShellInlinePayload( + _ argv: [String], + normalizedWrapper: String) -> String? + { + if normalizedWrapper == "cmd" { + return self.extractCmdInlineCommand(argv) + } + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + + private static func extractInlineCommandByFlags( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + if let inlineValue = self.trimmedNonEmpty(inline) { + return inlineValue + } + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + idx += 1 + } + return nil + } + + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let tailIndex = idx + 1 + guard tailIndex < argv.count else { + return nil + } + let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } +} diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 60cfdfb1d737..4dae858771cc 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -304,8 +304,7 @@ struct GeneralSettings: View { .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." - ) + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -549,8 +548,7 @@ extension GeneralSettings { } guard Self.isValidWsUrl(trimmedUrl) else { self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" - ) + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") return } } else { diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 00e2a9be0a63..d7ab72ce86f6 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -431,7 +431,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } } -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} +extension SparkleUpdaterController: SPUUpdaterDelegate {} private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 37fd6ca25052..eb6271d0a8ce 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -446,6 +446,8 @@ extension MenuSessionsInjector { private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu { let menu = NSMenu() + // Keep submenu delegate nil: reusing the status-menu delegate here causes + // recursive reinjection whenever this submenu is opened. for row in rows { let item = NSMenuItem() item.tag = self.tag @@ -493,7 +495,6 @@ extension MenuSessionsInjector { guard !summary.daily.isEmpty else { return nil } let menu = NSMenu() - menu.delegate = self let chartView = CostUsageHistoryMenuView(summary: summary, width: width) let hosting = NSHostingView(rootView: AnyView(chartView)) @@ -1226,6 +1227,12 @@ extension MenuSessionsInjector { self.usageCacheUpdatedAt = Date() } + func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) { + self.cachedCostSummary = summary + self.cachedCostErrorText = errorText + self.costCacheUpdatedAt = Date() + } + func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift index e35057d28cfa..81e06abda2df 100644 --- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift +++ b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift @@ -14,6 +14,13 @@ actor MicLevelMonitor { if self.running { return } self.logger.info( "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } let engine = AVAudioEngine() self.engine = engine let input = engine.inputNode diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5b05ab164c2d..ed40bd2ed58b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -87,19 +87,9 @@ extension OnboardingView { self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { - let localSubtitle: String = { - guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." - } - let base = probe.expected - ? "Existing gateway detected" - : "Port \(probe.port) already in use" - let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" - return "\(base)\(command). Will attach." - }() self.connectionChoiceButton( title: "This Mac", - subtitle: localSubtitle, + subtitle: self.localGatewaySubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() @@ -107,50 +97,7 @@ extension OnboardingView { Divider().padding(.vertical, 4) - HStack(spacing: 8) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.gatewayDiscovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - if self.gatewayDiscovery.gateways.isEmpty { - ProgressView().controlSize(.small) - Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) - } - .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") - } - Spacer(minLength: 0) - } - - if self.gatewayDiscovery.gateways.isEmpty { - Text("Searching for nearby gateways…") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } else { - VStack(alignment: .leading, spacing: 6) { - Text("Nearby gateways") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in - self.connectionChoiceButton( - title: gateway.displayName, - subtitle: self.gatewaySubtitle(for: gateway), - selected: self.isSelectedGateway(gateway)) - { - self.selectRemoteGateway(gateway) - } - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } + self.gatewayDiscoverySection() self.connectionChoiceButton( title: "Configure later", @@ -160,104 +107,168 @@ extension OnboardingView { self.selectUnconfiguredGateway() } - Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.showAdvancedConnection.toggle() + self.advancedConnectionSection() + } + } + } + } + + private var localGatewaySubtitle: String { + guard let probe = self.localGatewayProbe else { + return "Gateway starts automatically on this Mac." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + } + + @ViewBuilder + private func gatewayDiscoverySection() -> some View { + HStack(spacing: 8) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.gatewayDiscovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.gatewayDiscovery.gateways.isEmpty { + ProgressView().controlSize(.small) + Button("Refresh") { + self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + } + .buttonStyle(.link) + .help("Retry Tailscale discovery (DNS-SD).") + } + Spacer(minLength: 0) + } + + if self.gatewayDiscovery.gateways.isEmpty { + Text("Searching for nearby gateways…") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Nearby gateways") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in + self.connectionChoiceButton( + title: gateway.displayName, + subtitle: self.gatewaySubtitle(for: gateway), + selected: self.isSelectedGateway(gateway)) + { + self.selectRemoteGateway(gateway) + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + + @ViewBuilder + private func advancedConnectionSection() -> some View { + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.showAdvancedConnection.toggle() + } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } + } + .buttonStyle(.link) + + if self.showAdvancedConnection { + let labelWidth: CGFloat = 110 + let fieldWidth: CGFloat = 320 + + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote + .pickerStyle(.segmented) + .frame(width: fieldWidth) + } + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) } } - .buttonStyle(.link) - - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 - - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(width: fieldWidth) - } - if self.state.remoteTransport == .direct { - GridRow { - Text("Gateway URL") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - if self.state.remoteTransport == .ssh { - GridRow { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if let message = CommandResolver - .sshTargetValidationMessage(self.state.remoteTarget) - { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .frame(width: fieldWidth, alignment: .leading) - } - } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/OpenClaw.app/.../openclaw", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + if let message = CommandResolver + .sshTargetValidationMessage(self.state.remoteTarget) + { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(width: fieldWidth, alignment: .leading) } - - Text(self.state.remoteTransport == .direct - ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." - : "Tip: keep Tailscale enabled so your gateway stays reachable.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) } - .transition(.opacity.combined(with: .move(edge: .top))) + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/OpenClaw.app/.../openclaw", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } } + + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) } + .transition(.opacity.combined(with: .move(edge: .top))) } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift index 1895b2af94f7..7538f846b890 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -13,8 +13,10 @@ extension OnboardingView { guard self.state.connectionMode == .local else { return } let configured = await self.loadAgentWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: + let safety = AgentWorkspace.bootstrapSafety(for: url) + if let reason = safety.unsafeReason { + self.workspaceStatus = "Workspace not touched: \(reason)" + } else { do { _ = try AgentWorkspace.bootstrap(workspaceURL: url) if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -23,8 +25,6 @@ extension OnboardingView { } catch { self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } - case let .unsafe (reason): - self.workspaceStatus = "Workspace not touched: \(reason)" } self.refreshBootstrapStatus() } @@ -54,7 +54,7 @@ extension OnboardingView { do { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { + if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason { self.workspaceStatus = "Workspace not created: \(reason)" return } diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 3a425368d090..5abb959dc8e8 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.23 + 2026.2.25 CFBundleVersion - 202602230 + 202602250 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index a6d81f50bcac..7c047e01d03c 100644 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -383,12 +383,12 @@ final class ExecApprovalsSettingsModel { func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { guard !self.isDefaultsScope else { return nil } switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) self.allowlistValidationMessage = rejected.first?.reason.message return rejected.first?.reason - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } @@ -400,9 +400,9 @@ final class ExecApprovalsSettingsModel { guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } var next = entry switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { - case .valid(let normalizedPattern): + case let .valid(normalizedPattern): next.pattern = normalizedPattern - case .invalid(let reason): + case let .invalid(reason): self.allowlistValidationMessage = reason.message return reason } diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 47b041a5873e..a8d8008c6530 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -11,6 +11,7 @@ actor TalkModeRuntime { private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime") private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() @@ -184,6 +185,12 @@ actor TalkModeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + self.logger.error("talk mode: no usable audio input device") + return + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) input.removeTap(onBus: 0) @@ -792,6 +799,82 @@ extension TalkModeRuntime { let apiKey: String? } + struct TalkProviderConfigSelection { + let provider: String + let config: [String: AnyCodable] + let normalizedPayload: Bool + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? { + if let typed = value.value as? [String: AnyCodable] { + return typed + } + if let foundation = value.value as? [String: Any] { + return foundation.mapValues(AnyCodable.init) + } + if let nsDict = value.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in nsDict { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let raw else { return [:] } + var providerMap: [String: AnyCodable] = [:] + if let typed = raw.value as? [String: AnyCodable] { + providerMap = typed + } else if let foundation = raw.value as? [String: Any] { + providerMap = foundation.mapValues(AnyCodable.init) + } else if let nsDict = raw.value as? NSDictionary { + for case let (key as String, value) in nsDict { + providerMap[key] = AnyCodable(value) + } + } else { + return [:] + } + + return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = Self.normalizedTalkProviderConfig(entry.value) + else { return } + acc[providerID] = providerConfig + } + } + + static func selectTalkProviderConfig( + _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? + { + guard let talk else { return nil } + let rawProvider = talk["provider"]?.stringValue + let rawProviders = talk["providers"] + let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + if hasNormalizedPayload { + let normalizedProviders = Self.normalizedTalkProviders(rawProviders) + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.min() ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + return TalkProviderConfigSelection( + provider: Self.defaultTalkProvider, + config: talk, + normalizedPayload: false) + } + private func fetchTalkConfig() async -> TalkRuntimeConfig { let env = ProcessInfo.processInfo.environment let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -804,13 +887,16 @@ extension TalkModeRuntime { params: ["includeSecrets": AnyCodable(true)], timeoutMs: 8000) let talk = snap.config?["talk"]?.dictionaryValue + let selection = Self.selectTalkProviderConfig(talk) + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config let ui = snap.config?["ui"]?.dictionaryValue let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await MainActor.run { AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam } - let voice = talk?["voiceId"]?.stringValue - let rawAliases = talk?["voiceAliases"]?.dictionaryValue + let voice = activeConfig?["voiceId"]?.stringValue + let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue let resolvedAliases: [String: String] = rawAliases?.reduce(into: [:]) { acc, entry in let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -818,18 +904,30 @@ extension TalkModeRuntime { guard !key.isEmpty, !value.isEmpty else { return } acc[key] = value } ?? [:] - let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = talk?["outputFormat"]?.stringValue + let outputFormat = activeConfig?["outputFormat"]?.stringValue let interrupt = talk?["interruptOnSpeech"]?.boolValue - let apiKey = talk?["apiKey"]?.stringValue - let resolvedVoice = + let apiKey = activeConfig?["apiKey"]?.stringValue + let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + } else { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) + } + let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + } else { + nil + } + if activeProvider != Self.defaultTalkProvider { + self.ttsLogger + .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") + } else if selection?.normalizedPayload == true { + self.ttsLogger.info("talk config provider elevenlabs") + } return TalkRuntimeConfig( voiceId: resolvedVoice, voiceAliases: resolvedAliases, diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift index e535ebd6616f..6eaa45e06759 100644 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -244,6 +244,14 @@ actor VoicePushToTalk { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) if self.tapInstalled { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift index ee634a628ed4..0c6ea54c90e0 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift @@ -37,7 +37,7 @@ enum VoiceWakeForwarder { var thinking: String = "low" var deliver: Bool = true var to: String? - var channel: GatewayAgentChannel = .last + var channel: GatewayAgentChannel = .webchat } @discardableResult diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift index 8e88c86d45d1..bbbed72926b5 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift @@ -185,6 +185,11 @@ private final class TranscriptNSTextView: NSTextView { self.onEscape?() return } + // Keep IME candidate confirmation behavior: Return should commit marked text first. + if isReturn, self.hasMarkedText() { + super.keyDown(with: event) + return + } if isReturn, event.modifierFlags.contains(.command) { self.onSend?() return diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift index 61f913b9da88..b7e2d329b820 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -166,6 +166,14 @@ actor VoiceWakeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) guard format.channelCount > 0, format.sampleRate > 0 else { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift index b3d0c58d90c7..063fea826ab6 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -89,6 +89,14 @@ final class VoiceWakeTester { self.logInputSelection(preferredMicID: micID) self.configureSession(preferredMicID: micID) + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let engine = AVAudioEngine() self.audioEngine = engine diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 5b866304b090..46e5d80a01eb 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -316,7 +316,12 @@ final class WebChatSwiftUIWindowController { let controller = NSViewController() let effectView = NSVisualEffectView() effectView.material = .sidebar - effectView.blendingMode = .behindWindow + effectView.blendingMode = switch presentation { + case .panel: + .withinWindow + case .window: + .behindWindow + } effectView.state = .active effectView.wantsLayer = true effectView.layer?.cornerCurve = .continuous @@ -328,6 +333,7 @@ final class WebChatSwiftUIWindowController { } effectView.layer?.cornerRadius = cornerRadius effectView.layer?.masksToBounds = true + effectView.layer?.backgroundColor = NSColor.clear.cgColor effectView.translatesAutoresizingMaskIntoConstraints = true effectView.autoresizingMask = [.width, .height] @@ -335,6 +341,9 @@ final class WebChatSwiftUIWindowController { hosting.view.translatesAutoresizingMaskIntoConstraints = false hosting.view.wantsLayer = true + hosting.view.layer?.cornerCurve = .continuous + hosting.view.layer?.cornerRadius = cornerRadius + hosting.view.layer?.masksToBounds = true hosting.view.layer?.backgroundColor = NSColor.clear.cgColor controller.addChild(hosting) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc2..4e766514defc 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift index 6d5e4a37efd0..8794a3f22fce 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -59,12 +59,7 @@ struct AgentWorkspaceTests { try "hello".write(to: marker, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .unsafe: - break - case .safe: - #expect(Bool(false), "Expected unsafe bootstrap safety result.") - } + #expect(result.unsafeReason != nil) } @Test @@ -77,12 +72,7 @@ struct AgentWorkspaceTests { try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .safe: - break - case .unsafe: - #expect(Bool(false), "Expected safe bootstrap safety result.") - } + #expect(result.unsafeReason == nil) } @Test diff --git a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift new file mode 100644 index 000000000000..a175e5e1a0ac --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct AudioInputDeviceObserverTests { + @Test func hasUsableDefaultInputDeviceReturnsBool() { + // Smoke test: verifies the composition logic runs without crashing. + // Actual result depends on whether the host has an audio input device. + let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice() + _ = result // suppress unused-variable warning; the assertion is "no crash" + } + + @Test func hasUsableDefaultInputDeviceConsistentWithComponents() { + // When no default UID exists, the method must return false. + // When a default UID exists, the result must match alive-set membership. + let uid = AudioInputDeviceObserver.defaultInputDeviceUID() + let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let expected = uid.map { alive.contains($0) } ?? false + #expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 7a71bc08b6ea..d84706791838 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -66,6 +66,48 @@ import Testing } } + @Test func prefersOpenClawBinaryOverPnpm() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + let pnpmPath = binDir.appendingPathComponent("pnpm") + try self.makeExec(at: openclawPath) + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"])) + } + + @Test func usesOpenClawBinaryWithoutNodeRuntime() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "gateway", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) + } + @Test func fallsBackToPnpm() async throws { let defaults = self.makeDefaults() defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) @@ -76,7 +118,11 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.openclawCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) } @@ -95,7 +141,8 @@ import Testing subcommand: "health", extraArgs: ["--json", "--timeout", "5"], defaults: defaults, - configRoot: [:]) + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"])) #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift new file mode 100644 index 000000000000..64ef6a21edad --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecHostRequestEvaluatorTests { + @Test func validateRequestRejectsEmptyCommand() { + let request = ExecHostRequest(command: [], rawCommand: nil, cwd: nil, env: nil, timeoutMs: nil, needsScreenRecording: nil, agentId: nil, sessionKey: nil, approvalDecision: nil) + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success: + Issue.record("expected invalid request") + case .failure(let error): + #expect(error.code == "INVALID_REQUEST") + #expect(error.message == "command required") + } + } + + @Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil) + switch decision { + case .requiresPrompt: + break + case .allow: + Issue.record("expected prompt requirement") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce) + switch decision { + case .allow(let approvedByAsk): + #expect(approvedByAsk) + case .requiresPrompt: + Issue.record("expected allow decision") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateDeniesOnExplicitDenyDecision() { + let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny) + switch decision { + case .deny(let error): + #expect(error.reason == "user-denied") + case .requiresPrompt: + Issue.record("expected deny decision") + case .allow: + Issue.record("expected deny decision") + } + } + + private static func makeContext( + security: ExecSecurity, + ask: ExecAsk, + allowlistSatisfied: Bool, + skillAllow: Bool) -> ExecApprovalEvaluation + { + ExecApprovalEvaluation( + command: ["/usr/bin/echo", "hi"], + displayCommand: "/usr/bin/echo hi", + agentId: nil, + security: security, + ask: ask, + env: [:], + resolution: nil, + allowlistResolutions: [], + allowlistMatches: [], + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: nil, + skillAllow: skillAllow) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift new file mode 100644 index 000000000000..ed3773a44ed6 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +private struct SystemRunCommandContractFixture: Decodable { + let cases: [SystemRunCommandContractCase] +} + +private struct SystemRunCommandContractCase: Decodable { + let name: String + let command: [String] + let rawCommand: String? + let expected: SystemRunCommandContractExpected +} + +private struct SystemRunCommandContractExpected: Decodable { + let valid: Bool + let displayCommand: String? + let errorContains: String? +} + +struct ExecSystemRunCommandValidatorTests { + @Test func matchesSharedSystemRunCommandContractFixture() throws { + for entry in try Self.loadContractCases() { + let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand) + + if !entry.expected.valid { + switch result { + case .ok(let resolved): + Issue.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)") + case .invalid(let message): + if let expected = entry.expected.errorContains { + #expect( + message.contains(expected), + "\(entry.name): expected error containing \(expected), got \(message)") + } + } + continue + } + + switch result { + case .ok(let resolved): + #expect( + resolved.displayCommand == entry.expected.displayCommand, + "\(entry.name): unexpected display command") + case .invalid(let message): + Issue.record("\(entry.name): unexpected invalid result: \(message)") + } + } + } + + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { + let fixtureURL = try self.findContractFixtureURL() + let data = try Data(contentsOf: fixtureURL) + let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data) + return decoded.cases + } + + private static func findContractFixtureURL() throws -> URL { + var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = cursor + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent("system-run-command-contract.json") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + cursor.deleteLastPathComponent() + } + throw NSError( + domain: "ExecSystemRunCommandValidatorTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"]) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 8395ed145ce8..ff63673b9e08 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } + + @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let summary = GatewayCostUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + days: 1, + daily: [ + GatewayCostUsageDay( + date: "2026-02-24", + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0), + ], + totals: GatewayCostUsageTotals( + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0)) + injector.setTestingCostUsageSummary(summary, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + + let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } + #expect(usageCostItem != nil) + #expect(usageCostItem?.submenu != nil) + #expect(usageCostItem?.submenu?.delegate == nil) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift new file mode 100644 index 000000000000..5ee30af273d4 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -0,0 +1,36 @@ +import OpenClawProtocol +import Testing + +@testable import OpenClaw + +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: AnyCodable] = [ + "voiceId": AnyCodable("voice-legacy"), + "apiKey": AnyCodable("legacy-key"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") + #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift index 46971ac314c1..6640d526a741 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift @@ -17,6 +17,7 @@ import Testing #expect(opts.thinking == "low") #expect(opts.deliver == true) #expect(opts.to == nil) - #expect(opts.channel == .last) + #expect(opts.channel == .webchat) + #expect(opts.channel.shouldDeliver(opts.deliver) == false) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 145e17f3b7b6..627148381779 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -486,6 +486,10 @@ private final class ChatComposerNSTextView: NSTextView { override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 if isReturn { + if self.hasMarkedText() { + super.keyDown(with: event) + return + } if event.modifierFlags.contains(.shift) { super.insertNewline(nil) return diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc2..4e766514defc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js index a9cb659876a5..530287ca21db 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js @@ -32,6 +32,66 @@ if (modalElement && Array.isArray(modalElement.styles)) { modalElement.styles = [...modalElement.styles, modalStyles]; } +const appendComponentStyles = (tagName, extraStyles) => { + const component = customElements.get(tagName); + if (!component) { + return; + } + + const current = component.styles; + if (!current) { + component.styles = [extraStyles]; + return; + } + + component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles]; +}; + +appendComponentStyles( + "a2ui-row", + css` + @media (max-width: 860px) { + section { + flex-wrap: wrap; + align-content: flex-start; + } + + ::slotted(*) { + flex: 1 1 100%; + min-width: 100%; + width: 100%; + max-width: 100%; + } + } + `, +); + +appendComponentStyles( + "a2ui-column", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + +appendComponentStyles( + "a2ui-card", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + const emptyClasses = () => ({}); const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index b149f8745dc3..60f50d6551e5 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,4 @@ -import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' const DEFAULT_PORT = 18792 @@ -30,6 +30,10 @@ const pending = new Map() /** @type {Set} */ const tabOperationLocks = new Set() +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + // Reconnect state for exponential backoff. let reconnectAttempt = 0 let reconnectTimer = null @@ -190,6 +194,8 @@ function onRelayClosed(reason) { p.reject(new Error(`Relay disconnected (${reason})`)) } + reattachPending.clear() + for (const [tabId, tab] of tabs.entries()) { if (tab.state === 'connected') { setBadge(tabId, 'connecting') @@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() { tabOperationLocks.add(tabId) try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') @@ -632,50 +648,109 @@ function onDebuggerEvent(source, method, params) { } } -// Navigation/reload fires target_closed but the tab is still alive — Chrome -// just swaps the renderer process. Suppress the detach event to the relay and -// seamlessly re-attach after a short grace period. -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - if (reason === 'target_closed') { - const oldState = tabs.get(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) + // User explicitly cancelled or DevTools replaced the connection — respect their intent + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } - setTimeout(async () => { - try { - // If user manually detached during the grace period, bail out. - if (!tabs.has(tabId)) return - const tab = await chrome.tabs.get(tabId) - if (tab && relayWs?.readyState === WebSocket.OPEN) { - console.log(`Re-attaching tab ${tabId} after navigation`) - if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) - tabs.delete(tabId) - await attachTab(tabId, { skipAttachedEvent: false }) - } else { - // Tab gone or relay down — full cleanup. - void detachTab(tabId, reason) - } - } catch (err) { - console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) - void detachTab(tabId, reason) - } - }, 500) + // Check if tab still exists — distinguishes navigation from tab close + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + // Tab is gone (closed) — normal cleanup + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) return } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } // Tab lifecycle listeners — clean up stale entries. chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) if (!tabs.has(tabId)) return const tab = tabs.get(tabId) if (tab?.sessionId) tabBySession.delete(tab.sessionId) @@ -807,7 +882,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const { url, token } = msg const headers = token ? { 'x-openclaw-relay-token': token } : {} fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then((res) => sendResponse({ status: res.status, ok: res.ok })) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) return true }) diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 000000000000..53e2cd550147 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 7a47a5d947e7..aa6fcc4901fd 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,17 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -async function deriveRelayToken(gatewayToken, port) { - const enc = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', enc.encode(gatewayToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], - ) - const sig = await crypto.subtle.sign( - 'HMAC', key, enc.encode(`openclaw-extension-relay-v1:${port}`), - ) - return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('') -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -47,19 +39,12 @@ async function checkRelayReachable(port, token) { url, token: relayToken, }) - if (!res) throw new Error('No response from service worker') - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (res.error) throw new Error(res.error) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index c25cbcb80dbc..9676d960d236 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -62,7 +62,7 @@ The agent reads this on each heartbeat and handles all items in one turn. defaults: { heartbeat: { every: "30m", // interval - target: "last", // where to deliver alerts + target: "last", // explicit alert delivery target (default is "none") activeHours: { start: "08:00", end: "22:00" }, // optional }, }, diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 108ef34d4efe..31913842e4db 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -919,6 +919,8 @@ Auto-join example: channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -933,6 +935,10 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. +- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. +- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. +- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). ## Voice messages @@ -1008,6 +1014,18 @@ openclaw logs --follow If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. + + + + - keep OpenClaw current (`openclaw update`) so the Discord voice receive recovery logic is present + - confirm `channels.discord.voice.daveEncryption=true` (default) + - start from `channels.discord.voice.decryptionFailureTolerance=24` (upstream default) and tune only if needed + - watch logs for: + - `discord voice: DAVE decrypt failures detected` + - `discord voice: repeated decrypt failures; attempting rejoin` + - if failures continue after automatic rejoin, collect logs and compare against [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419) + + ## Configuration reference pointers diff --git a/docs/channels/groups.md b/docs/channels/groups.md index de848243c9c1..8b8af64b94cb 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -1,5 +1,5 @@ --- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams/Zalo)" read_when: - Changing group chat behavior or mention gating title: "Groups" @@ -7,7 +7,7 @@ title: "Groups" # Groups -OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. +OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams, Zalo. ## Beginner intro (2 minutes) @@ -183,7 +183,7 @@ Control how group/room messages are handled per channel: Notes: - `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord: allowlist uses `channels.discord.guilds..channels`. - Slack: allowlist uses `channels.slack.channels`. - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 78beff43bc40..89e96b318a3b 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -72,6 +72,7 @@ Config values override env vars. - `dmPolicy: "allowlist"` is the recommended default. - `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. +- In `allowlist` mode, an empty `allowedUserIds` list is treated as misconfiguration and the webhook route will not start (use `dmPolicy: "open"` for allow-all). - `dmPolicy: "open"` allows any sender. - `dmPolicy: "disabled"` blocks DMs. - Pairing approvals work with: diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cda126f56491..8e5d8ab0382a 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. Direct messages only; groups coming soon per Zalo docs. +Status: experimental. DMs are supported; group handling is available with explicit group policy controls. ## Plugin required @@ -51,7 +51,7 @@ It is a good fit for support or notifications where you want deterministic routi - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are not yet supported (Zalo docs state "coming soon"). +- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. ## Setup (fast path) @@ -107,6 +107,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and - Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available). +## Access control (Groups) + +- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. +- Default behavior is fail-closed: `allowlist`. +- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. +- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. +- `groupPolicy: "disabled"` blocks all group messages. +- `groupPolicy: "open"` allows any group member (mention-gated). +- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -130,16 +140,16 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Capabilities -| Feature | Status | -| --------------- | ------------------------------ | -| Direct messages | ✅ Supported | -| Groups | ❌ Coming soon (per Zalo docs) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +| Feature | Status | +| --------------- | -------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ⚠️ Supported with policy controls (allowlist by default) | +| Media (images) | ✅ Supported | +| Reactions | ❌ Not supported | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -172,6 +182,8 @@ Provider options: - `channels.zalo.tokenFile`: read token from file path. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). - `channels.zalo.webhookSecret`: webhook secret (8-256 chars). @@ -186,6 +198,8 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. - `channels.zalo.accounts..webhookPath`: per-account webhook path. diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 1590a0550501..0055abec7b49 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -29,5 +29,5 @@ Notes: ```bash openclaw configure -openclaw configure --section models --section channels +openclaw configure --section model --section channels ``` diff --git a/docs/cli/devices.md b/docs/cli/devices.md index edacf9a2876e..be01e3cc0d52 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -21,6 +21,25 @@ openclaw devices list openclaw devices list --json ``` +### `openclaw devices remove ` + +Remove one paired device entry. + +``` +openclaw devices remove +openclaw devices remove --json +``` + +### `openclaw devices clear --yes [--pending]` + +Clear paired devices in bulk. + +``` +openclaw devices clear --yes +openclaw devices clear --yes --pending +openclaw devices clear --yes --pending --json +``` + ### `openclaw devices approve [requestId] [--latest]` Approve a pending device pairing request. If `requestId` is omitted, OpenClaw @@ -71,3 +90,5 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er - Token rotation returns a new token (sensitive). Treat it like a secret. - These commands require `operator.pairing` (or `operator.admin`) scope. +- `devices clear` is intentionally gated by `--yes`. +- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index dff899d7cd28..d53d86452f3b 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -28,6 +28,8 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. - State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. +- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. +- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). ## macOS: `launchctl` env overrides diff --git a/docs/cli/index.md b/docs/cli/index.md index 49017c3735df..32eb31b5eb3d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -281,7 +281,7 @@ Vector search over `MEMORY.md` + `memory/*.md`: - `openclaw memory status` — show index stats. - `openclaw memory index` — reindex memory files. -- `openclaw memory search ""` — semantic search over memory. +- `openclaw memory search ""` (or `--query ""`) — semantic search over memory. ## Chat slash commands @@ -468,8 +468,23 @@ Approve DM pairing requests across channels. Subcommands: -- `pairing list [--json]` -- `pairing approve [--notify]` +- `pairing list [channel] [--channel ] [--account ] [--json]` +- `pairing approve [--account ] [--notify]` +- `pairing approve --channel [--account ] [--notify]` + +### `devices` + +Manage gateway device pairing entries and per-role device tokens. + +Subcommands: + +- `devices list [--json]` +- `devices approve [requestId] [--latest]` +- `devices reject ` +- `devices remove ` +- `devices clear --yes [--pending]` +- `devices rotate --device --role [--scope ]` +- `devices revoke --device --role ` ### `webhooks gmail` diff --git a/docs/cli/memory.md b/docs/cli/memory.md index bc6d05c12e3c..11b9926c56a7 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -26,6 +26,7 @@ openclaw memory status --deep --index --verbose openclaw memory index openclaw memory index --verbose openclaw memory search "release checklist" +openclaw memory search --query "release checklist" openclaw memory status --agent main openclaw memory index --agent main --verbose ``` @@ -37,6 +38,12 @@ Common: - `--agent `: scope to a single agent (default: all configured agents). - `--verbose`: emit detailed logs during probes and indexing. +`memory search`: + +- Query input: pass either positional `[query]` or `--query `. +- If both are provided, `--query` wins. +- If neither is provided, the command exits with an error. + Notes: - `memory status --deep` probes vector + embedding availability. diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 319ddc29a0f6..13ad8a59948b 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -16,6 +16,17 @@ Related: ## Commands ```bash -openclaw pairing list whatsapp -openclaw pairing approve whatsapp --notify +openclaw pairing list telegram +openclaw pairing list --channel telegram --account work +openclaw pairing list telegram --json + +openclaw pairing approve telegram +openclaw pairing approve --channel telegram --account work --notify ``` + +## Notes + +- Channel input: pass it positionally (`pairing list telegram`) or with `--channel `. +- `pairing list` supports `--account ` for multi-account channels. +- `pairing approve` supports `--account ` and `--notify`. +- If only one pairing-capable channel is configured, `pairing approve ` is allowed. diff --git a/docs/cli/security.md b/docs/cli/security.md index 9b1cce7db792..fe8af41ec259 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,16 +25,20 @@ openclaw security audit --json The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts). +It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. +For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records). It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. +It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins). It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. +For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). ## JSON output diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe557..bbd58d599ced 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -152,7 +152,7 @@ Parameters: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, aborts the sub-agent run after N seconds) - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 81550a032ed8..6c9010d2c11e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -283,7 +283,7 @@ Runtime override (owner only): - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). -- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). +- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - JSONL transcripts can be opened directly to review full turns. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8ff410363548..01ad82b6098c 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -255,6 +255,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -282,6 +284,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. +- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default). +- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). @@ -796,7 +800,7 @@ Periodic heartbeat runs. includeReasoning: false, session: "main", to: "+15555550123", - target: "last", // last | whatsapp | telegram | discord | ... | none + target: "none", // default: none | options: last | whatsapp | telegram | discord | ... prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, suppressToolErrorWarnings: false, @@ -808,6 +812,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. +- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. @@ -1017,7 +1022,9 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. -**Containers default to `network: "none"`** — set to `"bridge"` if the agent needs outbound access. +**Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access. +`"host"` is blocked. `"container:"` is blocked by default unless you explicitly set +`sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). **Inbound attachments** are staged into `media/inbound/*` in the active workspace. @@ -1683,6 +1690,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1691,6 +1699,7 @@ Notes: ``` - `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model. +- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout. - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`. --- diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f4fea3b5a35b..3f7403d4647e 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -239,7 +239,7 @@ When validation fails: ``` - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked) - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b682da0f814d..cf7ea489c40b 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -19,7 +19,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) 1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence. 2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). -3. Decide where heartbeat messages should go (`target: "last"` is the default). +3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact). 4. Optional: enable heartbeat reasoning delivery for transparency. 5. Optional: restrict heartbeats to active hours (local time). @@ -31,7 +31,7 @@ Example config: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") // activeHours: { start: "08:00", end: "24:00" }, // includeReasoning: true, // optional: send separate `Reasoning:` message too }, @@ -87,7 +87,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-6", includeReasoning: false, // default: false (deliver separate Reasoning: message when available) - target: "last", // last | none | (core or plugin, e.g. "bluebubbles") + target: "last", // default: none | options: last | none | (core or plugin, e.g. "bluebubbles") to: "+15551234567", // optional channel-specific override accountId: "ops-bot", // optional multi-account channel id prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", @@ -120,7 +120,7 @@ Example: two agents, only the second agent runs heartbeats. defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") }, }, list: [ @@ -149,7 +149,7 @@ Restrict heartbeats to business hours in a specific timezone: defaults: { heartbeat: { every: "30m", - target: "last", + target: "last", // explicit delivery to last contact (default is "none") activeHours: { start: "09:00", end: "22:00", @@ -212,9 +212,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele - Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)). - Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups). - `target`: - - `last` (default): deliver to the last used external channel. + - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - - `none`: run the heartbeat but **do not deliver** externally. + - `none` (default): run the heartbeat but **do not deliver** externally. +- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). @@ -235,6 +236,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. +- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/remote-gateway-readme.md b/docs/gateway/remote-gateway-readme.md index 27fbfb6d2a9a..cb069629070e 100644 --- a/docs/gateway/remote-gateway-readme.md +++ b/docs/gateway/remote-gateway-readme.md @@ -84,7 +84,7 @@ To have the SSH tunnel start automatically when you log in, create a Launch Agen ### Create the PLIST file -Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: +Save this as `~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist`: ```xml @@ -92,7 +92,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: Label - bot.molt.ssh-tunnel + ai.openclaw.ssh-tunnel ProgramArguments /usr/bin/ssh @@ -110,7 +110,7 @@ Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: ### Load the Launch Agent ```bash -launchctl bootstrap gui/$UID ~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist ``` The tunnel will now: @@ -135,13 +135,13 @@ lsof -i :18789 **Restart the tunnel:** ```bash -launchctl kickstart -k gui/$UID/bot.molt.ssh-tunnel +launchctl kickstart -k gui/$UID/ai.openclaw.ssh-tunnel ``` **Stop the tunnel:** ```bash -launchctl bootout gui/$UID/bot.molt.ssh-tunnel +launchctl bootout gui/$UID/ai.openclaw.ssh-tunnel ``` --- diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 6d51f573990a..8be57bd10642 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -138,6 +138,12 @@ scripts/sandbox-browser-setup.sh By default, sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. +Security defaults: + +- `network: "host"` is blocked. +- `network: "container:"` is blocked by default (namespace join bypass risk). +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. + Docker installs and the containerized gateway live here: [Docker](/install/docker) @@ -154,6 +160,7 @@ Paths: Common pitfalls: - Default `docker.network` is `"none"` (no egress), so package installs will fail. +- `docker.network: "container:"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only. - `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. - `user` must be root for package installs (omit `user` or set `user: "0:0"`). - Sandbox exec does **not** inherit host `process.env`. Use diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 49b985be2a65..3824d1d283e1 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -7,6 +7,22 @@ title: "Security" # Security 🔒 +> [!WARNING] +> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). +> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway. +> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts). + +## Scope first: personal assistant security model + +OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents. + +- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary). +- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users. +- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts). +- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent. + +This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway. + ## Quick check: `openclaw security audit` See also: [Formal Verification (Security Models)](/security/formal-verification/) @@ -228,10 +244,13 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | | `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | | `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no | | `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | | `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | | `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | +| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no | | `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no | | `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | | `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | | `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | @@ -251,14 +270,40 @@ keep it off unless you are actively debugging and can revert quickly. ## Insecure or dangerous flags summary -`openclaw security audit` includes `config.insecure_or_dangerous_flags` when any -insecure/dangerous debug switches are enabled. This warning aggregates the exact -keys so you can review them in one place (for example -`gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`, -`gateway.controlUi.allowInsecureAuth=true`, -`gateway.controlUi.dangerouslyDisableDeviceAuth=true`, -`hooks.gmail.allowUnsafeExternalContent=true`, or -`tools.exec.applyPatch.workspaceOnly=false`). +`openclaw security audit` includes `config.insecure_or_dangerous_flags` when +known insecure/dangerous debug switches are enabled. That check currently +aggregates: + +- `gateway.controlUi.allowInsecureAuth=true` +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` +- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` +- `hooks.gmail.allowUnsafeExternalContent=true` +- `hooks.mappings[].allowUnsafeExternalContent=true` +- `tools.exec.applyPatch.workspaceOnly=false` + +Complete `dangerous*` / `dangerously*` config keys defined in OpenClaw config +schema: + +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` +- `gateway.controlUi.dangerouslyDisableDeviceAuth` +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +- `channels.discord.dangerouslyAllowNameMatching` +- `channels.discord.accounts..dangerouslyAllowNameMatching` +- `channels.slack.dangerouslyAllowNameMatching` +- `channels.slack.accounts..dangerouslyAllowNameMatching` +- `channels.googlechat.dangerouslyAllowNameMatching` +- `channels.googlechat.accounts..dangerouslyAllowNameMatching` +- `channels.msteams.dangerouslyAllowNameMatching` +- `channels.irc.dangerouslyAllowNameMatching` (extension channel) +- `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel) +- `channels.mattermost.accounts..dangerouslyAllowNameMatching` (extension channel) +- `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin` +- `agents.list[].sandbox.docker.dangerouslyAllowReservedContainerTargets` +- `agents.list[].sandbox.docker.dangerouslyAllowExternalBindSources` +- `agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin` ## Reverse Proxy Configuration @@ -791,7 +836,8 @@ We may add a single `readOnlyMode` flag later to simplify this configuration. Additional hardening options: - `tools.exec.applyPatch.workspaceOnly: true` (default): ensures `apply_patch` cannot write/delete outside the workspace directory even when sandboxing is off. Set to `false` only if you intentionally want `apply_patch` to touch files outside the workspace. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory (useful if you allow absolute paths today and want a single guardrail). +- Keep filesystem roots narrow: avoid broad roots like your home directory for agent workspaces/sandbox workspaces. Broad roots can expose sensitive local files (for example state/config under `~/.openclaw`) to filesystem tools. ### 5) Secure baseline (copy/paste) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index d3bb0ad9e410..23483076102b 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -36,7 +36,7 @@ If channels are up but nothing answers, check routing and policy before reconnec ```bash openclaw status openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw config get channels openclaw logs --follow ``` @@ -125,7 +125,7 @@ If channel state is connected but message flow is dead, focus on policy, permiss ```bash openclaw channels status --probe -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw status --deep openclaw logs --follow openclaw config get channels @@ -174,6 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design). Related: @@ -289,7 +290,7 @@ Common signatures: ```bash openclaw devices list -openclaw pairing list +openclaw pairing list --channel [--account ] openclaw logs --follow openclaw doctor ``` diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index 2b30b234e242..7144452b2e6c 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -35,6 +35,18 @@ Use `trusted-proxy` auth mode when: 4. OpenClaw extracts the user identity from the configured header 5. If everything checks out, the request is authorized +## Control UI Pairing Behavior + +When `gateway.auth.mode = "trusted-proxy"` is active and the request passes +trusted-proxy checks, Control UI WebSocket sessions can connect without device +pairing identity. + +Implications: + +- Pairing is no longer the primary gate for Control UI access in this mode. +- Your reverse proxy auth policy and `allowUsers` become the effective access control. +- Keep gateway ingress locked to trusted proxy IPs only (`gateway.trustedProxies` + firewall). + ## Configuration ```json5 diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205e..b5c5fa8f24af 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2475,7 +2475,7 @@ Quick setup (recommended): - Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). - Install a per-profile service: `openclaw --profile gateway install`. -Profiles also suffix service names (`bot.molt.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). +Profiles also suffix service names (`ai.openclaw.`; legacy `com.openclaw.*`, `openclaw-gateway-.service`, `OpenClaw Gateway ()`). Full guide: [Multiple gateways](/gateway/multiple-gateways). ### What does invalid handshake code 1008 mean @@ -2705,8 +2705,8 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk: - Default behavior on DM-capable channels is **pairing**: - Unknown senders receive a pairing code; the bot does not process their message. - - Approve with: `openclaw pairing approve ` - - Pending requests are capped at **3 per channel**; check `openclaw pairing list ` if a code didn't arrive. + - Approve with: `openclaw pairing approve --channel [--account ] ` + - Pending requests are capped at **3 per channel**; check `openclaw pairing list --channel [--account ]` if a code didn't arrive. - Opening DMs publicly requires explicit opt-in (`dmPolicy: "open"` and allowlist `"*"`). Run `openclaw doctor` to surface risky DM policies. @@ -2814,6 +2814,19 @@ Send any of these **as a standalone message** (no slash): ``` stop +stop action +stop current action +stop run +stop current run +stop agent +stop the agent +stop openclaw +openclaw stop +stop don't do anything +stop do not do anything +stop doing anything +please stop +stop please abort esc wait diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 83cad80ba32a..c4754da18673 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -62,7 +62,7 @@ flowchart TD openclaw status openclaw gateway status openclaw channels status --probe - openclaw pairing list + openclaw pairing list --channel [--account ] openclaw logs --follow ``` diff --git a/docs/install/docker.md b/docs/install/docker.md index 8826192c1c13..decd1d779ee7 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -368,6 +368,8 @@ precedence, and troubleshooting. - `"rw"` mounts the agent workspace read/write at `/workspace` - Auto-prune: idle > 24h OR age > 7d - Network: `none` by default (explicitly opt-in if you need egress) + - `host` is blocked. + - `container:` is blocked by default (namespace-join risk). - Default allow: `exec`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` - Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway` @@ -376,6 +378,9 @@ precedence, and troubleshooting. If you plan to install packages in `setupCommand`, note: - Default `docker.network` is `"none"` (no egress). +- `docker.network: "host"` is blocked. +- `docker.network: "container:"` is blocked by default. +- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`. - `readOnlyRoot: true` blocks package installs. - `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`). OpenClaw auto-recreates containers when `setupCommand` (or docker config) changes @@ -445,7 +450,8 @@ If you plan to install packages in `setupCommand`, note: Hardening knobs live under `agents.defaults.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, -`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. +`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`, +`dangerouslyAllowContainerNamespaceJoin` (break-glass only). Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*` (ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`). diff --git a/docs/install/nix.md b/docs/install/nix.md index a17e46589a7b..784ca24707aa 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -58,7 +58,7 @@ On macOS, the GUI app does not automatically inherit shell env vars. You can also enable Nix mode via defaults: ```bash -defaults write bot.molt.mac openclaw.nixMode -bool true +defaults write ai.openclaw.mac openclaw.nixMode -bool true ``` ### Config + state paths diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index f5543ce1c45b..09c5587579b4 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -81,14 +81,14 @@ Use this if the gateway service keeps running but `openclaw` is missing. ### macOS (launchd) -Default label is `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may still exist): +Default label is `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may still exist): ```bash -launchctl bootout gui/$UID/bot.molt.gateway -rm -f ~/Library/LaunchAgents/bot.molt.gateway.plist +launchctl bootout gui/$UID/ai.openclaw.gateway +rm -f ~/Library/LaunchAgents/ai.openclaw.gateway.plist ``` -If you used a profile, replace the label and plist name with `bot.molt.`. Remove any legacy `com.openclaw.*` plists if present. +If you used a profile, replace the label and plist name with `ai.openclaw.`. Remove any legacy `com.openclaw.*` plists if present. ### Linux (systemd user unit) diff --git a/docs/install/updating.md b/docs/install/updating.md index 6606a933b7dc..f94c26007765 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -196,7 +196,7 @@ openclaw logs --follow If you’re supervised: -- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/bot.molt.gateway` (use `bot.molt.`; legacy `com.openclaw.*` still works) +- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/ai.openclaw.gateway` (use `ai.openclaw.`; legacy `com.openclaw.*` still works) - Linux systemd user service: `systemctl --user restart openclaw-gateway[-].service` - Windows (WSL2): `systemctl --user restart openclaw-gateway[-].service` - `launchctl`/`systemctl` only work if the service is installed; otherwise run `openclaw gateway install`. diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 0f37c275cd3c..ec2663aefe40 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -49,5 +49,5 @@ Use one of these (all supported): The service target depends on OS: -- macOS: LaunchAgent (`bot.molt.gateway` or `bot.molt.`; legacy `com.openclaw.*`) +- macOS: LaunchAgent (`ai.openclaw.gateway` or `ai.openclaw.`; legacy `com.openclaw.*`) - Linux/WSL2: systemd user service (`openclaw-gateway[-].service`) diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 54064656dca3..6cb878015fb4 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -28,12 +28,12 @@ The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun no Label: -- `bot.molt.gateway` (or `bot.molt.`; legacy `com.openclaw.*` may remain) +- `ai.openclaw.gateway` (or `ai.openclaw.`; legacy `com.openclaw.*` may remain) Plist location (per‑user): -- `~/Library/LaunchAgents/bot.molt.gateway.plist` - (or `~/Library/LaunchAgents/bot.molt..plist`) +- `~/Library/LaunchAgents/ai.openclaw.gateway.plist` + (or `~/Library/LaunchAgents/ai.openclaw..plist`) Manager: diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index e009a58257c1..b65ca5f0d9d4 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -18,8 +18,8 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. ## Default behavior (launchd) -- The app installs a per‑user LaunchAgent labeled `bot.molt.gateway` - (or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). +- The app installs a per‑user LaunchAgent labeled `ai.openclaw.gateway` + (or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` is supported). - When Local mode is enabled, the app ensures the LaunchAgent is loaded and starts the Gateway if needed. - Logs are written to the launchd gateway log path (visible in Debug Settings). @@ -27,11 +27,11 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. Common commands: ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. ## Unsigned dev builds diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 8aff51348862..e50a850086ac 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -84,7 +84,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* 1. Reset the TCC permissions: ```bash - tccutil reset All bot.molt.mac.debug + tccutil reset All ai.openclaw.mac.debug ``` 2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS. diff --git a/docs/platforms/mac/logging.md b/docs/platforms/mac/logging.md index c1abf717cc95..5e1af460e3c7 100644 --- a/docs/platforms/mac/logging.md +++ b/docs/platforms/mac/logging.md @@ -26,12 +26,12 @@ Notes: Unified logging redacts most payloads unless a subsystem opts into `privacy -off`. Per Peter's write-up on macOS [logging privacy shenanigans](https://steipete.me/posts/2025/logging-privacy-shenanigans) (2025) this is controlled by a plist in `/Library/Preferences/Logging/Subsystems/` keyed by the subsystem name. Only new log entries pick up the flag, so enable it before reproducing an issue. -## Enable for OpenClaw (`bot.molt`) +## Enable for OpenClaw (`ai.openclaw`) - Write the plist to a temp file first, then install it atomically as root: ```bash -cat <<'EOF' >/tmp/bot.molt.plist +cat <<'EOF' >/tmp/ai.openclaw.plist @@ -44,7 +44,7 @@ cat <<'EOF' >/tmp/bot.molt.plist EOF -sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Logging/Subsystems/bot.molt.plist +sudo install -m 644 -o root -g wheel /tmp/ai.openclaw.plist /Library/Preferences/Logging/Subsystems/ai.openclaw.plist ``` - No reboot is required; logd notices the file quickly, but only new log lines will include private payloads. @@ -52,6 +52,6 @@ sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Lo ## Disable after debugging -- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/bot.molt.plist`. +- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/ai.openclaw.plist`. - Optionally run `sudo log config --reload` to force logd to drop the override immediately. - Remember this surface can include phone numbers and message bodies; keep the plist in place only while you actively need the extra detail. diff --git a/docs/platforms/mac/permissions.md b/docs/platforms/mac/permissions.md index 12f75eb9f511..e749ecf9d771 100644 --- a/docs/platforms/mac/permissions.md +++ b/docs/platforms/mac/permissions.md @@ -35,8 +35,8 @@ grants, and prompts can disappear entirely until the stale entries are cleared. Example resets (replace bundle ID as needed): ```bash -sudo tccutil reset Accessibility bot.molt.mac -sudo tccutil reset ScreenCapture bot.molt.mac +sudo tccutil reset Accessibility ai.openclaw.mac +sudo tccutil reset ScreenCapture ai.openclaw.mac sudo tccutil reset AppleEvents ``` diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 029ab3eed934..978e79ff4806 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -33,33 +33,33 @@ Notes: ```bash # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +BUNDLE_ID=ai.openclaw.mac \ +APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.25.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: # xcrun notarytool store-credentials "openclaw-notary" \ # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +BUNDLE_ID=ai.openclaw.mac \ +APP_VERSION=2026.2.25 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.25.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.25.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. +- Upload `OpenClaw-2026.2.25.zip` (and `OpenClaw-2026.2.25.dSYM.zip`) to the GitHub release for tag `v2026.2.25`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/docs/platforms/mac/voice-overlay.md b/docs/platforms/mac/voice-overlay.md index 9c42601b1865..86f02d9ed244 100644 --- a/docs/platforms/mac/voice-overlay.md +++ b/docs/platforms/mac/voice-overlay.md @@ -37,7 +37,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Push-to-talk: no delay; wake-word: optional delay for auto-send. - Apply a short cooldown to the wake runtime after push-to-talk finishes so wake-word doesn’t immediately retrigger. 5. **Logging** - - Coordinator emits `.info` logs in subsystem `bot.molt`, categories `voicewake.overlay` and `voicewake.chime`. + - Coordinator emits `.info` logs in subsystem `ai.openclaw`, categories `voicewake.overlay` and `voicewake.chime`. - Key events: `session_started`, `adopted_by_push_to_talk`, `partial`, `finalized`, `send`, `dismiss`, `cancel`, `cooldown`. ## Debugging checklist @@ -45,7 +45,7 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Stream logs while reproducing a sticky overlay: ```bash - sudo log stream --predicate 'subsystem == "bot.molt" AND category CONTAINS "voicewake"' --level info --style compact + sudo log stream --predicate 'subsystem == "ai.openclaw" AND category CONTAINS "voicewake"' --level info --style compact ``` - Verify only one active session token; stale callbacks should be dropped by the coordinator. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index ea6791ff50e8..11b500a8596d 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -24,7 +24,7 @@ agent (with a session switcher for other sessions). dist/OpenClaw.app/Contents/MacOS/OpenClaw --webchat ``` -- Logs: `./scripts/clawlog.sh` (subsystem `bot.molt`, category `WebChatSwiftUI`). +- Logs: `./scripts/clawlog.sh` (subsystem `ai.openclaw`, category `WebChatSwiftUI`). ## How it’s wired diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index a9327970261a..04c61df266af 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -34,15 +34,15 @@ capabilities to the agent as a node. ## Launchd control -The app manages a per‑user LaunchAgent labeled `bot.molt.gateway` -(or `bot.molt.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). +The app manages a per‑user LaunchAgent labeled `ai.openclaw.gateway` +(or `ai.openclaw.` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads). ```bash -launchctl kickstart -k gui/$UID/bot.molt.gateway -launchctl bootout gui/$UID/bot.molt.gateway +launchctl kickstart -k gui/$UID/ai.openclaw.gateway +launchctl bootout gui/$UID/ai.openclaw.gateway ``` -Replace the label with `bot.molt.` when running a named profile. +Replace the label with `ai.openclaw.` when running a named profile. If the LaunchAgent isn’t installed, enable it from the app or run `openclaw gateway install`. diff --git a/docs/plugins/community.md b/docs/plugins/community.md index c135381676ce..94c6ddbe00d8 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -42,3 +42,10 @@ Use this format when adding entries: npm: `@scope/package` repo: `https://github.com/org/repo` install: `openclaw plugins install @scope/package` + +## Listed plugins + +- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. + npm: `@icesword760/openclaw-wechat` + repo: `https://github.com/icesword0760/openclaw-wechat` + install: `openclaw plugins install @icesword760/openclaw-wechat` diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 8233fd9de3aa..67561e4a21b4 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -9,6 +9,10 @@ read_when: # Prompt caching +Prompt caching means the model provider can reuse unchanged prompt prefixes (usually system/developer instructions and other stable context) across turns instead of re-processing them every time. The first matching request writes cache tokens (`cacheWrite`), and later matching requests can read them back (`cacheRead`). + +Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. + This page covers all cache-related knobs that affect prompt reuse and token cost. For Anthropic pricing details, see: diff --git a/docs/reference/test.md b/docs/reference/test.md index 91db2244bd04..e369b4da7adf 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -15,6 +15,19 @@ title: "Tests" - `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. - `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. +## Local PR gate + +For local PR land/gate checks, run: + +- `pnpm check` +- `pnpm build` +- `pnpm test` +- `pnpm check:docs` + +If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run `. For memory-constrained hosts, use: + +- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` + ## Model latency bench (local keys) Script: [`scripts/bench-model.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/bench-model.ts) diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6a..058f2fa67fef 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,6 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. +- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a73..964eb40f37b5 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -77,6 +77,18 @@ openclaw browser create-profile \ --color "#00AA00" ``` +### Custom Gateway ports + +If you're using a custom gateway port, the extension relay port is automatically derived: + +**Extension Relay Port = Gateway Port + 3** + +Example: if `gateway.port: 19001`, then: + +- Extension relay port: `19004` (gateway + 3) + +Configure the extension to use the derived relay port in the extension Options page. + ## Attach / detach (toolbar button) - Open the tab you want OpenClaw to control. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0e6d0f528993..619f5cdb38e5 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -165,6 +165,10 @@ and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOM used to smuggle file reads. Safe bins must also resolve from trusted binary directories (system defaults plus optional `tools.exec.safeBinTrustedDirs`). `PATH` entries are never auto-trusted. +Default trusted safe-bin directories are intentionally minimal: `/bin`, `/usr/bin`. +If your safe-bin executable lives in package-manager/user paths (for example +`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/snap/bin`), add them explicitly +to `tools.exec.safeBinTrustedDirs`. Shell chaining and redirections are not auto-allowed in allowlist mode. Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist @@ -178,7 +182,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper -paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically. +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`, +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 1dc5cc4fc1de..822717fcf382 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -36,6 +36,8 @@ Notes: - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`) from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists. +- On Windows hosts, exec prefers PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, then PATH), + then falls back to Windows PowerShell 5.1. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. - Important: sandboxing is **off by default**. If sandboxing is off and `host=sandbox` is explicitly @@ -55,7 +57,7 @@ Notes: - `tools.exec.node` (default: unset) - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only). - `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only). -- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. +- `tools.exec.safeBinTrustedDirs`: additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. Built-in defaults are `/bin` and `/usr/bin`. - `tools.exec.safeBinProfiles`: optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). Example: diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccdb..269b6856d038 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -478,6 +478,7 @@ Notes: - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - If `thread: true` and `mode` is omitted, mode defaults to `session`. - `mode: "session"` requires `thread: true`. + - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - Reply format includes `Status`, `Result`, and compact stats. - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 7334da1ec401..9542858c8402 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -71,6 +71,7 @@ Use `sessions_spawn`: - Then runs an announce step and posts the announce reply to the requester chat channel - Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. - Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- Default run timeout: if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). Tool params: @@ -79,7 +80,7 @@ Tool params: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, the sub-agent run is aborted after N seconds) - `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session) - `mode?` (`run|session`) - default is `run` @@ -148,6 +149,7 @@ By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). Y maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) + runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout) }, }, }, diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b1ff11c32436..ad6d2393523a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -99,7 +99,7 @@ Cron jobs panel notes: - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band + - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: - When a run is aborted, partial assistant text can still be shown in the UI diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 4bb727ed27fa..fb05912e69dd 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "openclaw": { diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fef..904d21d4d3f2 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 7ebab0485df7..d6b12d311f88 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -268,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 3b8850f21540..6ccb043845f0 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, @@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment( url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe16..e4bef3fd73bb 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a0..72ccd9918570 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; }; diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index d8e6d9d50a6c..8dd561f27f3f 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 84ba07a684db..32c5ad8275d7 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,14 +1,14 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.212.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.212.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 8189ecaec8c7..ab3fb57e15aa 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -51,11 +51,11 @@ vi.mock("@opentelemetry/sdk-node", () => ({ }, })); -vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({ OTLPMetricExporter: class {}, })); -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({ OTLPTraceExporter: class { constructor(options?: unknown) { traceExporterCtor(options); @@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ }, })); -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({ OTLPLogExporter: class {}, })); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 0749708c8810..be9a547963f1 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1,8 +1,8 @@ import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; import type { SeverityNumber } from "@opentelemetry/api-logs"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; @@ -657,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { }); if (logsEnabled) { - ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)"); } }, async stop() { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index c62035fd375e..2553b1c08140 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1aea7e06425a..ca85938c6911 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 91d390ac04dc..f18658e62b50 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -720,10 +720,10 @@ export async function handleFeishuMessage(params: { // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; + let topicSessionMode: "enabled" | "disabled" = "disabled"; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - const topicSessionMode = - groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; @@ -739,6 +739,14 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, + // Add parentPeer for binding inheritance in topic mode + parentPeer: + isGroup && ctx.rootId && topicSessionMode === "enabled" + ? { + kind: "group", + id: ctx.chatId, + } + : null, }); // Dynamic agent creation for DM users diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 5851e849037e..fc600481e85b 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); @@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void { expect(pathValue).not.toContain(key); expect(pathValue).not.toContain(".."); - const tmpRoot = path.resolve(os.tmpdir()); + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const resolved = path.resolve(pathValue); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index ec461eba92e3..9ec1c1af3608 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 386228d9306e..380de2eabc85 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,11 +1,11 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { - "google-auth-library": "^10.5.0" + "google-auth-library": "^10.6.1" }, "peerDependencies": { "openclaw": ">=2026.1.26" diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index cedfc9b09ece..7eeafd8b8722 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 268d91cb5fe0..e5937ee763b7 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw IRC channel plugin", "type": "module", "openclaw": { diff --git a/extensions/irc/src/inbound.policy.test.ts b/extensions/irc/src/inbound.policy.test.ts new file mode 100644 index 000000000000..c5b6cdfac897 --- /dev/null +++ b/extensions/irc/src/inbound.policy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./inbound.js"; + +describe("irc inbound policy", () => { + it("keeps DM allowlist merged with pairing-store entries", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); + }); + + it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: ["group-owner"], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("does not grant group access from pairing-store when groupAllowFrom is empty", () => { + const resolved = __testing.resolveIrcEffectiveAllowlists({ + configAllowFrom: ["owner"], + configGroupAllowFrom: [], + storeAllowList: ["paired-user"], + }); + + expect(resolved.effectiveGroupAllowFrom).toEqual([]); + }); +}); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 26d0aa85927a..efb0b781d4a3 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -31,6 +31,20 @@ const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function resolveIrcEffectiveAllowlists(params: { + configAllowFrom: string[]; + configGroupAllowFrom: string[]; + storeAllowList: string[]; +}): { + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; +} { + const effectiveAllowFrom = [...params.configAllowFrom, ...params.storeAllowList].filter(Boolean); + // Pairing-store entries are DM approvals and must not widen group sender authorization. + const effectiveGroupAllowFrom = [...params.configGroupAllowFrom].filter(Boolean); + return { effectiveAllowFrom, effectiveGroupAllowFrom }; +} + async function deliverIrcReply(params: { payload: OutboundReplyPayload; target: string; @@ -123,8 +137,11 @@ export async function handleIrcInbound(params: { const groupAllowFrom = directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean); + const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({ + configAllowFrom, + configGroupAllowFrom, + storeAllowList, + }); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, @@ -344,3 +361,7 @@ export async function handleIrcInbound(params: { }, }); } + +export const __testing = { + resolveIrcEffectiveAllowlists, +}; diff --git a/extensions/line/package.json b/extensions/line/package.json index 4903a1323978..0d026d39c452 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 44dc1b46fe11..9e182b901345 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 87588d7adbd1..6a58118618cd 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; // NOTE: This extension is intended to be bundled with OpenClaw. // When running from source (tests/dev), OpenClaw internals live under src/. // When running from a built install, internals live under dist/ (no src/ tree). @@ -180,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { let tmpDir: string | null = null; try { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + tmpDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"), + ); const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 4fdbe8bd8877..f60a1ff73a65 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.23", + "version": "2026.2.25", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index b78bc85cecdb..8a659e40ab0a 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 3c7cd96588a8..3e1d1ce95cde 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts new file mode 100644 index 000000000000..3754cfd178e7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -0,0 +1,141 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixAuth } from "../client.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import type { MatrixRawEvent } from "./types.js"; + +const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../send.js", () => ({ + sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args), +})); + +describe("registerMatrixMonitorEvents", () => { + beforeEach(() => { + sendReadReceiptMatrixMock.mockClear(); + }); + + function createHarness(options?: { getUserId?: ReturnType }) { + const handlers = new Map void>(); + const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org"); + const client = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + getUserId, + crypto: undefined, + } as unknown as MatrixClient; + + const onRoomMessage = vi.fn(); + const logVerboseMessage = vi.fn(); + const logger = { + warn: vi.fn(), + } as unknown as RuntimeLogger; + + registerMatrixMonitorEvents({ + client, + auth: { encryption: false } as MatrixAuth, + logVerboseMessage, + warnedEncryptedRooms: new Set(), + warnedCryptoMissingRooms: new Set(), + logger, + formatNativeDependencyHint: (() => + "") as PluginRuntime["system"]["formatNativeDependencyHint"], + onRoomMessage, + }); + + const roomMessageHandler = handlers.get("room.message"); + if (!roomMessageHandler) { + throw new Error("missing room.message handler"); + } + + return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage }; + } + + it("sends read receipt immediately for non-self messages", async () => { + const { client, onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e1", + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client); + }); + }); + + it("does not send read receipts for self messages", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + event_id: "$e2", + sender: "@bot:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); + + it("skips receipt when message lacks sender or event id", async () => { + const { onRoomMessage, roomMessageHandler } = createHarness(); + const event = { + sender: "@alice:example.org", + } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); + + it("caches self user id across messages", async () => { + const { getUserId, roomMessageHandler } = createHarness(); + const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent; + const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", first); + roomMessageHandler("!room:example.org", second); + + await vi.waitFor(() => { + expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + }); + + it("logs and continues when sending read receipt fails", async () => { + sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom")); + const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness(); + const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("matrix: early read receipt failed"), + ); + }); + }); + + it("skips read receipts if self-user lookup fails", async () => { + const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({ + getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")), + }); + const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent; + + roomMessageHandler("!room:example.org", event); + + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + }); + expect(getUserId).toHaveBeenCalledTimes(1); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 60bbe574add9..279517d521d8 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,9 +1,36 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { MatrixAuth } from "../client.js"; +import { sendReadReceiptMatrix } from "../send.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; +function createSelfUserIdResolver(client: Pick) { + let selfUserId: string | undefined; + let selfUserIdLookup: Promise | undefined; + + return async (): Promise => { + if (selfUserId) { + return selfUserId; + } + if (!selfUserIdLookup) { + selfUserIdLookup = client + .getUserId() + .then((userId) => { + selfUserId = userId; + return userId; + }) + .catch(() => undefined) + .finally(() => { + if (!selfUserId) { + selfUserIdLookup = undefined; + } + }); + } + return await selfUserIdLookup; + }; +} + export function registerMatrixMonitorEvents(params: { client: MatrixClient; auth: MatrixAuth; @@ -25,7 +52,26 @@ export function registerMatrixMonitorEvents(params: { onRoomMessage, } = params; - client.on("room.message", onRoomMessage); + const resolveSelfUserId = createSelfUserIdResolver(client); + client.on("room.message", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id; + const senderId = event?.sender; + if (eventId && senderId) { + void (async () => { + const currentSelfUserId = await resolveSelfUserId(); + if (!currentSelfUserId || senderId === currentSelfUserId) { + return; + } + await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => { + logVerboseMessage( + `matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`, + ); + }); + })(); + } + + onRoomMessage(roomId, event); + }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id ?? "unknown"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index d884879001ef..77e88162af35 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -18,12 +18,7 @@ import { parsePollStartContent, type PollStartContent, } from "../poll-types.js"; -import { - reactMatrixMessage, - sendMessageMatrix, - sendReadReceiptMatrix, - sendTypingMatrix, -} from "../send.js"; +import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, @@ -602,14 +597,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - if (messageId) { - sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { - logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, - ); - }); - } - let didSendReply = false; const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, @@ -648,6 +635,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { await deliverMatrixReplies({ replies: [payload], @@ -665,8 +653,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onError: (err, info) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, }); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b58..dfbfbabb8af2 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => { ); }); + it("skips reasoning-only replies with Reasoning prefix", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, + { text: "Here is the answer.", replyToId: "r2" }, + ], + roomId: "room:reason", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "first", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); + }); + + it("skips reasoning-only replies with thinking tags", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "internal chain of thought", replyToId: "r1" }, + { text: " more reasoning ", replyToId: "r2" }, + { text: "hidden", replyToId: "r3" }, + { text: "Visible reply", replyToId: "r4" }, + ], + roomId: "room:tags", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); + }); + + it("delivers all replies when none are reasoning-only", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "First answer", replyToId: "r1" }, + { text: "Second answer", replyToId: "r2" }, + ], + roomId: "room:normal", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + }); + it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 643e95cd4134..c86c7dde688c 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } + // Skip pure reasoning messages so internal thinking traces are never delivered. + if (reply.text && isReasoningOnlyMessage(reply.text)) { + logVerbose("matrix reply is reasoning-only; skipping"); + continue; + } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; const rawText = reply.text ?? ""; @@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: { } } } + +const REASONING_PREFIX = "Reasoning:\n"; +const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; + +/** + * Detect messages that contain only reasoning/thinking content and no user-facing answer. + * These are emitted by the agent when `includeReasoning` is active but should not + * be forwarded to channels that do not support a dedicated reasoning lane. + */ +function isReasoningOnlyMessage(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith(REASONING_PREFIX)) { + return true; + } + if (THINKING_TAG_RE.test(trimmed)) { + return true; + } + return false; +} diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts new file mode 100644 index 000000000000..aa4765eaab34 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("enqueueSend", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("serializes sends per room", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + events.push("end1"); + return "one"; + }); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + events.push("end2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + expect(events).toEqual(["start1"]); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2); + expect(events).toEqual(["start1"]); + + gate.resolve(); + await first; + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1); + expect(events).toEqual(["start1", "end1"]); + await vi.advanceTimersByTimeAsync(1); + await second; + expect(events).toEqual(["start1", "end1", "start2", "end2"]); + }); + + it("does not serialize across different rooms", async () => { + const events: string[] = []; + + const a = enqueueSend("!a:example.org", async () => { + events.push("a"); + return "a"; + }); + const b = enqueueSend("!b:example.org", async () => { + events.push("b"); + return "b"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await Promise.all([a, b]); + expect(events.sort()).toEqual(["a", "b"]); + }); + + it("continues queue after failures", async () => { + const first = enqueueSend("!room:example.org", async () => { + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected first queue item to fail"); + } + expect(firstResult.error).toBeInstanceOf(Error); + expect(firstResult.error.message).toBe("boom"); + + const second = enqueueSend("!room:example.org", async () => "ok"); + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await expect(second).resolves.toBe("ok"); + }); + + it("continues queued work when the head task fails", async () => { + const gate = deferred(); + const events: string[] = []; + + const first = enqueueSend("!room:example.org", async () => { + events.push("start1"); + await gate.promise; + throw new Error("boom"); + }).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + const second = enqueueSend("!room:example.org", async () => { + events.push("start2"); + return "two"; + }); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + expect(events).toEqual(["start1"]); + + gate.resolve(); + const firstResult = await first; + expect(firstResult.ok).toBe(false); + if (firstResult.ok) { + throw new Error("expected head queue item to fail"); + } + expect(firstResult.error).toBeInstanceOf(Error); + + await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["start1", "start2"]); + }); + + it("supports custom gap and delay injection", async () => { + const events: string[] = []; + const delayFn = vi.fn(async (_ms: number) => {}); + + const first = enqueueSend( + "!room:example.org", + async () => { + events.push("first"); + return "one"; + }, + { gapMs: 7, delayFn }, + ); + const second = enqueueSend( + "!room:example.org", + async () => { + events.push("second"); + return "two"; + }, + { gapMs: 7, delayFn }, + ); + + await expect(first).resolves.toBe("one"); + await expect(second).resolves.toBe("two"); + expect(events).toEqual(["first", "second"]); + expect(delayFn).toHaveBeenCalledTimes(2); + expect(delayFn).toHaveBeenNthCalledWith(1, 7); + expect(delayFn).toHaveBeenNthCalledWith(2, 7); + }); +}); diff --git a/extensions/matrix/src/matrix/send-queue.ts b/extensions/matrix/src/matrix/send-queue.ts new file mode 100644 index 000000000000..daf5e40931e8 --- /dev/null +++ b/extensions/matrix/src/matrix/send-queue.ts @@ -0,0 +1,44 @@ +export const DEFAULT_SEND_GAP_MS = 150; + +type MatrixSendQueueOptions = { + gapMs?: number; + delayFn?: (ms: number) => Promise; +}; + +// Serialize sends per room to preserve Matrix delivery order. +const roomQueues = new Map>(); + +export async function enqueueSend( + roomId: string, + fn: () => Promise, + options?: MatrixSendQueueOptions, +): Promise { + const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS; + const delayFn = options?.delayFn ?? delay; + const previous = roomQueues.get(roomId) ?? Promise.resolve(); + + const next = previous + .catch(() => {}) + .then(async () => { + await delayFn(gapMs); + return await fn(); + }); + + const queueMarker = next.then( + () => {}, + () => {}, + ); + roomQueues.set(roomId, queueMarker); + + queueMarker.finally(() => { + if (roomQueues.get(roomId) === queueMarker) { + roomQueues.delete(roomId); + } + }); + + return await next; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index b531b55dcdaf..dd72ec2883b3 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -2,6 +2,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "openclaw/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; +import { enqueueSend } from "./send-queue.js"; import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; import { buildReplyRelation, @@ -49,103 +50,105 @@ export async function sendMessageMatrix( }); try { const roomId = await resolveMatrixRoomId(client, to); - const cfg = getCore().config.loadConfig(); - const tableMode = getCore().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix", - accountId: opts.accountId, - }); - const convertedMessage = getCore().channel.text.convertMarkdownTables( - trimmedMessage, - tableMode, - ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); - const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); - const chunks = getCore().channel.text.chunkMarkdownTextWithMode( - convertedMessage, - chunkLimit, - chunkMode, - ); - const threadId = normalizeThreadId(opts.threadId); - const relation = threadId - ? buildThreadRelation(threadId, opts.replyToId) - : buildReplyRelation(opts.replyToId); - const sendContent = async (content: MatrixOutboundContent) => { - // @vector-im/matrix-bot-sdk uses sendMessage differently - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; - - let lastMessageId = ""; - if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { - contentType: media.contentType, - filename: media.fileName, - }); - const durationMs = await resolveMediaDurationMs({ - buffer: media.buffer, - contentType: media.contentType, - fileName: media.fileName, - kind: media.kind, + return await enqueueSend(roomId, async () => { + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, }); - const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); - const { useVoice } = resolveMatrixVoiceDecision({ - wantsVoice: opts.audioAsVoice === true, - contentType: media.contentType, - fileName: media.fileName, - }); - const msgtype = useVoice ? MsgType.Audio : baseMsgType; - const isImage = msgtype === MsgType.Image; - const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) - : undefined; - const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); - const content = buildMediaContent({ - msgtype, - body, - url: uploaded.url, - file: uploaded.file, - filename: media.fileName, - mimetype: media.contentType, - size: media.buffer.byteLength, - durationMs, - relation, - isVoice: useVoice, - imageInfo, - }); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - const textChunks = useVoice ? chunks : rest; - const followupRelation = threadId ? relation : undefined; - for (const chunk of textChunks) { - const text = chunk.trim(); - if (!text) { - continue; - } - const followup = buildTextContent(text, followupRelation); - const followupEventId = await sendContent(followup); - lastMessageId = followupEventId ?? lastMessageId; - } - } else { - for (const chunk of chunks.length ? chunks : [""]) { - const text = chunk.trim(); - if (!text) { - continue; - } - const content = buildTextContent(text, relation); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + // @vector-im/matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(opts.accountId); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind, + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) { + continue; + } + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) { + continue; + } + const content = buildTextContent(text, relation); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + } } - } - return { - messageId: lastMessageId || "unknown", - roomId, - }; + return { + messageId: lastMessageId || "unknown", + roomId, + }; + }); } finally { if (stopOnDone) { client.stop(); diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e73e289d4045..77ae9df46394 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Mattermost channel plugin", "type": "module", "openclaw": { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index fe799a295c93..6056c3fef156 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -768,6 +768,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); @@ -804,7 +805,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onError: (err, info) => { runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: typingCallbacks.onReplyStart, }); await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index c2e1bb451663..98bdbe76f73a 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index ed21817badc6..a658940881ee 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,13 +1,13 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", "dependencies": { "@lancedb/lancedb": "^0.26.2", "@sinclair/typebox": "0.34.48", - "openai": "^6.22.0" + "openai": "^6.25.0" }, "openclaw": { "extensions": [ diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index b70bfd3adfac..4a0dfc6121da 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index d2bdbcaf47d0..5bd503fead20 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index bc1aebf3adc2..b90fba51b9a7 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts new file mode 100644 index 000000000000..124599147a86 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -0,0 +1,96 @@ +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; +import { setMSTeamsRuntime } from "../runtime.js"; +import { createMSTeamsMessageHandler } from "./message-handler.js"; + +describe("msteams monitor handler authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); + setMSTeamsRuntime({ + logging: { shouldLogVerbose: () => false }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: (params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + enqueue: async (entry: T) => { + await params.onFlush([entry]); + }, + }), + }, + pairing: { + readAllowFromStore, + upsertPairingRequest: vi.fn(async () => null), + }, + text: { + hasControlCommand: () => false, + }, + }, + } as unknown as PluginRuntime); + + const conversationStore = { + upsert: vi.fn(async () => undefined), + }; + + const deps: MSTeamsMessageHandlerDeps = { + cfg: { + channels: { + msteams: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + } as OpenClawConfig, + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + appId: "test-app", + adapter: {} as MSTeamsMessageHandlerDeps["adapter"], + tokenProvider: { + getAccessToken: vi.fn(async () => "token"), + }, + textLimit: 4000, + mediaMaxBytes: 1024 * 1024, + conversationStore: + conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"], + pollStore: { + recordVote: vi.fn(async () => null), + } as unknown as MSTeamsMessageHandlerDeps["pollStore"], + log: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as MSTeamsMessageHandlerDeps["log"], + }; + + const handler = createMSTeamsMessageHandler(deps); + await handler({ + activity: { + id: "msg-1", + type: "message", + text: "", + from: { + id: "attacker-id", + aadObjectId: "attacker-aad", + name: "Attacker", + }, + recipient: { + id: "bot-id", + name: "Bot", + }, + conversation: { + id: "19:group@thread.tacv2", + conversationType: "groupChat", + }, + channelData: {}, + attachments: [], + }, + sendActivity: vi.fn(async () => undefined), + } as unknown as Parameters[0]); + + expect(readAllowFromStore).toHaveBeenCalledWith("msteams"); + expect(conversationStore.upsert).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 085efeeb0a88..a87f704a3405 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -135,7 +135,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { // Check DM policy for direct messages. const dmAllowFrom = msteamsCfg?.allowFrom ?? []; - const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom]; + const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v)); + const effectiveDmAllowFrom = [...configuredDmAllowFrom, ...storedAllowFrom]; if (isDirectMessage && msteamsCfg) { const allowFrom = dmAllowFrom; @@ -189,9 +190,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : [])) : []; const effectiveGroupAllowFrom = - !isDirectMessage && msteamsCfg - ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom] - : []; + !isDirectMessage && msteamsCfg ? groupAllowFrom.map((v) => String(v)) : []; const teamId = activity.channelData?.team?.id; const teamName = activity.channelData?.team?.name; const channelName = activity.channelData?.channel?.name; @@ -248,9 +247,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom; const ownerAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, senderId, senderName, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), @@ -266,7 +266,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 55389f2f6960..36d611c39dad 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -68,6 +68,7 @@ export function createMSTeamsReplyDispatcher(params: { core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + typingCallbacks, deliver: async (payload) => { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: params.cfg, @@ -121,7 +122,6 @@ export function createMSTeamsReplyDispatcher(params: { hint, }); }, - onReplyStart: typingCallbacks.onReplyStart, }); return { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 0894642126e8..fdc3b97e9c36 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "openclaw": { diff --git a/extensions/nextcloud-talk/src/inbound.authz.test.ts b/extensions/nextcloud-talk/src/inbound.authz.test.ts new file mode 100644 index 000000000000..88a655ec4426 --- /dev/null +++ b/extensions/nextcloud-talk/src/inbound.authz.test.ts @@ -0,0 +1,81 @@ +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { setNextcloudTalkRuntime } from "./runtime.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; + +describe("nextcloud-talk inbound authz", () => { + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const readAllowFromStore = vi.fn(async () => ["attacker"]); + const buildMentionRegexes = vi.fn(() => [/@openclaw/i]); + + setNextcloudTalkRuntime({ + channel: { + pairing: { + readAllowFromStore, + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns: () => false, + }, + }, + } as unknown as PluginRuntime); + + const message: NextcloudTalkInboundMessage = { + messageId: "m-1", + roomToken: "room-1", + roomName: "Room 1", + senderId: "attacker", + senderName: "Attacker", + text: "hello", + mediaType: "text/plain", + timestamp: Date.now(), + isGroupChat: true, + }; + + const account: ResolvedNextcloudTalkAccount = { + accountId: "default", + enabled: true, + baseUrl: "", + secret: "", + secretSource: "none", + config: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }; + + const config: CoreConfig = { + channels: { + "nextcloud-talk": { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + }; + + await handleNextcloudTalkInbound({ + message, + account, + config, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeEnv, + }); + + expect(readAllowFromStore).toHaveBeenCalledWith("nextcloud-talk"); + expect(buildMentionRegexes).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index dcef6aa93822..526249aa9772 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -122,7 +122,7 @@ export async function handleNextcloudTalkInbound(params: { configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean); + const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts new file mode 100644 index 000000000000..f2b4b65054d9 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts @@ -0,0 +1,73 @@ +import { type AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; + +type WebhookHarness = { + webhookUrl: string; + stop: () => Promise; +}; + +const cleanupFns: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupFns.length > 0) { + const cleanup = cleanupFns.pop(); + if (cleanup) { + await cleanup(); + } + } +}); + +async function startWebhookServer(params: { + path: string; + maxBodyBytes: number; + readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; +}): Promise { + const { server, start } = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: params.path, + secret: "nextcloud-secret", + maxBodyBytes: params.maxBodyBytes, + readBody: params.readBody, + onMessage: vi.fn(), + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + return { + webhookUrl: `http://127.0.0.1:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe("createNextcloudTalkWebhookServer auth order", () => { + it("rejects missing signature headers before reading request body", async () => { + const readBody = vi.fn(async () => { + throw new Error("should not be called for missing signature headers"); + }); + const harness = await startWebhookServer({ + path: "/nextcloud-auth-order", + maxBodyBytes: 128, + readBody, + }); + cleanupFns.push(harness.stop); + + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: "{}", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Missing signature headers" }); + expect(readBody).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index b7daac4d07c3..4b68a3c4d0b8 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -92,6 +92,7 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe opts.maxBodyBytes > 0 ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES; + const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -107,8 +108,6 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); - const headers = extractNextcloudTalkHeaders( req.headers as Record, ); @@ -118,6 +117,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe return; } + const body = await readBody(req, maxBodyBytes); + const isValid = verifyNextcloudTalkSignature({ signature: headers.signature, random: headers.random, diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index ecdbe8437ae4..a9fe49be36da 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -169,6 +169,7 @@ export type NextcloudTalkWebhookServerOptions = { path: string; secret: string; maxBodyBytes?: number; + readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; onError?: (error: Error) => void; abortSignal?: AbortSignal; diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index c6299cfc5c3b..6f94eb00a46f 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 96a9703a3f9f..2cf8be1831f2 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 038a5d7f3a7f..4d28edc8e68a 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 9c31df4a9835..1005503eff19 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index b78bf31d2926..adbd311981f8 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index fbdac9b40416..e4474651f07e 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.23", + "version": "2026.2.25", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts new file mode 100644 index 000000000000..6005cbd923b2 --- /dev/null +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RegisteredRoute = { + path: string; + accountId: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise; +}; + +const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn()); +const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} }); + +vi.mock("openclaw/plugin-sdk", () => ({ + DEFAULT_ACCOUNT_ID: "default", + setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})), + registerPluginHttpRoute: registerPluginHttpRouteMock, + buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })), +})); + +vi.mock("./runtime.js", () => ({ + getSynologyRuntime: vi.fn(() => ({ + config: { loadConfig: vi.fn().mockResolvedValue({}) }, + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher, + }, + }, + })), +})); + +vi.mock("./client.js", () => ({ + sendMessage: vi.fn().mockResolvedValue(true), + sendFileUrl: vi.fn().mockResolvedValue(true), +})); + +const { createSynologyChatPlugin } = await import("./channel.js"); + +function makeReq(method: string, body: string): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.socket = { remoteAddress: "127.0.0.1" } as any; + process.nextTick(() => { + req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + return req; +} + +function makeRes(): ServerResponse & { _status: number; _body: string } { + const res = { + _status: 0, + _body: "", + writeHead(statusCode: number, _headers: Record) { + res._status = statusCode; + }, + end(body?: string) { + res._body = body ?? ""; + }, + } as any; + return res; +} + +function makeFormBody(fields: Record): string { + return Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); +} + +describe("Synology channel wiring integration", () => { + beforeEach(() => { + registerPluginHttpRouteMock.mockClear(); + dispatchReplyWithBufferedBlockDispatcher.mockClear(); + }); + + it("registers real webhook handler with resolved account config and enforces allowlist", async () => { + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + accounts: { + alerts: { + enabled: true, + token: "valid-token", + incomingUrl: "https://nas.example.com/incoming", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["456"], + }, + }, + }, + }, + }, + accountId: "alerts", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const started = await plugin.gateway.startAccount(ctx); + expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1); + + const firstCall = registerPluginHttpRouteMock.mock.calls[0]; + expect(firstCall).toBeTruthy(); + if (!firstCall) throw new Error("Expected registerPluginHttpRoute to be called"); + const registered = firstCall[0]; + expect(registered.path).toBe("/webhook/synology-alerts"); + expect(registered.accountId).toBe("alerts"); + expect(typeof registered.handler).toBe("function"); + + const req = makeReq( + "POST", + makeFormBody({ + token: "valid-token", + user_id: "123", + username: "unauthorized-user", + text: "Hello", + }), + ); + const res = makeRes(); + await registered.handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain("not authorized"); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + started.stop(); + }); +}); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 622c7bffaedf..bc6c00a47126 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -39,6 +39,7 @@ vi.mock("zod", () => ({ })); const { createSynologyChatPlugin } = await import("./channel.js"); +const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk"); describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { @@ -182,6 +183,25 @@ describe("createSynologyChatPlugin", () => { expect(warnings.some((w: string) => w.includes("open"))).toBe(true); }); + it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => { + const plugin = createSynologyChatPlugin(); + const account = { + accountId: "default", + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + nasHost: "h", + webhookPath: "/w", + dmPolicy: "allowlist" as const, + allowedUserIds: [], + rateLimitPerMinute: 30, + botName: "Bot", + allowInsecureSsl: false, + }; + const warnings = plugin.security.collectWarnings({ account }); + expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); + }); + it("returns no warnings for fully configured account", () => { const plugin = createSynologyChatPlugin(); const account = { @@ -336,5 +356,68 @@ describe("createSynologyChatPlugin", () => { const result = await plugin.gateway.startAccount(ctx); expect(typeof result.stop).toBe("function"); }); + + it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => { + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockClear(); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + dmPolicy: "allowlist", + allowedUserIds: [], + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const result = await plugin.gateway.startAccount(ctx); + expect(typeof result.stop).toBe("function"); + expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds")); + expect(registerMock).not.toHaveBeenCalled(); + }); + + it("deregisters stale route before re-registering same account/path", async () => { + const unregisterFirst = vi.fn(); + const unregisterSecond = vi.fn(); + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123"], + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const first = await plugin.gateway.startAccount(ctx); + const second = await plugin.gateway.startAccount(ctx); + + expect(registerMock).toHaveBeenCalledTimes(2); + expect(unregisterFirst).toHaveBeenCalledTimes(1); + expect(unregisterSecond).not.toHaveBeenCalled(); + + // Clean up active route map so this module-level state doesn't leak across tests. + first.stop(); + second.stop(); + }); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0e205f60c3e8..431dfd2cbd2d 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,6 +20,8 @@ import { createWebhookHandler } from "./webhook-handler.js"; const CHANNEL_ID = "synology-chat"; const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); +const activeRouteUnregisters = new Map void>(); + export function createSynologyChatPlugin() { return { id: CHANNEL_ID, @@ -139,6 +141,11 @@ export function createSynologyChatPlugin() { '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', ); } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + warnings.push( + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + } return warnings; }, }, @@ -219,6 +226,12 @@ export function createSynologyChatPlugin() { ); return { stop: () => {} }; } + if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { + log?.warn?.( + `Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`, + ); + return { stop: () => {} }; + } log?.info?.( `Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`, @@ -270,7 +283,16 @@ export function createSynologyChatPlugin() { log, }); - // Register HTTP route via the SDK + // Deregister any stale route from a previous start (e.g. on auto-restart) + // to avoid "already registered" collisions that trigger infinite loops. + const routeKey = `${accountId}:${account.webhookPath}`; + const prevUnregister = activeRouteUnregisters.get(routeKey); + if (prevUnregister) { + log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); + prevUnregister(); + activeRouteUnregisters.delete(routeKey); + } + const unregister = registerPluginHttpRoute({ path: account.webhookPath, pluginId: CHANNEL_ID, @@ -278,6 +300,7 @@ export function createSynologyChatPlugin() { log: (msg: string) => log?.info?.(msg), handler, }); + activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); @@ -285,6 +308,7 @@ export function createSynologyChatPlugin() { stop: () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); + activeRouteUnregisters.delete(routeKey); }, }; }, diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index 11330dcddc83..f77fd21ca8e0 100644 --- a/extensions/synology-chat/src/security.test.ts +++ b/extensions/synology-chat/src/security.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { + validateToken, + checkUserAllowed, + authorizeUserForDm, + sanitizeInput, + RateLimiter, +} from "./security.js"; describe("validateToken", () => { it("returns true for matching tokens", () => { @@ -24,8 +30,8 @@ describe("validateToken", () => { }); describe("checkUserAllowed", () => { - it("allows any user when allowlist is empty", () => { - expect(checkUserAllowed("user1", [])).toBe(true); + it("rejects all users when allowlist is empty", () => { + expect(checkUserAllowed("user1", [])).toBe(false); }); it("allows user in the allowlist", () => { @@ -37,6 +43,39 @@ describe("checkUserAllowed", () => { }); }); +describe("authorizeUserForDm", () => { + it("allows any user when dmPolicy is open", () => { + expect(authorizeUserForDm("user1", "open", [])).toEqual({ allowed: true }); + }); + + it("rejects all users when dmPolicy is disabled", () => { + expect(authorizeUserForDm("user1", "disabled", ["user1"])).toEqual({ + allowed: false, + reason: "disabled", + }); + }); + + it("rejects when dmPolicy is allowlist and list is empty", () => { + expect(authorizeUserForDm("user1", "allowlist", [])).toEqual({ + allowed: false, + reason: "allowlist-empty", + }); + }); + + it("rejects users not in allowlist", () => { + expect(authorizeUserForDm("user9", "allowlist", ["user1"])).toEqual({ + allowed: false, + reason: "not-allowlisted", + }); + }); + + it("allows users in allowlist", () => { + expect(authorizeUserForDm("user1", "allowlist", ["user1", "user2"])).toEqual({ + allowed: true, + }); + }); +}); + describe("sanitizeInput", () => { it("returns normal text unchanged", () => { expect(sanitizeInput("hello world")).toBe("hello world"); diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 43ff054b0779..22883babbf5a 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -4,6 +4,10 @@ import * as crypto from "node:crypto"; +export type DmAuthorizationResult = + | { allowed: true } + | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" }; + /** * Validate webhook token using constant-time comparison. * Prevents timing attacks that could leak token bytes. @@ -22,13 +26,37 @@ export function validateToken(received: string, expected: string): boolean { /** * Check if a user ID is in the allowed list. - * Empty allowlist = allow all users. + * Allowlist mode must be explicit; empty lists should not match any user. */ export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean { - if (allowedUserIds.length === 0) return true; + if (allowedUserIds.length === 0) return false; return allowedUserIds.includes(userId); } +/** + * Resolve DM authorization for a sender across all DM policy modes. + * Keeps policy semantics in one place so webhook/startup behavior stays consistent. + */ +export function authorizeUserForDm( + userId: string, + dmPolicy: "open" | "allowlist" | "disabled", + allowedUserIds: string[], +): DmAuthorizationResult { + if (dmPolicy === "disabled") { + return { allowed: false, reason: "disabled" }; + } + if (dmPolicy === "open") { + return { allowed: true }; + } + if (allowedUserIds.length === 0) { + return { allowed: false, reason: "allowlist-empty" }; + } + if (!checkUserAllowed(userId, allowedUserIds)) { + return { allowed: false, reason: "not-allowlisted" }; + } + return { allowed: true }; +} + /** * Sanitize user input to prevent prompt injection attacks. * Filters known dangerous patterns and truncates long messages. diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 7e20c1006109..1c8ef393ced7 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -156,6 +156,26 @@ describe("createWebhookHandler", () => { }); }); + it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => { + const deliver = vi.fn(); + const handler = createWebhookHandler({ + account: makeAccount({ + dmPolicy: "allowlist", + allowedUserIds: [], + }), + deliver, + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain("Allowlist is empty"); + expect(deliver).not.toHaveBeenCalled(); + }); + it("returns 403 when DMs are disabled", async () => { await expectForbiddenByPolicy({ account: { dmPolicy: "disabled" }, diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index d1dae50a6738..b077e61fc7c4 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -6,7 +6,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; import { sendMessage } from "./client.js"; -import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js"; +import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; // One rate limiter per account, created lazily @@ -137,21 +137,25 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { return; } - // User allowlist check - if ( - account.dmPolicy === "allowlist" && - !checkUserAllowed(payload.user_id, account.allowedUserIds) - ) { + // DM policy authorization + const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds); + if (!auth.allowed) { + if (auth.reason === "disabled") { + respond(res, 403, { error: "DMs are disabled" }); + return; + } + if (auth.reason === "allowlist-empty") { + log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message"); + respond(res, 403, { + error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.", + }); + return; + } log?.warn(`Unauthorized user: ${payload.user_id}`); respond(res, 403, { error: "User not authorized" }); return; } - if (account.dmPolicy === "disabled") { - respond(res, 403, { error: "DMs are disabled" }); - return; - } - // Rate limit if (!rateLimiter.check(payload.user_id)) { log?.warn(`Rate limit exceeded for user: ${payload.user_id}`); diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index b18032e9e0db..83586d5da0e9 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index ca19605fea3a..cac5ddf083b4 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index f2b937a60de1..b2e4534bcbcf 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index d112549f73ac..1efd4d0814f9 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 06789681f4f7..f604647f0cd6 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7338db139f66..e09e59fef8d1 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index e1a4524d2803..7fcd756b9431 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -103,4 +103,37 @@ describe("TelnyxProvider.verifyWebhook", () => { const spkiDerBase64 = spkiDer.toString("base64"); expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey }); }); + + it("returns replay status when the same signed request is seen twice", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "call-replay-test" }, + nonce: crypto.randomUUID(), + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }); + + const first = provider.verifyWebhook(ctx); + const second = provider.verifyWebhook(ctx); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); }); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 05a750a00bb8..e81844f1f659 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -87,7 +87,7 @@ export class TelnyxProvider implements VoiceCallProvider { skipVerification: this.options.skipVerification, }); - return { ok: result.ok, reason: result.reason }; + return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; } /** diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index a047481125f5..e85838a13830 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -1,6 +1,10 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; -import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js"; +import { + verifyPlivoWebhook, + verifyTelnyxWebhook, + verifyTwilioWebhook, +} from "./webhook-security.js"; function canonicalizeBase64(input: string): string { return Buffer.from(input, "base64").toString("base64"); @@ -199,6 +203,37 @@ describe("verifyPlivoWebhook", () => { }); }); +describe("verifyTelnyxWebhook", () => { + it("marks replayed valid requests as replay without failing auth", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const rawBody = JSON.stringify({ + data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } }, + nonce: crypto.randomUUID(), + }); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const ctx = { + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST" as const, + }; + + const first = verifyTelnyxWebhook(ctx, pemPublicKey); + const second = verifyTelnyxWebhook(ctx, pemPublicKey); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); +}); + describe("verifyTwilioWebhook", () => { it("uses request query when publicUrl omits it", () => { const authToken = "test-auth-token"; diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index cc035b115b8d..d190ed8f9ffa 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -20,6 +20,11 @@ const plivoReplayCache: ReplayCache = { calls: 0, }; +const telnyxReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + function sha256Hex(input: string): string { return crypto.createHash("sha256").update(input).digest("hex"); } @@ -392,6 +397,8 @@ export interface TwilioVerificationResult { export interface TelnyxVerificationResult { ok: boolean; reason?: string; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } function createTwilioReplayKey(params: { @@ -499,7 +506,9 @@ export function verifyTelnyxWebhook( return { ok: false, reason: "Timestamp too old" }; } - return { ok: true }; + const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; + const isReplay = markReplay(telnyxReplayCache, replayKey); + return { ok: true, isReplay }; } catch (err) { return { ok: false, diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index e0018a9b5f6c..8cabcd7bf57b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.23", + "version": "2026.2.25", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 5c9c551e6745..18422d82e4c3 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 4f7bacf0ff97..ba53002e1ca2 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 9e263f0bff8d..34706e168828 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -16,6 +16,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -56,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { export const zaloDock: ChannelDock = { id: "zalo", capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, @@ -82,7 +84,7 @@ export const zaloPlugin: ChannelPlugin = { meta, onboarding: zaloOnboardingAdapter, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, @@ -143,6 +145,31 @@ export const zaloPlugin: ChannelPlugin = { normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }; }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.zalo !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => + String(entry), + ); + const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`, + ]; + }, }, groups: { resolveRequireMention: () => true, diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index db4fba278143..a38a0a1cbfd9 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({ webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts new file mode 100644 index 000000000000..7acd1997096b --- /dev/null +++ b/extensions/zalo/src/group-access.ts @@ -0,0 +1,48 @@ +import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk"; +import { + evaluateSenderGroupAccess, + isNormalizedSenderAllowed, + resolveOpenProviderRuntimeGroupPolicy, +} from "openclaw/plugin-sdk"; + +const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i; + +export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean { + return isNormalizedSenderAllowed({ + senderId, + allowFrom, + stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE, + }); +} + +export function resolveZaloRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); +} + +export function evaluateZaloGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + groupAllowFrom: string[]; + senderId: string; +}): SenderGroupAccessDecision { + return evaluateSenderGroupAccess({ + providerConfigPresent: params.providerConfigPresent, + configuredGroupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + groupAllowFrom: params.groupAllowFrom, + senderId: params.senderId, + isSenderAllowed: isZaloSenderAllowed, + }); +} diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts new file mode 100644 index 000000000000..2ce0b1be2a23 --- /dev/null +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./monitor.js"; + +describe("zalo group policy access", () => { + it("defaults missing provider config to allowlist", () => { + const resolved = __testing.resolveZaloRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: undefined, + defaultGroupPolicy: "open", + }); + expect(resolved).toEqual({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + }); + }); + + it("blocks all group messages when policy is disabled", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:123"], + senderId: "123", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "disabled", + reason: "disabled", + }); + }); + + it("blocks group messages on allowlist policy with empty allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "attacker", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "empty_allowlist", + }); + }); + + it("blocks sender not in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zalo:victim-user-001"], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: false, + groupPolicy: "allowlist", + reason: "sender_not_allowlisted", + }); + }); + + it("allows sender in group allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows any sender with wildcard allowlist", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["*"], + senderId: "random-user", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + + it("allows all group senders on open policy", () => { + const decision = __testing.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "open", + defaultGroupPolicy: "allowlist", + groupAllowFrom: [], + senderId: "attacker-user-999", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "open", + reason: "allowed", + }); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 47269635a442..76e656af7de1 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,19 +1,13 @@ -import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { - createDedupeCache, createReplyPrefixOptions, - readJsonBodyWithLimit, - registerWebhookTarget, - rejectNonPostWebhookRequest, - resolveSingleWebhookTarget, resolveSenderCommandAuthorization, resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, sendMediaWithLeadingCaption, resolveWebhookPath, - resolveWebhookTargets, - requestBodyErrorToText, + warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -27,6 +21,16 @@ import { type ZaloMessage, type ZaloUpdate, } from "./api.js"; +import { + evaluateZaloGroupAccess, + isZaloSenderAllowed, + resolveZaloRuntimeGroupPolicy, +} from "./group-access.js"; +import { + handleZaloWebhookRequest as handleZaloWebhookRequestInternal, + registerZaloWebhookTarget as registerZaloWebhookTargetInternal, + type ZaloWebhookTarget, +} from "./monitor.webhook.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { getZaloRuntime } from "./runtime.js"; @@ -55,13 +59,8 @@ export type ZaloMonitorResult = { const ZALO_TEXT_LIMIT = 2000; const DEFAULT_MEDIA_MAX_MB = 5; -const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; -const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; -const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; -const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; type ZaloCoreRuntime = ReturnType; -type WebhookRateLimitState = { count: number; windowStartMs: number }; function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { @@ -69,216 +68,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str } } -function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = senderId.toLowerCase(); - return allowFrom.some((entry) => { - const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, ""); - return normalized === normalizedSenderId; - }); -} - -type WebhookTarget = { - token: string; - account: ResolvedZaloAccount; - config: OpenClawConfig; - runtime: ZaloRuntimeEnv; - core: ZaloCoreRuntime; - secret: string; - path: string; - mediaMaxMb: number; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - fetcher?: ZaloFetch; -}; - -const webhookTargets = new Map(); -const webhookRateLimits = new Map(); -const recentWebhookEvents = createDedupeCache({ - ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, - maxSize: 5000, -}); -const webhookStatusCounters = new Map(); - -function isJsonContentType(value: string | string[] | undefined): boolean { - const first = Array.isArray(value) ? value[0] : value; - if (!first) { - return false; - } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); - return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); -} - -function timingSafeEquals(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left); - const rightBuffer = Buffer.from(right); - - if (leftBuffer.length !== rightBuffer.length) { - const length = Math.max(1, leftBuffer.length, rightBuffer.length); - const paddedLeft = Buffer.alloc(length); - const paddedRight = Buffer.alloc(length); - leftBuffer.copy(paddedLeft); - rightBuffer.copy(paddedRight); - timingSafeEqual(paddedLeft, paddedRight); - return false; - } - - return timingSafeEqual(leftBuffer, rightBuffer); -} - -function isWebhookRateLimited(key: string, nowMs: number): boolean { - const state = webhookRateLimits.get(key); - if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { - webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); - return false; - } - - state.count += 1; - if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { - return true; - } - return false; -} - -function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { - const messageId = update.message?.message_id; - if (!messageId) { - return false; - } - const key = `${update.event_name}:${messageId}`; - return recentWebhookEvents.check(key, nowMs); -} - -function recordWebhookStatus( - runtime: ZaloRuntimeEnv | undefined, - path: string, - statusCode: number, -): void { - if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { - return; - } - const key = `${path}:${statusCode}`; - const next = (webhookStatusCounters.get(key) ?? 0) + 1; - webhookStatusCounters.set(key, next); - if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { - runtime?.log?.( - `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, - ); - } -} - -export function registerZaloWebhookTarget(target: WebhookTarget): () => void { - return registerWebhookTarget(webhookTargets, target).unregister; +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerZaloWebhookTargetInternal(target); } export async function handleZaloWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const resolved = resolveWebhookTargets(req, webhookTargets); - if (!resolved) { - return false; - } - const { targets } = resolved; - - if (rejectNonPostWebhookRequest(req, res)) { - return true; - } - - const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); - const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => - timingSafeEquals(entry.secret, headerToken), - ); - if (matchedTarget.kind === "none") { - res.statusCode = 401; - res.end("unauthorized"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - if (matchedTarget.kind === "ambiguous") { - res.statusCode = 401; - res.end("ambiguous webhook target"); - recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); - return true; - } - const target = matchedTarget.target; - const path = req.url ?? ""; - const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; - const nowMs = Date.now(); - - if (isWebhookRateLimited(rateLimitKey, nowMs)) { - res.statusCode = 429; - res.end("Too Many Requests"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (!isJsonContentType(req.headers["content-type"])) { - res.statusCode = 415; - res.end("Unsupported Media Type"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - const body = await readJsonBodyWithLimit(req, { - maxBytes: 1024 * 1024, - timeoutMs: 30_000, - emptyObjectOnEmpty: false, - }); - if (!body.ok) { - res.statusCode = - body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; - const message = - body.code === "PAYLOAD_TOO_LARGE" - ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") - : body.code === "REQUEST_BODY_TIMEOUT" - ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") - : "Bad Request"; - res.end(message); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result } - const raw = body.value; - const record = raw && typeof raw === "object" ? (raw as Record) : null; - const update: ZaloUpdate | undefined = - record && record.ok === true && record.result - ? (record.result as ZaloUpdate) - : ((record as ZaloUpdate | null) ?? undefined); - - if (!update?.event_name) { - res.statusCode = 400; - res.end("Bad Request"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - if (isReplayEvent(update, nowMs)) { - res.statusCode = 200; - res.end("ok"); - return true; - } - - target.statusSink?.({ lastInboundAt: Date.now() }); - processUpdate( - update, - target.token, - target.account, - target.config, - target.runtime, - target.core, - target.mediaMaxMb, - target.statusSink, - target.fetcher, - ).catch((err) => { - target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => { + await processUpdate( + update, + target.token, + target.account, + target.config, + target.runtime, + target.core as ZaloCoreRuntime, + target.mediaMaxMb, + target.statusSink, + target.fetcher, + ); }); - - res.statusCode = 200; - res.end("ok"); - return true; } function startPollingLoop(params: { @@ -502,6 +312,42 @@ async function processMessageWithPipeline(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); + const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); + const groupAllowFrom = + configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom; + const defaultGroupPolicy = resolveDefaultGroupPolicy(config); + const groupAccess = isGroup + ? evaluateZaloGroupAccess({ + providerConfigPresent: config.channels?.zalo !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + groupAllowFrom, + senderId, + }) + : undefined; + if (groupAccess) { + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied, + providerKey: "zalo", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + }); + if (!groupAccess.allowed) { + if (groupAccess.reason === "disabled") { + logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`); + } else if (groupAccess.reason === "empty_allowlist") { + logVerbose( + core, + runtime, + `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`, + ); + } else if (groupAccess.reason === "sender_not_allowlisted") { + logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`); + } + return; + } + } + const rawBody = text?.trim() || (mediaPath ? "" : ""); const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ cfg: config, @@ -510,7 +356,7 @@ async function processMessageWithPipeline(params: { dmPolicy, configuredAllowFrom: configAllowFrom, senderId, - isSenderAllowed, + isSenderAllowed: isZaloSenderAllowed, readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), shouldComputeCommandAuthorized: (body, cfg) => core.channel.commands.shouldComputeCommandAuthorized(body, cfg), @@ -818,3 +664,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< return { stop }; } + +export const __testing = { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +}; diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts new file mode 100644 index 000000000000..dd2b0c655850 --- /dev/null +++ b/extensions/zalo/src/monitor.webhook.ts @@ -0,0 +1,219 @@ +import { timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + createDedupeCache, + readJsonBodyWithLimit, + registerWebhookTarget, + rejectNonPostWebhookRequest, + requestBodyErrorToText, + resolveSingleWebhookTarget, + resolveWebhookTargets, +} from "openclaw/plugin-sdk"; +import type { ResolvedZaloAccount } from "./accounts.js"; +import type { ZaloFetch, ZaloUpdate } from "./api.js"; +import type { ZaloRuntimeEnv } from "./monitor.js"; + +type WebhookRateLimitState = { count: number; windowStartMs: number }; + +const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000; +const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120; +const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; +const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25; + +export type ZaloWebhookTarget = { + token: string; + account: ResolvedZaloAccount; + config: OpenClawConfig; + runtime: ZaloRuntimeEnv; + core: unknown; + secret: string; + path: string; + mediaMaxMb: number; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + fetcher?: ZaloFetch; +}; + +export type ZaloWebhookProcessUpdate = (params: { + update: ZaloUpdate; + target: ZaloWebhookTarget; +}) => Promise; + +const webhookTargets = new Map(); +const webhookRateLimits = new Map(); +const recentWebhookEvents = createDedupeCache({ + ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, + maxSize: 5000, +}); +const webhookStatusCounters = new Map(); + +function isJsonContentType(value: string | string[] | undefined): boolean { + const first = Array.isArray(value) ? value[0] : value; + if (!first) { + return false; + } + const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); +} + +function timingSafeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + const length = Math.max(1, leftBuffer.length, rightBuffer.length); + const paddedLeft = Buffer.alloc(length); + const paddedRight = Buffer.alloc(length); + leftBuffer.copy(paddedLeft); + rightBuffer.copy(paddedRight); + timingSafeEqual(paddedLeft, paddedRight); + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function isWebhookRateLimited(key: string, nowMs: number): boolean { + const state = webhookRateLimits.get(key); + if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) { + webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs }); + return false; + } + + state.count += 1; + if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) { + return true; + } + return false; +} + +function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { + const messageId = update.message?.message_id; + if (!messageId) { + return false; + } + const key = `${update.event_name}:${messageId}`; + return recentWebhookEvents.check(key, nowMs); +} + +function recordWebhookStatus( + runtime: ZaloRuntimeEnv | undefined, + path: string, + statusCode: number, +): void { + if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { + return; + } + const key = `${path}:${statusCode}`; + const next = (webhookStatusCounters.get(key) ?? 0) + 1; + webhookStatusCounters.set(key, next); + if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) { + runtime?.log?.( + `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`, + ); + } +} + +export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { + return registerWebhookTarget(webhookTargets, target).unregister; +} + +export async function handleZaloWebhookRequest( + req: IncomingMessage, + res: ServerResponse, + processUpdate: ZaloWebhookProcessUpdate, +): Promise { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { + return false; + } + const { targets } = resolved; + + if (rejectNonPostWebhookRequest(req, res)) { + return true; + } + + const headerToken = String(req.headers["x-bot-api-secret-token"] ?? ""); + const matchedTarget = resolveSingleWebhookTarget(targets, (entry) => + timingSafeEquals(entry.secret, headerToken), + ); + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + recordWebhookStatus(targets[0]?.runtime, req.url ?? "", res.statusCode); + return true; + } + const target = matchedTarget.target; + const path = req.url ?? ""; + const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; + const nowMs = Date.now(); + + if (isWebhookRateLimited(rateLimitKey, nowMs)) { + res.statusCode = 429; + res.end("Too Many Requests"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (!isJsonContentType(req.headers["content-type"])) { + res.statusCode = 415; + res.end("Unsupported Media Type"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + const body = await readJsonBodyWithLimit(req, { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + emptyObjectOnEmpty: false, + }); + if (!body.ok) { + res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + const message = + body.code === "PAYLOAD_TOO_LARGE" + ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") + : body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : "Bad Request"; + res.end(message); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }. + const raw = body.value; + const record = raw && typeof raw === "object" ? (raw as Record) : null; + const update: ZaloUpdate | undefined = + record && record.ok === true && record.result + ? (record.result as ZaloUpdate) + : ((record as ZaloUpdate | null) ?? undefined); + + if (!update?.event_name) { + res.statusCode = 400; + res.end("Bad Request"); + recordWebhookStatus(target.runtime, path, res.statusCode); + return true; + } + + if (isReplayEvent(update, nowMs)) { + res.statusCode = 200; + res.end("ok"); + return true; + } + + target.statusSink?.({ lastInboundAt: Date.now() }); + processUpdate({ update, target }).catch((err) => { + target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`); + }); + + res.statusCode = 200; + res.end("ok"); + return true; +} diff --git a/extensions/zalo/src/types.ts b/extensions/zalo/src/types.ts index bcc43138f976..c17ea0cfc617 100644 --- a/extensions/zalo/src/types.ts +++ b/extensions/zalo/src/types.ts @@ -17,6 +17,10 @@ export type ZaloAccountConfig = { dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; /** Allowlist for DM senders (Zalo user IDs). */ allowFrom?: Array; + /** Group-message access policy. */ + groupPolicy?: "open" | "allowlist" | "disabled"; + /** Allowlist for group senders (falls back to allowFrom when unset). */ + groupAllowFrom?: Array; /** Max inbound media size in MB. */ mediaMaxMb?: number; /** Proxy URL for API requests. */ diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index b90b1a97a5db..5797a8667a21 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## 2026.2.23 +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 8c3962340422..9b2e6ebfa598 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.23", + "version": "2026.2.25", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { diff --git a/package.json b/package.json index 7c53d6ca1782..e9a17dc5a251 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@qverisai/qverisbot", - "version": "2026.2.23", - "description": "QVerisBot - OpenClaw-based professional AI assistant with QVeris toolbox integrations", + "name": "openclaw", + "version": "2026.2.25", + "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://qveris.ai", "bugs": { @@ -59,7 +59,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -98,6 +98,8 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", + "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", @@ -136,8 +138,6 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", - "test:pack:smoke": "bash scripts/test-npm-pack-smoke.sh", - "test:pack:smoke:docker": "bash scripts/test-npm-pack-smoke.sh --docker", "test:ui": "pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", @@ -149,7 +149,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.995.0", + "@aws-sdk/client-bedrock": "^3.997.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", "@discordjs/voice": "^0.19.0", @@ -159,14 +159,15 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.54.1", - "@mariozechner/pi-ai": "0.54.1", - "@mariozechner/pi-coding-agent": "0.54.1", - "@mariozechner/pi-tui": "0.54.1", + "@mariozechner/pi-agent-core": "0.55.0", + "@mariozechner/pi-ai": "0.55.0", + "@mariozechner/pi-coding-agent": "0.55.0", + "@mariozechner/pi-tui": "0.55.0", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.14.1", + "@snazzah/davey": "^0.1.9", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", @@ -189,7 +190,7 @@ "long": "^5.3.2", "markdown-it": "^14.1.1", "node-edge-tts": "^1.2.10", - "opusscript": "^0.0.8", + "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.4.624", "playwright-core": "1.58.2", @@ -213,12 +214,12 @@ "@types/node": "^25.3.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260222.1", + "@typescript/native-preview": "7.0.0-dev.20260224.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", - "oxfmt": "0.34.0", - "oxlint": "^1.49.0", - "oxlint-tsgolint": "^0.14.2", + "oxfmt": "0.35.0", + "oxlint": "^1.50.0", + "oxlint-tsgolint": "^0.15.0", "signal-utils": "0.21.1", "tsdown": "^0.20.3", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 933706f73c06..6933cba7d0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,17 +24,17 @@ importers: specifier: 0.14.1 version: 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.995.0 - version: 3.995.0 + specifier: ^3.997.0 + version: 3.997.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.0.1 version: 1.0.1 '@discordjs/voice': specifier: ^0.19.0 - version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + version: 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.40.0) @@ -54,17 +54,17 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.54.1 - version: 0.54.1(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.0 + version: 0.55.0(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.54.1 - version: 0.54.1 + specifier: 0.55.0 + version: 0.55.0 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -80,6 +80,9 @@ importers: '@slack/web-api': specifier: ^7.14.1 version: 7.14.1 + '@snazzah/davey': + specifier: ^0.1.9 + version: 0.1.9 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -150,8 +153,8 @@ importers: specifier: 3.15.1 version: 3.15.1(typescript@5.9.3) opusscript: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.1.1 + version: 0.1.1 osc-progress: specifier: ^0.3.0 version: 0.3.0 @@ -217,8 +220,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260222.1 - version: 7.0.0-dev.20260222.1 + specifier: 7.0.0-dev.20260224.1 + version: 7.0.0-dev.20260224.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -226,20 +229,20 @@ importers: specifier: ^3.3.2 version: 3.3.2 oxfmt: - specifier: 0.34.0 - version: 0.34.0 + specifier: 0.35.0 + version: 0.35.0 oxlint: - specifier: ^1.49.0 - version: 1.49.0(oxlint-tsgolint@0.14.2) + specifier: ^1.50.0 + version: 1.50.0(oxlint-tsgolint@0.15.0) oxlint-tsgolint: - specifier: ^0.14.2 - version: 0.14.2 + specifier: ^0.15.0 + version: 0.15.0 signal-utils: specifier: 0.21.1 version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260222.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -266,13 +269,13 @@ importers: '@opentelemetry/api-logs': specifier: ^0.212.0 version: 0.212.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': + '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': + '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': @@ -313,11 +316,11 @@ importers: extensions/googlechat: dependencies: google-auth-library: - specifier: ^10.5.0 - version: 10.5.0 + specifier: ^10.6.1 + version: 10.6.1 openclaw: specifier: '>=2026.1.26' - version: 2026.2.22(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/imessage: {} @@ -353,7 +356,7 @@ importers: dependencies: openclaw: specifier: '>=2026.1.26' - version: 2026.2.22(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -364,8 +367,8 @@ importers: specifier: 0.34.48 version: 0.34.48 openai: - specifier: ^6.22.0 - version: 6.22.0(ws@8.19.0)(zod@4.3.6) + specifier: ^6.25.0 + version: 6.25.0(ws@8.19.0)(zod@4.3.6) extensions/minimax-portal-auth: {} @@ -438,8 +441,6 @@ importers: extensions/whatsapp: {} - extensions/x: {} - extensions/zalo: dependencies: undici: @@ -547,10 +548,18 @@ packages: resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.997.0': + resolution: {integrity: sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.995.0': resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.997.0': + resolution: {integrity: sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sso@3.993.0': resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} engines: {node: '>=20.0.0'} @@ -559,6 +568,14 @@ packages: resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.13': + resolution: {integrity: sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.11': + resolution: {integrity: sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.9': resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} engines: {node: '>=20.0.0'} @@ -567,10 +584,22 @@ packages: resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.13': + resolution: {integrity: sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.11': + resolution: {integrity: sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.9': resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.11': + resolution: {integrity: sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.9': resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} engines: {node: '>=20.0.0'} @@ -579,14 +608,30 @@ packages: resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.12': + resolution: {integrity: sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.11': + resolution: {integrity: sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.9': resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.11': + resolution: {integrity: sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.9': resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.11': + resolution: {integrity: sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.9': resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} engines: {node: '>=20.0.0'} @@ -595,30 +640,58 @@ packages: resolution: {integrity: sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.7': + resolution: {integrity: sha512-p8k2ZWKJVrR3KIcBbI+/+FcWXdwe3LLgGnixsA7w8lDwWjzSVDHFp6uPeSqBt5PQpRxzak9EheJ1xTmOnHGf4g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.3': resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.4': + resolution: {integrity: sha512-0t+2Dn46cRE9iu5ynUXINBtR0wNHi/Jz3FbrqS5k3dGot2O7Ln1xCqXbJUAtGM5ZAqN77SbnpETAgVWC84DeoA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.3': resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.4': + resolution: {integrity: sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-logger@3.972.3': resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-logger@3.972.4': + resolution: {integrity: sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.3': resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.4': + resolution: {integrity: sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.11': resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.972.13': + resolution: {integrity: sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.6': resolution: {integrity: sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==} engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.8': + resolution: {integrity: sha512-KPUXz8lRw73Rh12/QkELxiryC9Wi9Ah1xNzFe2Vtbz2/81c2ZA0yM8er+u0iCF/SRMMhDQshLcmRNgn/ueA+gA==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.993.0': resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} engines: {node: '>=20.0.0'} @@ -627,10 +700,18 @@ packages: resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.996.1': + resolution: {integrity: sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.3': resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.4': + resolution: {integrity: sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.993.0': resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} engines: {node: '>=20.0.0'} @@ -639,10 +720,18 @@ packages: resolution: {integrity: sha512-lYSadNdZZ513qCKoj/KlJ+PgCycL3n8ZNS37qLVFC0t7TbHzoxvGquu9aD2n9OCERAn43OMhQ7dXjYDYdjAXzA==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.997.0': + resolution: {integrity: sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.1': resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.2': + resolution: {integrity: sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.993.0': resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} engines: {node: '>=20.0.0'} @@ -651,10 +740,18 @@ packages: resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.996.1': + resolution: {integrity: sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.3': resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.4': + resolution: {integrity: sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} @@ -662,6 +759,9 @@ packages: '@aws-sdk/util-user-agent-browser@3.972.3': resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-browser@3.972.4': + resolution: {integrity: sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==} + '@aws-sdk/util-user-agent-node@3.972.10': resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==} engines: {node: '>=20.0.0'} @@ -671,10 +771,23 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.972.12': + resolution: {integrity: sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.972.5': resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.6': + resolution: {integrity: sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.3': resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} @@ -1395,20 +1508,38 @@ packages: resolution: {integrity: sha512-AC0SqEbR62PckWOyP0CmhYtfcC+Q6e1DGghwEcKpomTtmNfHTy7iTVy64mmtB2CFiN8j4rJFCqh2xJHgucUvkA==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-agent-core@0.55.0': + resolution: {integrity: sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==} + engines: {node: '>=20.0.0'} + '@mariozechner/pi-ai@0.54.1': resolution: {integrity: sha512-tiVvoNQV+3dpWgRQ1U/3bwJoDVSYwL17BE/kc00nXmaSLAPwNZoxLagtQ+HBr/rGzkq5viOgQf2dk+ud+/4UCg==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-ai@0.55.0': + resolution: {integrity: sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-coding-agent@0.54.1': resolution: {integrity: sha512-pPFrdaKZ16oIcdhZVcfWPhCDFx8PWHaACjQS9aFFcMOhLBduyKAGyf8bQtfysekl+gIbBSGDT2rgCxsOwK2bQw==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-coding-agent@0.55.0': + resolution: {integrity: sha512-neflZvWsbFDph3RG+b3/ItfFtGaQnOFJO+N+fsnIC3BG/FEUu1IK1lcMwrM1FGGSMfJnCv7Q3Zk5MSBiRj4azQ==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-tui@0.54.1': resolution: {integrity: sha512-FY8QcLlr9T276oZAwMSSPo1drg+J9Y7B+A0S9g8Jh6IFJxymKZZq29/Vit6XDziJfZIgJDraC6lpobtxgTEoFQ==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.55.0': + resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==} + engines: {node: '>=20.0.0'} + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -1942,260 +2073,260 @@ packages: '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} - '@oxfmt/binding-android-arm-eabi@0.34.0': - resolution: {integrity: sha512-sqkqjh/Z38l+duOb1HtVqJTAj1grt2ttkobCopC/72+a4Xxz4xUgZPFyQ4HxrYMvyqO/YA0tvM1QbfOu70Gk1Q==} + '@oxfmt/binding-android-arm-eabi@0.35.0': + resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.34.0': - resolution: {integrity: sha512-1KRCtasHcVcGOMwfOP9d5Bus2NFsN8yAYM5cBwi8LBg5UtXC3C49WHKrlEa8iF1BjOS6CR2qIqiFbGoA0DJQNQ==} + '@oxfmt/binding-android-arm64@0.35.0': + resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.34.0': - resolution: {integrity: sha512-b+Rmw9Bva6e/7PBES2wLO8sEU7Mi0+/Kv+pXSe/Y8i4fWNftZZlGwp8P01eECaUqpXATfSgNxdEKy7+ssVNz7g==} + '@oxfmt/binding-darwin-arm64@0.35.0': + resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.34.0': - resolution: {integrity: sha512-QGjpevWzf1T9COEokZEWt80kPOtthW1zhRbo7x4Qoz646eTTfi6XsHG2uHeDWJmTbgBoJZPMgj2TAEV/ppEZaA==} + '@oxfmt/binding-darwin-x64@0.35.0': + resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.34.0': - resolution: {integrity: sha512-VMSaC02cG75qL59M9M/szEaqq/RsLfgpzQ4nqUu8BUnX1zkiZIW2gTpUv3ZJ6qpWnHxIlAXiRZjQwmcwpvtbcg==} + '@oxfmt/binding-freebsd-x64@0.35.0': + resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': - resolution: {integrity: sha512-Klm367PFJhH6vYK3vdIOxFepSJZHPaBfIuqwxdkOcfSQ4qqc/M8sgK0UTFnJWWTA/IkhMIh1kW6uEqiZ/xtQqg==} + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': - resolution: {integrity: sha512-nqn0QueVXRfbN9m58/E9Zij0Ap8lzayx591eWBYn0sZrGzY1IRv9RYS7J/1YUXbb0Ugedo0a8qIWzUHU9bWQuA==} + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.34.0': - resolution: {integrity: sha512-DDn+dcqW+sMTCEjvLoQvC/VWJjG7h8wcdN/J+g7ZTdf/3/Dx730pQElxPPGsCXPhprb11OsPyMp5FwXjMY3qvA==} + '@oxfmt/binding-linux-arm64-gnu@0.35.0': + resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.34.0': - resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==} + '@oxfmt/binding-linux-arm64-musl@0.35.0': + resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': - resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==} + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': - resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==} + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.34.0': - resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==} + '@oxfmt/binding-linux-riscv64-musl@0.35.0': + resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.34.0': - resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==} + '@oxfmt/binding-linux-s390x-gnu@0.35.0': + resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.34.0': - resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==} + '@oxfmt/binding-linux-x64-gnu@0.35.0': + resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.34.0': - resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==} + '@oxfmt/binding-linux-x64-musl@0.35.0': + resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.34.0': - resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==} + '@oxfmt/binding-openharmony-arm64@0.35.0': + resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.34.0': - resolution: {integrity: sha512-6id8kK0t5hKfbV6LHDzRO21wRTA6ctTlKGTZIsG/mcoir0rssvaYsedUymF4HDj7tbCUlnxCX/qOajKlEuqbIw==} + '@oxfmt/binding-win32-arm64-msvc@0.35.0': + resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.34.0': - resolution: {integrity: sha512-QHaz+w673mlYqn9v/+fuiKZpjkmagleXQ+NygShDv8tdHpRYX2oYhTJwwt9j1ZfVhRgza1EIUW3JmzCXmtPdhQ==} + '@oxfmt/binding-win32-ia32-msvc@0.35.0': + resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.34.0': - resolution: {integrity: sha512-CXKQM/VaF+yuvGru8ktleHLJoBdjBtTFmAsLGePiESiTN0NjCI/PiaiOCfHMJ1HdP1LykvARUwMvgaN3tDhcrg==} + '@oxfmt/binding-win32-x64-msvc@0.35.0': + resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.14.2': - resolution: {integrity: sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==} + '@oxlint-tsgolint/darwin-arm64@0.15.0': + resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.14.2': - resolution: {integrity: sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==} + '@oxlint-tsgolint/darwin-x64@0.15.0': + resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.14.2': - resolution: {integrity: sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==} + '@oxlint-tsgolint/linux-arm64@0.15.0': + resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.14.2': - resolution: {integrity: sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==} + '@oxlint-tsgolint/linux-x64@0.15.0': + resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.14.2': - resolution: {integrity: sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==} + '@oxlint-tsgolint/win32-arm64@0.15.0': + resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.14.2': - resolution: {integrity: sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==} + '@oxlint-tsgolint/win32-x64@0.15.0': + resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.49.0': - resolution: {integrity: sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==} + '@oxlint/binding-android-arm-eabi@1.50.0': + resolution: {integrity: sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.49.0': - resolution: {integrity: sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==} + '@oxlint/binding-android-arm64@1.50.0': + resolution: {integrity: sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.49.0': - resolution: {integrity: sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==} + '@oxlint/binding-darwin-arm64@1.50.0': + resolution: {integrity: sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.49.0': - resolution: {integrity: sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==} + '@oxlint/binding-darwin-x64@1.50.0': + resolution: {integrity: sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.49.0': - resolution: {integrity: sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==} + '@oxlint/binding-freebsd-x64@1.50.0': + resolution: {integrity: sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': - resolution: {integrity: sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==} + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': + resolution: {integrity: sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.49.0': - resolution: {integrity: sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==} + '@oxlint/binding-linux-arm-musleabihf@1.50.0': + resolution: {integrity: sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.49.0': - resolution: {integrity: sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==} + '@oxlint/binding-linux-arm64-gnu@1.50.0': + resolution: {integrity: sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.49.0': - resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==} + '@oxlint/binding-linux-arm64-musl@1.50.0': + resolution: {integrity: sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.49.0': - resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==} + '@oxlint/binding-linux-ppc64-gnu@1.50.0': + resolution: {integrity: sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.49.0': - resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==} + '@oxlint/binding-linux-riscv64-gnu@1.50.0': + resolution: {integrity: sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.49.0': - resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==} + '@oxlint/binding-linux-riscv64-musl@1.50.0': + resolution: {integrity: sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.49.0': - resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==} + '@oxlint/binding-linux-s390x-gnu@1.50.0': + resolution: {integrity: sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.49.0': - resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==} + '@oxlint/binding-linux-x64-gnu@1.50.0': + resolution: {integrity: sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.49.0': - resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==} + '@oxlint/binding-linux-x64-musl@1.50.0': + resolution: {integrity: sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.49.0': - resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==} + '@oxlint/binding-openharmony-arm64@1.50.0': + resolution: {integrity: sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.49.0': - resolution: {integrity: sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==} + '@oxlint/binding-win32-arm64-msvc@1.50.0': + resolution: {integrity: sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.49.0': - resolution: {integrity: sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==} + '@oxlint/binding-win32-ia32-msvc@1.50.0': + resolution: {integrity: sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.49.0': - resolution: {integrity: sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==} + '@oxlint/binding-win32-x64-msvc@1.50.0': + resolution: {integrity: sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2544,6 +2675,10 @@ packages: resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@smithy/abort-controller@4.2.10': + resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} + engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.8': resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} @@ -2552,42 +2687,86 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.9': + resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.23.2': resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} engines: {node: '>=18.0.0'} + '@smithy/core@3.23.6': + resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.10': + resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.10': + resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.8': resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.2.10': + resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.2.8': resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-config-resolver@4.3.10': + resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-config-resolver@4.3.8': resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-node@4.2.10': + resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-node@4.2.8': resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-universal@4.2.10': + resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-universal@4.2.8': resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.11': + resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.9': resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.10': + resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.8': resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.10': + resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} + engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.8': resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} engines: {node: '>=18.0.0'} @@ -2600,6 +2779,14 @@ packages: resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@4.2.1': + resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.10': + resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.8': resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} @@ -2608,18 +2795,38 @@ packages: resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.20': + resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.33': resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.37': + resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.11': + resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.9': resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.10': + resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.8': resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.10': + resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.8': resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} @@ -2628,22 +2835,46 @@ packages: resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.12': + resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.10': + resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.8': resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.10': + resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.8': resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.10': + resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.8': resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.10': + resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.8': resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.10': + resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.8': resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} engines: {node: '>=18.0.0'} @@ -2652,6 +2883,14 @@ packages: resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.5': + resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.10': + resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.8': resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} @@ -2660,10 +2899,22 @@ packages: resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.0': + resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.12.0': resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.10': + resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -2672,14 +2923,26 @@ packages: resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.1': + resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.0': resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.1': + resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.1': resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.2': + resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -2688,30 +2951,62 @@ packages: resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@4.2.1': + resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} + engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.0': resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.1': + resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.32': resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.36': + resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.35': resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.39': + resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} + engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.8': resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.3.1': + resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.1': + resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.10': + resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.8': resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.10': + resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.8': resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} engines: {node: '>=18.0.0'} @@ -2720,10 +3015,18 @@ packages: resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.15': + resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} + engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.0': resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.1': + resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} + engines: {node: '>=18.0.0'} + '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} @@ -2732,10 +3035,105 @@ packages: resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} + '@smithy/util-utf8@4.2.1': + resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} + engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.0': resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.1': + resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} + engines: {node: '>=18.0.0'} + + '@snazzah/davey-android-arm-eabi@0.1.9': + resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@snazzah/davey-android-arm64@0.1.9': + resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@snazzah/davey-darwin-arm64@0.1.9': + resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@snazzah/davey-darwin-x64@0.1.9': + resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@snazzah/davey-freebsd-x64@0.1.9': + resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-arm64-musl@0.1.9': + resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-x64-gnu@0.1.9': + resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-linux-x64-musl@0.1.9': + resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-wasm32-wasi@0.1.9': + resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@snazzah/davey-win32-x64-msvc@0.1.9': + resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@snazzah/davey@0.1.9': + resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} + engines: {node: '>= 10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2906,43 +3304,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-aXfK/s3QlbzXvZoFQ07KJDNx86q61nCITSreqLytnqjhjsXUUuMACsxjy/YsReLG2bdii+mHTA2WB2IB0LKKGA==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-+bHnCeONX47pmVXTt6kuwxiLayDVqkLtshjqpqthXMWFFGk+1K/5ASbFEb2FumSABgB9hQ/xqkjj5QHUgGmbPg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Usm9oJzLPqK7Z7echSSaHnmTXhr3knLXycoyVZwRrmWC33aX2efZb+XrdaV/SMhdYjYHCZ6mE60qcK4nEaXdng==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-bavfJlI3JNH2F/7BX0drZ4JCSjLsCc2Dy5e2s6pc2wuLIzJ6hIjFaXIeB9TDbVYJE+MlLf6rtQF9nP9iSsgk9g==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-JaOwNBJ2nA0C/MBfMXilrVNv+hUpIzs7JtpSgpOsXa3Hq7BL2rnoO6WMuCo8IHz7v8+Lr+MPJufXVEHfrOtf5A==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Mngr3qdeO7Ey3DtsHe4oqIghXYcjOr9pVQtKXbijfT0slRtVPeF1TmEb/eH+Z+LsY1SOW8c/Cig1G4NDXZnghw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-8Gps/FPcQiyoHeDhRY3RXhJSJwQQuUIP5lepYO3+2xvCPPeeNBoOueiLoGKxno4CYbS4O2fPdVmymboX0ApjZA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260222.1': - resolution: {integrity: sha512-Uxon0iNhNqH/HkWvKmTmr7d5TJp6yomoyFHNpLIEghy91/DNWEtKMuLjNDYPFcoNxWpuJW9vuWTWeu3mcqT94Q==} + '@typescript/native-preview@7.0.0-dev.20260224.1': + resolution: {integrity: sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3211,8 +3609,8 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} bcrypt-pbkdf@1.0.2: @@ -3838,8 +4236,8 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - google-auth-library@10.5.0: - resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + google-auth-library@10.6.1: + resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} engines: {node: '>=18'} google-logging-utils@1.1.3: @@ -3857,10 +4255,6 @@ packages: resolution: {integrity: sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==} engines: {node: ^12.20.0 || >=14.13.1} - gtoken@8.0.0: - resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} - engines: {node: '>=18'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4625,8 +5019,8 @@ packages: zod: optional: true - openai@6.22.0: - resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} + openai@6.25.0: + resolution: {integrity: sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -4637,8 +5031,8 @@ packages: zod: optional: true - openclaw@2026.2.22: - resolution: {integrity: sha512-Bd+0qKfXL5sjzxnyjAVywIkGgl5riY2HOqWUA829+VRIih3TRLYOVXaO7rHb9getXR5jSWwiLliNloPXzcrfxw==} + openclaw@2026.2.23: + resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -4651,6 +5045,9 @@ packages: opusscript@0.0.8: resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==} + opusscript@0.1.1: + resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -4659,17 +5056,17 @@ packages: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} - oxfmt@0.34.0: - resolution: {integrity: sha512-t+zTE4XGpzPTK+Zk9gSwcJcFi4pqjl6PwO/ZxPBJiJQ2XCKMucwjPlHxvPHyVKJtkMSyrDGfQ7Ntg/hUr4OgHQ==} + oxfmt@0.35.0: + resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.14.2: - resolution: {integrity: sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==} + oxlint-tsgolint@0.15.0: + resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==} hasBin: true - oxlint@1.49.0: - resolution: {integrity: sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==} + oxlint@1.50.0: + resolution: {integrity: sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5711,7 +6108,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -5719,7 +6116,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5727,7 +6124,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -5736,7 +6133,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.2 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5792,6 +6189,58 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock-runtime@3.997.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-node': 3.972.12 + '@aws-sdk/eventstream-handler-node': 3.972.7 + '@aws-sdk/middleware-eventstream': 3.972.4 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.8 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/eventstream-serde-config-resolver': 4.3.10 + '@smithy/eventstream-serde-node': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-bedrock@3.995.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5837,6 +6286,51 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock@3.997.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-node': 3.972.12 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.993.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5896,6 +6390,30 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.973.13': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws-sdk/xml-builder': 3.972.6 + '@smithy/core': 3.23.6 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -5917,6 +6435,38 @@ snapshots: '@smithy/util-stream': 4.5.12 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/credential-provider-env': 3.972.11 + '@aws-sdk/credential-provider-http': 3.972.13 + '@aws-sdk/credential-provider-login': 3.972.11 + '@aws-sdk/credential-provider-process': 3.972.11 + '@aws-sdk/credential-provider-sso': 3.972.11 + '@aws-sdk/credential-provider-web-identity': 3.972.11 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -5936,6 +6486,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -5966,15 +6529,54 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 + '@aws-sdk/credential-provider-node@3.972.12': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.11 + '@aws-sdk/credential-provider-http': 3.972.13 + '@aws-sdk/credential-provider-ini': 3.972.11 + '@aws-sdk/credential-provider-process': 3.972.11 + '@aws-sdk/credential-provider-sso': 3.972.11 + '@aws-sdk/credential-provider-web-identity': 3.972.11 + '@aws-sdk/types': 3.973.2 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.9': + dependencies: + '@aws-sdk/core': 3.973.11 '@aws-sdk/types': 3.973.1 '@smithy/property-provider': 4.2.8 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/token-providers': 3.997.0 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.972.9': dependencies: '@aws-sdk/client-sso': 3.993.0 @@ -5988,6 +6590,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.11': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.9': dependencies: '@aws-sdk/core': 3.973.11 @@ -6007,6 +6621,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6014,6 +6635,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6021,12 +6649,25 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6035,6 +6676,14 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.11': dependencies: '@aws-sdk/core': 3.973.11 @@ -6045,6 +6694,16 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@smithy/core': 3.23.6 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.6': dependencies: '@aws-sdk/types': 3.973.1 @@ -6060,6 +6719,21 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-format-url': 3.972.4 + '@smithy/eventstream-codec': 4.2.10 + '@smithy/eventstream-serde-browser': 4.2.10 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.993.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6146,6 +6820,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.996.1': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.13 + '@aws-sdk/middleware-host-header': 3.972.4 + '@aws-sdk/middleware-logger': 3.972.4 + '@aws-sdk/middleware-recursion-detection': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/region-config-resolver': 3.972.4 + '@aws-sdk/types': 3.973.2 + '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/util-user-agent-browser': 3.972.4 + '@aws-sdk/util-user-agent-node': 3.972.12 + '@smithy/config-resolver': 4.4.9 + '@smithy/core': 3.23.6 + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/hash-node': 4.2.10 + '@smithy/invalid-dependency': 4.2.10 + '@smithy/middleware-content-length': 4.2.10 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-retry': 4.4.37 + '@smithy/middleware-serde': 4.2.11 + '@smithy/middleware-stack': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/node-http-handler': 4.4.12 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-body-length-node': 4.2.2 + '@smithy/util-defaults-mode-browser': 4.3.36 + '@smithy/util-defaults-mode-node': 4.2.39 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6154,6 +6871,14 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/config-resolver': 4.4.9 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.993.0': dependencies: '@aws-sdk/core': 3.973.11 @@ -6178,11 +6903,28 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.997.0': + dependencies: + '@aws-sdk/core': 3.973.13 + '@aws-sdk/nested-clients': 3.996.1 + '@aws-sdk/types': 3.973.2 + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.973.1': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/types@3.973.2': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.993.0': dependencies: '@aws-sdk/types': 3.973.1 @@ -6199,6 +6941,14 @@ snapshots: '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.996.1': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-endpoints': 3.3.1 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -6206,6 +6956,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 @@ -6217,6 +6974,13 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.972.4': + dependencies: + '@aws-sdk/types': 3.973.2 + '@smithy/types': 4.13.0 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.972.10': dependencies: '@aws-sdk/middleware-user-agent': 3.972.11 @@ -6225,12 +6989,26 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.972.12': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.13 + '@aws-sdk/types': 3.973.2 + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.5': dependencies: '@smithy/types': 4.12.0 fast-xml-parser: 5.3.6 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.6': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.3.6 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.3': {} '@azure/abort-controller@2.1.2': @@ -6322,6 +7100,26 @@ snapshots: - opusscript - utf-8-validate + '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)': + dependencies: + '@types/node': 25.3.0 + discord-api-types: 0.38.37 + optionalDependencies: + '@cloudflare/workers-types': 4.20260120.0 + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@hono/node-server': 1.19.9(hono@4.11.10) + '@types/bun': 1.3.9 + '@types/ws': 8.18.1 + ws: 8.19.0 + transitivePeerDependencies: + - '@discordjs/opus' + - bufferutil + - ffmpeg-static + - hono + - node-opus + - opusscript + - utf-8-validate + '@cacheable/memory@2.0.7': dependencies: '@cacheable/utils': 2.3.4 @@ -6473,6 +7271,21 @@ snapshots: - opusscript - utf-8-validate + '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': + dependencies: + '@types/ws': 8.18.1 + discord-api-types: 0.38.40 + prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1) + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - '@discordjs/opus' + - bufferutil + - ffmpeg-static + - node-opus + - opusscript + - utf-8-validate + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -6572,7 +7385,7 @@ snapshots: '@google/genai@1.42.0': dependencies: - google-auth-library: 10.5.0 + google-auth-library: 10.6.1 p-retry: 4.6.2 protobufjs: 7.5.4 ws: 8.19.0 @@ -6927,6 +7740,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.54.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -6951,6 +7776,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.997.0 + '@google/genai': 1.42.0 + '@mistralai/mistralai': 1.10.0 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.10.0(ws@8.19.0)(zod@4.3.6) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.22.0 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-coding-agent@0.54.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -6980,6 +7829,35 @@ snapshots: - ws - zod + '@mariozechner/pi-coding-agent@0.55.0(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/jiti': 2.6.5 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + file-type: 21.3.0 + glob: 13.0.6 + hosted-git-info: 9.0.2 + ignore: 7.0.5 + marked: 15.0.12 + minimatch: 10.2.1 + proper-lockfile: 4.1.2 + yaml: 2.8.2 + optionalDependencies: + '@mariozechner/clipboard': 0.3.2 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-tui@0.54.1': dependencies: '@types/mime-types': 2.1.4 @@ -6989,6 +7867,15 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 + '@mariozechner/pi-tui@0.55.0': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + koffi: 2.15.1 + marked: 15.0.12 + mime-types: 3.0.2 + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': dependencies: https-proxy-agent: 7.0.6 @@ -7560,136 +8447,136 @@ snapshots: '@oxc-project/types@0.112.0': {} - '@oxfmt/binding-android-arm-eabi@0.34.0': + '@oxfmt/binding-android-arm-eabi@0.35.0': optional: true - '@oxfmt/binding-android-arm64@0.34.0': + '@oxfmt/binding-android-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-arm64@0.34.0': + '@oxfmt/binding-darwin-arm64@0.35.0': optional: true - '@oxfmt/binding-darwin-x64@0.34.0': + '@oxfmt/binding-darwin-x64@0.35.0': optional: true - '@oxfmt/binding-freebsd-x64@0.34.0': + '@oxfmt/binding-freebsd-x64@0.35.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.34.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.34.0': + '@oxfmt/binding-linux-arm-musleabihf@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.34.0': + '@oxfmt/binding-linux-arm64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.34.0': + '@oxfmt/binding-linux-arm64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.34.0': + '@oxfmt/binding-linux-ppc64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.34.0': + '@oxfmt/binding-linux-riscv64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.34.0': + '@oxfmt/binding-linux-riscv64-musl@0.35.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.34.0': + '@oxfmt/binding-linux-s390x-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.34.0': + '@oxfmt/binding-linux-x64-gnu@0.35.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.34.0': + '@oxfmt/binding-linux-x64-musl@0.35.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.34.0': + '@oxfmt/binding-openharmony-arm64@0.35.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.34.0': + '@oxfmt/binding-win32-arm64-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.34.0': + '@oxfmt/binding-win32-ia32-msvc@0.35.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.34.0': + '@oxfmt/binding-win32-x64-msvc@0.35.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.14.2': + '@oxlint-tsgolint/darwin-arm64@0.15.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.14.2': + '@oxlint-tsgolint/darwin-x64@0.15.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.14.2': + '@oxlint-tsgolint/linux-arm64@0.15.0': optional: true - '@oxlint-tsgolint/linux-x64@0.14.2': + '@oxlint-tsgolint/linux-x64@0.15.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.14.2': + '@oxlint-tsgolint/win32-arm64@0.15.0': optional: true - '@oxlint-tsgolint/win32-x64@0.14.2': + '@oxlint-tsgolint/win32-x64@0.15.0': optional: true - '@oxlint/binding-android-arm-eabi@1.49.0': + '@oxlint/binding-android-arm-eabi@1.50.0': optional: true - '@oxlint/binding-android-arm64@1.49.0': + '@oxlint/binding-android-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-arm64@1.49.0': + '@oxlint/binding-darwin-arm64@1.50.0': optional: true - '@oxlint/binding-darwin-x64@1.49.0': + '@oxlint/binding-darwin-x64@1.50.0': optional: true - '@oxlint/binding-freebsd-x64@1.49.0': + '@oxlint/binding-freebsd-x64@1.50.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.49.0': + '@oxlint/binding-linux-arm-gnueabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.49.0': + '@oxlint/binding-linux-arm-musleabihf@1.50.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.49.0': + '@oxlint/binding-linux-arm64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.49.0': + '@oxlint/binding-linux-arm64-musl@1.50.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.49.0': + '@oxlint/binding-linux-ppc64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.49.0': + '@oxlint/binding-linux-riscv64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.49.0': + '@oxlint/binding-linux-riscv64-musl@1.50.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.49.0': + '@oxlint/binding-linux-s390x-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.49.0': + '@oxlint/binding-linux-x64-gnu@1.50.0': optional: true - '@oxlint/binding-linux-x64-musl@1.49.0': + '@oxlint/binding-linux-x64-musl@1.50.0': optional: true - '@oxlint/binding-openharmony-arm64@1.49.0': + '@oxlint/binding-openharmony-arm64@1.50.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.49.0': + '@oxlint/binding-win32-arm64-msvc@1.50.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.49.0': + '@oxlint/binding-win32-ia32-msvc@1.50.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.49.0': + '@oxlint/binding-win32-x64-msvc@1.50.0': optional: true '@pinojs/redact@0.4.0': {} @@ -7967,6 +8854,11 @@ snapshots: transitivePeerDependencies: - debug + '@smithy/abort-controller@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/abort-controller@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -7981,6 +8873,15 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 + '@smithy/config-resolver@4.4.9': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-endpoints': 3.3.1 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + '@smithy/core@3.23.2': dependencies: '@smithy/middleware-serde': 4.2.9 @@ -7994,6 +8895,27 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.23.6': + dependencies: + '@smithy/middleware-serde': 4.2.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.15 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.10': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -8002,6 +8924,13 @@ snapshots: '@smithy/url-parser': 4.2.8 tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.10': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.8': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8009,29 +8938,60 @@ snapshots: '@smithy/util-hex-encoding': 4.2.0 tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.2.10': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.2.8': dependencies: '@smithy/eventstream-serde-universal': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-config-resolver@4.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-config-resolver@4.3.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-node@4.2.10': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-node@4.2.8': dependencies: '@smithy/eventstream-serde-universal': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/eventstream-serde-universal@4.2.10': + dependencies: + '@smithy/eventstream-codec': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/eventstream-serde-universal@4.2.8': dependencies: '@smithy/eventstream-codec': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.9': dependencies: '@smithy/protocol-http': 5.3.8 @@ -8040,6 +9000,13 @@ snapshots: '@smithy/util-base64': 4.3.0 tslib: 2.8.1 + '@smithy/hash-node@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/hash-node@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8047,6 +9014,11 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8060,6 +9032,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/is-array-buffer@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.10': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.8': dependencies: '@smithy/protocol-http': 5.3.8 @@ -8077,6 +9059,17 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.20': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-serde': 4.2.11 + '@smithy/node-config-provider': 4.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.33': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -8089,17 +9082,47 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/middleware-retry@4.4.37': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/service-error-classification': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-retry': 4.2.10 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.9': dependencies: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.10': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.8': dependencies: '@smithy/property-provider': 4.2.8 @@ -8115,27 +9138,60 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/node-http-handler@4.4.12': + dependencies: + '@smithy/abort-controller': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.1 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.8': dependencies: '@smithy/types': 4.12.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/service-error-classification@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/service-error-classification@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -8145,6 +9201,22 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.10': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.8': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -8166,10 +9238,30 @@ snapshots: '@smithy/util-stream': 4.5.12 tslib: 2.8.1 + '@smithy/smithy-client@4.12.0': + dependencies: + '@smithy/core': 3.23.6 + '@smithy/middleware-endpoint': 4.4.20 + '@smithy/middleware-stack': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.15 + tslib: 2.8.1 + '@smithy/types@4.12.0': dependencies: tslib: 2.8.1 + '@smithy/types@4.13.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.10': + dependencies: + '@smithy/querystring-parser': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 @@ -8182,14 +9274,28 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/util-base64@4.3.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.1': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -8200,10 +9306,19 @@ snapshots: '@smithy/is-array-buffer': 4.2.0 tslib: 2.8.1 + '@smithy/util-buffer-from@4.2.1': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + tslib: 2.8.1 + '@smithy/util-config-provider@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-config-provider@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.32': dependencies: '@smithy/property-provider': 4.2.8 @@ -8211,6 +9326,13 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.36': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.35': dependencies: '@smithy/config-resolver': 4.4.6 @@ -8221,21 +9343,52 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.39': + dependencies: + '@smithy/config-resolver': 4.4.9 + '@smithy/credential-provider-imds': 4.2.10 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/smithy-client': 4.12.0 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-endpoints@3.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.3.1': + dependencies: + '@smithy/node-config-provider': 4.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-middleware@4.2.8': dependencies: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.10': + dependencies: + '@smithy/service-error-classification': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-retry@4.2.8': dependencies: '@smithy/service-error-classification': 4.2.8 @@ -8253,10 +9406,25 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/util-stream@4.5.15': + dependencies: + '@smithy/fetch-http-handler': 5.3.11 + '@smithy/node-http-handler': 4.4.12 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 @@ -8267,10 +9435,80 @@ snapshots: '@smithy/util-buffer-from': 4.2.0 tslib: 2.8.1 + '@smithy/util-utf8@4.2.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + tslib: 2.8.1 + '@smithy/uuid@1.1.0': dependencies: tslib: 2.8.1 + '@smithy/uuid@1.1.1': + dependencies: + tslib: 2.8.1 + + '@snazzah/davey-android-arm-eabi@0.1.9': + optional: true + + '@snazzah/davey-android-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-x64@0.1.9': + optional: true + + '@snazzah/davey-freebsd-x64@0.1.9': + optional: true + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-musl@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-musl@0.1.9': + optional: true + + '@snazzah/davey-wasm32-wasi@0.1.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-x64-msvc@0.1.9': + optional: true + + '@snazzah/davey@0.1.9': + optionalDependencies: + '@snazzah/davey-android-arm-eabi': 0.1.9 + '@snazzah/davey-android-arm64': 0.1.9 + '@snazzah/davey-darwin-arm64': 0.1.9 + '@snazzah/davey-darwin-x64': 0.1.9 + '@snazzah/davey-freebsd-x64': 0.1.9 + '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 + '@snazzah/davey-linux-arm64-gnu': 0.1.9 + '@snazzah/davey-linux-arm64-musl': 0.1.9 + '@snazzah/davey-linux-x64-gnu': 0.1.9 + '@snazzah/davey-linux-x64-musl': 0.1.9 + '@snazzah/davey-wasm32-wasi': 0.1.9 + '@snazzah/davey-win32-arm64-msvc': 0.1.9 + '@snazzah/davey-win32-ia32-msvc': 0.1.9 + '@snazzah/davey-win32-x64-msvc': 0.1.9 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.19': @@ -8495,36 +9733,36 @@ snapshots: dependencies: '@types/node': 25.3.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260222.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260222.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260222.1': + '@typescript/native-preview@7.0.0-dev.20260224.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260222.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260222.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260224.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -8864,7 +10102,7 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.1.0: {} + basic-ftp@5.2.0: {} bcrypt-pbkdf@1.0.2: dependencies: @@ -9535,7 +10773,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.1.0 + basic-ftp: 5.2.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -9572,14 +10810,13 @@ snapshots: path-is-absolute: 1.0.1 optional: true - google-auth-library@10.5.0: + google-auth-library@10.6.1: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 gaxios: 7.1.3 gcp-metadata: 8.1.2 google-logging-utils: 1.1.3 - gtoken: 8.0.0 jws: 4.0.1 transitivePeerDependencies: - supports-color @@ -9600,13 +10837,6 @@ snapshots: - encoding - supports-color - gtoken@8.0.0: - dependencies: - gaxios: 7.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - has-flag@4.0.0: {} has-own@1.0.1: {} @@ -10397,12 +11627,12 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openai@6.22.0(ws@8.19.0)(zod@4.3.6): + openai@6.25.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.2.22(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): + openclaw@2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': 3.995.0 @@ -10485,6 +11715,8 @@ snapshots: opusscript@0.0.8: {} + opusscript@0.1.1: {} + ora@8.2.0: dependencies: chalk: 5.6.2 @@ -10499,61 +11731,61 @@ snapshots: osc-progress@0.3.0: {} - oxfmt@0.34.0: + oxfmt@0.35.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.34.0 - '@oxfmt/binding-android-arm64': 0.34.0 - '@oxfmt/binding-darwin-arm64': 0.34.0 - '@oxfmt/binding-darwin-x64': 0.34.0 - '@oxfmt/binding-freebsd-x64': 0.34.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.34.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.34.0 - '@oxfmt/binding-linux-arm64-gnu': 0.34.0 - '@oxfmt/binding-linux-arm64-musl': 0.34.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.34.0 - '@oxfmt/binding-linux-riscv64-musl': 0.34.0 - '@oxfmt/binding-linux-s390x-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-gnu': 0.34.0 - '@oxfmt/binding-linux-x64-musl': 0.34.0 - '@oxfmt/binding-openharmony-arm64': 0.34.0 - '@oxfmt/binding-win32-arm64-msvc': 0.34.0 - '@oxfmt/binding-win32-ia32-msvc': 0.34.0 - '@oxfmt/binding-win32-x64-msvc': 0.34.0 - - oxlint-tsgolint@0.14.2: + '@oxfmt/binding-android-arm-eabi': 0.35.0 + '@oxfmt/binding-android-arm64': 0.35.0 + '@oxfmt/binding-darwin-arm64': 0.35.0 + '@oxfmt/binding-darwin-x64': 0.35.0 + '@oxfmt/binding-freebsd-x64': 0.35.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 + '@oxfmt/binding-linux-arm64-gnu': 0.35.0 + '@oxfmt/binding-linux-arm64-musl': 0.35.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 + '@oxfmt/binding-linux-riscv64-musl': 0.35.0 + '@oxfmt/binding-linux-s390x-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-gnu': 0.35.0 + '@oxfmt/binding-linux-x64-musl': 0.35.0 + '@oxfmt/binding-openharmony-arm64': 0.35.0 + '@oxfmt/binding-win32-arm64-msvc': 0.35.0 + '@oxfmt/binding-win32-ia32-msvc': 0.35.0 + '@oxfmt/binding-win32-x64-msvc': 0.35.0 + + oxlint-tsgolint@0.15.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.14.2 - '@oxlint-tsgolint/darwin-x64': 0.14.2 - '@oxlint-tsgolint/linux-arm64': 0.14.2 - '@oxlint-tsgolint/linux-x64': 0.14.2 - '@oxlint-tsgolint/win32-arm64': 0.14.2 - '@oxlint-tsgolint/win32-x64': 0.14.2 - - oxlint@1.49.0(oxlint-tsgolint@0.14.2): + '@oxlint-tsgolint/darwin-arm64': 0.15.0 + '@oxlint-tsgolint/darwin-x64': 0.15.0 + '@oxlint-tsgolint/linux-arm64': 0.15.0 + '@oxlint-tsgolint/linux-x64': 0.15.0 + '@oxlint-tsgolint/win32-arm64': 0.15.0 + '@oxlint-tsgolint/win32-x64': 0.15.0 + + oxlint@1.50.0(oxlint-tsgolint@0.15.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.49.0 - '@oxlint/binding-android-arm64': 1.49.0 - '@oxlint/binding-darwin-arm64': 1.49.0 - '@oxlint/binding-darwin-x64': 1.49.0 - '@oxlint/binding-freebsd-x64': 1.49.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.49.0 - '@oxlint/binding-linux-arm-musleabihf': 1.49.0 - '@oxlint/binding-linux-arm64-gnu': 1.49.0 - '@oxlint/binding-linux-arm64-musl': 1.49.0 - '@oxlint/binding-linux-ppc64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-gnu': 1.49.0 - '@oxlint/binding-linux-riscv64-musl': 1.49.0 - '@oxlint/binding-linux-s390x-gnu': 1.49.0 - '@oxlint/binding-linux-x64-gnu': 1.49.0 - '@oxlint/binding-linux-x64-musl': 1.49.0 - '@oxlint/binding-openharmony-arm64': 1.49.0 - '@oxlint/binding-win32-arm64-msvc': 1.49.0 - '@oxlint/binding-win32-ia32-msvc': 1.49.0 - '@oxlint/binding-win32-x64-msvc': 1.49.0 - oxlint-tsgolint: 0.14.2 + '@oxlint/binding-android-arm-eabi': 1.50.0 + '@oxlint/binding-android-arm64': 1.50.0 + '@oxlint/binding-darwin-arm64': 1.50.0 + '@oxlint/binding-darwin-x64': 1.50.0 + '@oxlint/binding-freebsd-x64': 1.50.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.50.0 + '@oxlint/binding-linux-arm-musleabihf': 1.50.0 + '@oxlint/binding-linux-arm64-gnu': 1.50.0 + '@oxlint/binding-linux-arm64-musl': 1.50.0 + '@oxlint/binding-linux-ppc64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-gnu': 1.50.0 + '@oxlint/binding-linux-riscv64-musl': 1.50.0 + '@oxlint/binding-linux-s390x-gnu': 1.50.0 + '@oxlint/binding-linux-x64-gnu': 1.50.0 + '@oxlint/binding-linux-x64-musl': 1.50.0 + '@oxlint/binding-openharmony-arm64': 1.50.0 + '@oxlint/binding-win32-arm64-msvc': 1.50.0 + '@oxlint/binding-win32-ia32-msvc': 1.50.0 + '@oxlint/binding-win32-x64-msvc': 1.50.0 + oxlint-tsgolint: 0.15.0 p-finally@1.0.0: {} @@ -10716,6 +11948,11 @@ snapshots: '@discordjs/opus': 0.10.0 opusscript: 0.0.8 + prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): + optionalDependencies: + '@discordjs/opus': 0.10.0 + opusscript: 0.1.1 + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -10904,7 +12141,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260222.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -10917,7 +12154,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260222.1 + '@typescript/native-preview': 7.0.0-dev.20260224.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -11366,7 +12603,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260222.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -11377,7 +12614,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260222.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs new file mode 100644 index 000000000000..af7b56a371fb --- /dev/null +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "infra", "outbound"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "src", "media-understanding"), + path.join(repoRoot, "extensions"), +]; +const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function collectOsTmpdirImports(sourceFile) { + const osModuleSpecifiers = new Set(["node:os", "os"]); + const osNamespaceOrDefault = new Set(); + const namedTmpdir = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + if (!osModuleSpecifiers.has(statement.moduleSpecifier.text)) { + continue; + } + const clause = statement.importClause; + if (clause.name) { + osNamespaceOrDefault.add(clause.name.text); + } + if (!clause.namedBindings) { + continue; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + osNamespaceOrDefault.add(clause.namedBindings.name.text); + continue; + } + for (const element of clause.namedBindings.elements) { + if ((element.propertyName?.text ?? element.name.text) === "tmpdir") { + namedTmpdir.add(element.name.text); + } + } + } + return { osNamespaceOrDefault, namedTmpdir }; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "tmpdir" && + ts.isIdentifier(callee.expression) && + osNamespaceOrDefault.has(callee.expression.text) + ) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + ).flat(); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const line of findMessagingTmpdirCallLines(content, filePath)) { + violations.push(`${path.relative(repoRoot, filePath)}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs new file mode 100644 index 000000000000..930bfe60a612 --- /dev/null +++ b/scripts/check-no-raw-window-open.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const uiSourceDir = path.join(repoRoot, "ui", "src", "ui"); +const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]); + +function isTestFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".browser.test.ts") || + filePath.endsWith(".node.test.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +function asPropertyAccess(expression) { + if (ts.isPropertyAccessExpression(expression)) { + return expression; + } + if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) { + return expression; + } + return null; +} + +function isRawWindowOpenCall(expression) { + const propertyAccess = asPropertyAccess(unwrapExpression(expression)); + if (!propertyAccess || propertyAccess.name.text !== "open") { + return false; + } + + const receiver = unwrapExpression(propertyAccess.expression); + return ( + ts.isIdentifier(receiver) && (receiver.text === "window" || receiver.text === "globalThis") + ); +} + +export function findRawWindowOpenLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) { + const line = + sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; + lines.push(line); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = await collectTypeScriptFiles(uiSourceDir); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + + const content = await fs.readFile(filePath, "utf8"); + for (const line of findRawWindowOpenLines(content, filePath)) { + const relPath = path.relative(repoRoot, filePath); + violations.push(`${relPath}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found raw window.open usage outside safe helper:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead."); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 0aa0773a5de7..0749fc13f2d9 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -22,20 +22,23 @@ echo "Creating Docker network..." docker network create "$NET_NAME" >/dev/null echo "Starting gateway container..." - docker run --rm -d \ - --name "$GW_NAME" \ - --network "$NET_NAME" \ - -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ - -e "OPENCLAW_SKIP_CHANNELS=1" \ - -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ - -e "OPENCLAW_SKIP_CRON=1" \ - -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - "$IMAGE_NAME" \ - bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" +docker run -d \ + --name "$GW_NAME" \ + --network "$NET_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CRON=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail; entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" echo "Waiting for gateway to come up..." ready=0 for _ in $(seq 1 40); do + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then + break + fi if docker exec "$GW_NAME" bash -lc "node --input-type=module -e ' import net from \"node:net\"; const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT }); @@ -65,7 +68,11 @@ done if [ "$ready" -ne 1 ]; then echo "Gateway failed to start" - docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" = "true" ]; then + docker exec "$GW_NAME" bash -lc "tail -n 80 /tmp/gateway-net-e2e.log" || true + else + docker logs "$GW_NAME" 2>&1 | tail -n 120 || true + fi exit 1 fi diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh index 9ce1a89f2db6..0963d8d84994 100755 --- a/scripts/ios-team-id.sh +++ b/scripts/ios-team-id.sh @@ -10,15 +10,35 @@ preferred_team="${IOS_PREFERRED_TEAM_ID:-${OPENCLAW_IOS_DEFAULT_TEAM_ID:-Y5PE65H preferred_team_name="${IOS_PREFERRED_TEAM_NAME:-}" allow_keychain_fallback="${IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK:-0}" prefer_non_free_team="${IOS_PREFER_NON_FREE_TEAM:-1}" +preferred_team="${preferred_team//$'\r'/}" +preferred_team_name="${preferred_team_name//$'\r'/}" declare -a team_ids=() declare -a team_is_free=() declare -a team_names=() +python_cmd="" + +detect_python() { + local candidate + for candidate in "${IOS_PYTHON_BIN:-}" python3 python /usr/bin/python3; do + [[ -n "$candidate" ]] || continue + if command -v "$candidate" >/dev/null 2>&1; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +python_cmd="$(detect_python || true)" append_team() { local candidate_id="$1" local candidate_is_free="$2" local candidate_name="$3" + candidate_id="${candidate_id//$'\r'/}" + candidate_is_free="${candidate_is_free//$'\r'/}" + candidate_name="${candidate_name//$'\r'/}" [[ -z "$candidate_id" ]] && return local i @@ -36,13 +56,14 @@ append_team() { load_teams_from_xcode_preferences() { local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" [[ -f "$plist_path" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 while IFS=$'\t' read -r team_id is_free team_name; do [[ -z "$team_id" ]] && continue append_team "$team_id" "${is_free:-0}" "${team_name:-}" done < <( plutil -extract IDEProvisioningTeams json -o - "$plist_path" 2>/dev/null \ - | /usr/bin/python3 -c ' + | "$python_cmd" -c ' import json import sys @@ -80,9 +101,49 @@ load_teams_from_legacy_defaults_key() { ) } +load_teams_from_xcode_managed_profiles() { + local profiles_dir="${HOME}/Library/MobileDevice/Provisioning Profiles" + [[ -d "$profiles_dir" ]] || return 0 + [[ -n "$python_cmd" ]] || return 0 + + while IFS= read -r team; do + [[ -z "$team" ]] && continue + append_team "$team" "0" "" + done < <( + for p in "${profiles_dir}"/*.mobileprovision; do + [[ -f "$p" ]] || continue + security cms -D -i "$p" 2>/dev/null \ + | "$python_cmd" -c ' +import plistlib, sys +try: + raw = sys.stdin.buffer.read() + if not raw: + raise SystemExit(0) + d = plistlib.loads(raw) + for tid in d.get("TeamIdentifier", []): + print(tid) +except Exception: + pass +' 2>/dev/null + done | sort -u + ) +} + +has_xcode_account() { + local plist_path="${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" + [[ -f "$plist_path" ]] || return 1 + local accts + accts="$(defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists 2>/dev/null || true)" + [[ -n "$accts" ]] && [[ "$accts" != *"does not exist"* ]] && grep -q 'identifier' <<< "$accts" +} + load_teams_from_xcode_preferences load_teams_from_legacy_defaults_key +if [[ ${#team_ids[@]} -eq 0 ]]; then + load_teams_from_xcode_managed_profiles +fi + if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then while IFS= read -r team; do [[ -z "$team" ]] && continue @@ -95,7 +156,19 @@ if [[ ${#team_ids[@]} -eq 0 && "$allow_keychain_fallback" == "1" ]]; then fi if [[ ${#team_ids[@]} -eq 0 ]]; then - if [[ "$allow_keychain_fallback" == "1" ]]; then + if has_xcode_account; then + echo "An Apple account is signed in to Xcode, but no Team ID could be resolved." >&2 + echo "" >&2 + echo "On Xcode 16+, team data is not written until you build a project." >&2 + echo "To fix this, do ONE of the following:" >&2 + echo "" >&2 + echo " 1. Open the iOS project in Xcode, select your Team in Signing &" >&2 + echo " Capabilities, and build once. Then re-run this script." >&2 + echo "" >&2 + echo " 2. Set your Team ID directly:" >&2 + echo " export IOS_DEVELOPMENT_TEAM=" >&2 + echo " Find your Team ID at: https://developer.apple.com/account#MembershipDetailsCard" >&2 + elif [[ "$allow_keychain_fallback" == "1" ]]; then echo "No Apple Team ID found. Open Xcode or install signing certificates first." >&2 else echo "No Apple Team ID found in Xcode accounts. Open Xcode → Settings → Accounts and sign in, then retry." >&2 diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 0db3fad39b0e..ba1aab336b61 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -265,5 +265,5 @@ else fi if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then - run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/bot.molt.gateway.plist' | head -n 40 || true" + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/ai.openclaw.gateway.plist' | head -n 40 || true" fi diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index bb0641df16b9..3cc5ed2bf0b6 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-modern}}" \ -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MAX_MODELS:-24}}" \ -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 1a7df857c7ae..f3aecc0049a9 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -22,8 +22,9 @@ docker run --rm -t \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_LIVE_TEST=1 \ - -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-all}}" \ + -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-modern}}" \ -e OPENCLAW_LIVE_PROVIDERS="${OPENCLAW_LIVE_PROVIDERS:-${CLAWDBOT_LIVE_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_MAX_MODELS="${OPENCLAW_LIVE_MAX_MODELS:-${CLAWDBOT_LIVE_MAX_MODELS:-48}}" \ -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index cb7e950a5dab..0ec8d2fdc5f3 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -19,6 +19,25 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Process supervision + docker setup suites are stable but setup-heavy. + "src/process/supervisor/supervisor.test.ts", + "src/docker-setup.test.ts", + // Filesystem-heavy skills sync suite. + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + // Real git hook integration test; keep signal, move off unit-fast critical path. + "test/git-hooks-pre-commit.test.ts", + // Setup-heavy doctor command suites; keep them off the unit-fast critical path. + "src/commands/doctor.warns-state-directory-is-missing.test.ts", + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", + // Setup-heavy CLI update flow suite; move off unit-fast critical path. + "src/cli/update-cli.test.ts", + // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. + "src/config/schema.test.ts", + "src/config/schema.tags.test.ts", + // CLI smoke/agent flows are stable but setup-heavy. + "src/cli/program.smoke.test.ts", + "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", "src/web/media.test.ts", @@ -210,7 +229,7 @@ const defaultWorkerBudget = unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(4, Math.floor(localWorkers / 3))), + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), } : lowMemLocalHost ? { diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b0193..0e106e65969d 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -15,7 +15,6 @@ const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); const readmePath = resolve("README.md"); -const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; const seedCommit = mapConfig.seedCommit ?? null; const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); @@ -98,33 +97,33 @@ for (const login of ensureLogins) { const entriesByKey = new Map(); for (const seed of seedEntries) { - const login = loginFromUrl(seed.html_url); - const resolvedLogin = - login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); - const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; - const avatar = - seed.avatar_url && !isGhostAvatar(seed.avatar_url) - ? normalizeAvatar(seed.avatar_url) - : placeholderAvatar; + const login = + loginFromUrl(seed.html_url) ?? + resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + const key = login.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(login); + if (!user) { + continue; + } + apiByLogin.set(key, user); const existing = entriesByKey.get(key); if (!existing) { - const user = resolvedLogin ? apiByLogin.get(key) : null; entriesByKey.set(key, { key, - login: resolvedLogin ?? login ?? undefined, + login: user.login, display: seed.display, - html_url: user?.html_url ?? seed.html_url, - avatar_url: user?.avatar_url ?? avatar, + html_url: user.html_url, + avatar_url: user.avatar_url, lines: 0, }); } else { existing.display = existing.display || seed.display; - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - existing.avatar_url = avatar; - } - if (!existing.html_url || existing.html_url.includes("/search?q=")) { - existing.html_url = seed.html_url; - } + existing.login = user.login; + existing.html_url = user.html_url; + existing.avatar_url = user.avatar_url; } } @@ -138,52 +137,37 @@ for (const item of contributors) { ? item.login : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); - if (resolvedLogin) { - const key = resolvedLogin.toLowerCase(); - const existing = entriesByKey.get(key); - if (!existing) { - let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - entriesByKey.set(key, { - key, - login: user.login, - display: pickDisplay(baseName, user.login, existing?.display), - html_url: user.html_url, - avatar_url: normalizeAvatar(user.avatar_url), - lines: lines > 0 ? lines : contributions, - }); - } - } else if (existing) { - existing.login = existing.login ?? resolvedLogin; - existing.display = pickDisplay(baseName, existing.login, existing.display); - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - existing.html_url = user.html_url; - existing.avatar_url = normalizeAvatar(user.avatar_url); - } - } - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); - } + if (!resolvedLogin) { + continue; + } + + const key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { continue; } + apiByLogin.set(key, user); - const anonKey = `name:${normalizeName(baseName)}`; - const existingAnon = entriesByKey.get(anonKey); - if (!existingAnon) { - entriesByKey.set(anonKey, { - key: anonKey, - display: baseName, - html_url: fallbackHref(baseName), - avatar_url: placeholderAvatar, - lines: item.contributions ?? 0, + const existing = entriesByKey.get(key); + if (!existing) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, }); } else { - existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + existing.login = user.login; + existing.display = pickDisplay(baseName, user.login, existing.display); + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); } } @@ -205,14 +189,6 @@ for (const [login, lines] of linesByLogin.entries()) { avatar_url: normalizeAvatar(user.avatar_url), lines: lines > 0 ? lines : contributions, }); - } else { - entriesByKey.set(login, { - key: login, - display: login, - html_url: fallbackHref(login), - avatar_url: placeholderAvatar, - lines, - }); } } @@ -323,10 +299,6 @@ function normalizeAvatar(url: string): string { return `${url}${sep}s=48`; } -function isGhostAvatar(url: string): boolean { - return url.toLowerCase().includes("ghost.png"); -} - function fetchUser(login: string): User | null { const normalized = normalizeLogin(login); if (!normalized) { diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index f921a1315763..ad4e0f56fd02 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -2,13 +2,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + hasConfiguredModelFallbacks, resolveAgentConfig, resolveAgentDir, resolveAgentEffectiveModelPrimary, resolveAgentExplicitModelPrimary, + resolveFallbackAgentId, resolveEffectiveModelFallbacks, resolveAgentModelFallbacksOverride, resolveAgentModelPrimary, + resolveRunModelFallbacksOverride, resolveAgentWorkspaceDir, } from "./agent-scope.js"; @@ -210,6 +213,109 @@ describe("resolveAgentConfig", () => { ).toEqual([]); }); + it("resolves fallback agent id from explicit agent id first", () => { + expect( + resolveFallbackAgentId({ + agentId: "Support", + sessionKey: "agent:main:session", + }), + ).toBe("support"); + }); + + it("resolves fallback agent id from session key when explicit id is missing", () => { + expect( + resolveFallbackAgentId({ + sessionKey: "agent:worker:session", + }), + ).toBe("worker"); + }); + + it("resolves run fallback overrides via shared helper", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [ + { + id: "support", + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + ], + }, + }; + + expect( + resolveRunModelFallbacksOverride({ + cfg, + agentId: "support", + sessionKey: "agent:main:session", + }), + ).toEqual(["openai/gpt-5.2"]); + expect( + resolveRunModelFallbacksOverride({ + cfg, + agentId: undefined, + sessionKey: "agent:support:session", + }), + ).toEqual(["openai/gpt-5.2"]); + }); + + it("computes whether any model fallbacks are configured via shared helper", () => { + const cfgDefaultsOnly: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: ["openai/gpt-4.1"], + }, + }, + list: [{ id: "main" }], + }, + }; + expect( + hasConfiguredModelFallbacks({ + cfg: cfgDefaultsOnly, + sessionKey: "agent:main:session", + }), + ).toBe(true); + + const cfgAgentOverrideOnly: OpenClawConfig = { + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + list: [ + { + id: "support", + model: { + fallbacks: ["openai/gpt-5.2"], + }, + }, + ], + }, + }; + expect( + hasConfiguredModelFallbacks({ + cfg: cfgAgentOverrideOnly, + agentId: "support", + sessionKey: "agent:support:session", + }), + ).toBe(true); + expect( + hasConfiguredModelFallbacks({ + cfg: cfgAgentOverrideOnly, + agentId: "main", + sessionKey: "agent:main:session", + }), + ).toBe(false); + }); + it("should return agent-specific sandbox config", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index c48cea9f6903..bdc880656969 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -7,13 +7,20 @@ import { DEFAULT_AGENT_ID, normalizeAgentId, parseAgentSessionKey, + resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { normalizeSkillFilter } from "./skills/filter.js"; import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; const log = createSubsystemLogger("agent-scope"); -export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +/** Strip null bytes from paths to prevent ENOTDIR errors. */ +function stripNullBytes(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\0/g, ""); +} + +export { resolveAgentIdFromSessionKey }; type AgentEntry = NonNullable["list"]>[number]; @@ -197,6 +204,41 @@ export function resolveAgentModelFallbacksOverride( return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined; } +export function resolveFallbackAgentId(params: { + agentId?: string | null; + sessionKey?: string | null; +}): string { + const explicitAgentId = typeof params.agentId === "string" ? params.agentId.trim() : ""; + if (explicitAgentId) { + return normalizeAgentId(explicitAgentId); + } + return resolveAgentIdFromSessionKey(params.sessionKey); +} + +export function resolveRunModelFallbacksOverride(params: { + cfg: OpenClawConfig | undefined; + agentId?: string | null; + sessionKey?: string | null; +}): string[] | undefined { + if (!params.cfg) { + return undefined; + } + return resolveAgentModelFallbacksOverride( + params.cfg, + resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }), + ); +} + +export function hasConfiguredModelFallbacks(params: { + cfg: OpenClawConfig | undefined; + agentId?: string | null; + sessionKey?: string | null; +}): boolean { + const fallbacksOverride = resolveRunModelFallbacksOverride(params); + const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model); + return (fallbacksOverride ?? defaultFallbacks).length > 0; +} + export function resolveEffectiveModelFallbacks(params: { cfg: OpenClawConfig; agentId: string; @@ -214,18 +256,18 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) { - return resolveUserPath(configured); + return stripNullBytes(resolveUserPath(configured)); } const defaultAgentId = resolveDefaultAgentId(cfg); if (id === defaultAgentId) { const fallback = cfg.agents?.defaults?.workspace?.trim(); if (fallback) { - return resolveUserPath(fallback); + return stripNullBytes(resolveUserPath(fallback)); } - return resolveDefaultAgentWorkspaceDir(process.env); + return stripNullBytes(resolveDefaultAgentWorkspaceDir(process.env)); } const stateDir = resolveStateDir(process.env); - return path.join(stateDir, `workspace-${id}`); + return stripNullBytes(path.join(stateDir, `workspace-${id}`)); } export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index c2720a7edde3..1a30d8a91199 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -26,6 +26,11 @@ async function withAuthProfileStore( provider: "anthropic", key: "sk-default", }, + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-default", + }, }, }), ); @@ -152,6 +157,29 @@ describe("markAuthProfileFailure", () => { fs.rmSync(agentDir, { recursive: true, force: true }); } }); + + it("does not persist cooldown windows for OpenRouter profiles", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "openrouter:default", + reason: "rate_limit", + agentDir, + }); + + await markAuthProfileFailure({ + store, + profileId: "openrouter:default", + reason: "billing", + agentDir, + }); + + expect(store.usageStats?.["openrouter:default"]).toBeUndefined(); + + const reloaded = ensureAuthProfileStore(agentDir); + expect(reloaded.usageStats?.["openrouter:default"]).toBeUndefined(); + }); + }); }); describe("calculateAuthProfileCooldownMs", () => { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts index a13ce8fd06d5..3e6437d7d27b 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.test.ts @@ -118,6 +118,50 @@ describe("resolveAuthProfileOrder", () => { }, ); + it.each(["store", "config"] as const)( + "keeps OpenRouter explicit order even when cooldown fields exist (%s)", + (orderSource) => { + const now = Date.now(); + const explicitOrder = ["openrouter:default", "openrouter:work"]; + const order = resolveAuthProfileOrder({ + cfg: + orderSource === "config" + ? { + auth: { + order: { openrouter: explicitOrder }, + }, + } + : undefined, + store: { + version: 1, + ...(orderSource === "store" ? { order: { openrouter: explicitOrder } } : {}), + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-default", + }, + "openrouter:work": { + type: "api_key", + provider: "openrouter", + key: "sk-or-work", + }, + }, + usageStats: { + "openrouter:default": { + cooldownUntil: now + 60_000, + disabledUntil: now + 120_000, + disabledReason: "billing", + }, + }, + }, + provider: "openrouter", + }); + + expect(order).toEqual(explicitOrder); + }, + ); + it("mode: oauth config accepts both oauth and token credentials (issue #559)", () => { const now = Date.now(); const storeWithBothTypes: AuthProfileStore = { diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 045a171fe8be..e95bb9f68ec7 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -102,13 +102,9 @@ export function resolveAuthProfileOrder(params: { const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = []; for (const profileId of deduped) { - const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0; - if ( - typeof cooldownUntil === "number" && - Number.isFinite(cooldownUntil) && - cooldownUntil > 0 && - now < cooldownUntil - ) { + if (isProfileInCooldown(store, profileId)) { + const cooldownUntil = + resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now; inCooldown.push({ profileId, cooldownUntil }); } else { available.push(profileId); diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 3d7c2305d3f6..0025007f7290 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -7,6 +7,7 @@ import { markAuthProfileFailure, resolveProfilesUnavailableReason, resolveProfileUnusableUntil, + resolveProfileUnusableUntilForDisplay, } from "./usage.js"; vi.mock("./store.js", async (importOriginal) => { @@ -24,6 +25,7 @@ function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore profiles: { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-test" }, "openai:default": { type: "api_key", provider: "openai", key: "sk-test-2" }, + "openrouter:default": { type: "api_key", provider: "openrouter", key: "sk-or-test" }, }, usageStats, }; @@ -51,6 +53,29 @@ describe("resolveProfileUnusableUntil", () => { }); }); +describe("resolveProfileUnusableUntilForDisplay", () => { + it("hides cooldown markers for OpenRouter profiles", () => { + const store = makeStore({ + "openrouter:default": { + cooldownUntil: Date.now() + 60_000, + }, + }); + + expect(resolveProfileUnusableUntilForDisplay(store, "openrouter:default")).toBeNull(); + }); + + it("keeps cooldown markers visible for other providers", () => { + const until = Date.now() + 60_000; + const store = makeStore({ + "anthropic:default": { + cooldownUntil: until, + }, + }); + + expect(resolveProfileUnusableUntilForDisplay(store, "anthropic:default")).toBe(until); + }); +}); + // --------------------------------------------------------------------------- // isProfileInCooldown // --------------------------------------------------------------------------- @@ -84,6 +109,17 @@ describe("isProfileInCooldown", () => { }); expect(isProfileInCooldown(store, "anthropic:default")).toBe(true); }); + + it("returns false for OpenRouter even when cooldown fields exist", () => { + const store = makeStore({ + "openrouter:default": { + cooldownUntil: Date.now() + 60_000, + disabledUntil: Date.now() + 60_000, + disabledReason: "billing", + }, + }); + expect(isProfileInCooldown(store, "openrouter:default")).toBe(false); + }); }); describe("resolveProfilesUnavailableReason", () => { diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index cc25aabdf670..958e3ae127e4 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -17,6 +17,10 @@ const FAILURE_REASON_ORDER = new Map( FAILURE_REASON_PRIORITY.map((reason, index) => [reason, index]), ); +function isAuthCooldownBypassedForProvider(provider: string | undefined): boolean { + return normalizeProviderId(provider ?? "") === "openrouter"; +} + export function resolveProfileUnusableUntil( stats: Pick, ): number | null { @@ -33,6 +37,9 @@ export function resolveProfileUnusableUntil( * Check if a profile is currently in cooldown (due to rate limiting or errors). */ export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean { + if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) { + return false; + } const stats = store.usageStats?.[profileId]; if (!stats) { return false; @@ -342,6 +349,9 @@ export function resolveProfileUnusableUntilForDisplay( store: AuthProfileStore, profileId: string, ): number | null { + if (isAuthCooldownBypassedForProvider(store.profiles[profileId]?.provider)) { + return null; + } const stats = store.usageStats?.[profileId]; if (!stats) { return null; @@ -425,11 +435,15 @@ export async function markAuthProfileFailure(params: { agentDir?: string; }): Promise { const { store, profileId, reason, agentDir, cfg } = params; + const profile = store.profiles[profileId]; + if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { + return; + } const updated = await updateAuthProfileStoreWithLock({ agentDir, updater: (freshStore) => { const profile = freshStore.profiles[profileId]; - if (!profile) { + if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { return false; } freshStore.usageStats = freshStore.usageStats ?? {}; diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 35f5e0408695..c14a3f62b91e 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -22,7 +22,13 @@ describe("requestExecApprovalDecision", () => { }); it("returns string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); const result = await requestExecApprovalDecision({ id: "approval-id", @@ -44,6 +50,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: undefined, host: "gateway", security: "allowlist", ask: "always", @@ -51,33 +58,112 @@ describe("requestExecApprovalDecision", () => { resolvedPath: "/usr/bin/echo", sessionKey: "session", timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, + ); + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "approval-id" }, ); }); it("returns null for missing or non-string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValueOnce({}); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id", expiresAtMs: 1234 }) + .mockResolvedValueOnce({}); await expect( requestExecApprovalDecision({ id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", }), ).resolves.toBeNull(); - vi.mocked(callGatewayTool).mockResolvedValueOnce({ decision: 123 }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id-2", expiresAtMs: 1234 }) + .mockResolvedValueOnce({ decision: 123 }); await expect( requestExecApprovalDecision({ id: "approval-id-2", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", }), ).resolves.toBeNull(); }); + + it("uses registration response id when waiting for decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "server-assigned-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); + + await expect( + requestExecApprovalDecision({ + id: "client-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBe("allow-once"); + + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "server-assigned-id" }, + ); + }); + + it("treats expired-or-missing waitDecision as null decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockRejectedValueOnce(new Error("approval expired or not found")); + + await expect( + requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + }); + + it("returns final decision directly when gateway already replies with decision", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ decision: "deny", id: "approval-id" }); + + const result = await requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }); + + expect(result).toBe("deny"); + expect(vi.mocked(callGatewayTool).mock.calls).toHaveLength(1); + }); }); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 2b08495a400e..83323845c0cf 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -9,6 +9,7 @@ export type RequestExecApprovalDecisionParams = { id: string; command: string; cwd: string; + nodeId?: string; host: "gateway" | "node"; security: ExecSecurity; ask: ExecAsk; @@ -17,16 +18,52 @@ export type RequestExecApprovalDecisionParams = { sessionKey?: string; }; -export async function requestExecApprovalDecision( +type ParsedDecision = { present: boolean; value: string | null }; + +function parseDecision(value: unknown): ParsedDecision { + if (!value || typeof value !== "object") { + return { present: false, value: null }; + } + // Distinguish "field missing" from "field present but null/invalid". + // Registration responses intentionally omit `decision`; decision waits can include it. + if (!Object.hasOwn(value, "decision")) { + return { present: false, value: null }; + } + const decision = (value as { decision?: unknown }).decision; + return { present: true, value: typeof decision === "string" ? decision : null }; +} + +function parseString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseExpiresAtMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export type ExecApprovalRegistration = { + id: string; + expiresAtMs: number; + finalDecision?: string | null; +}; + +export async function registerExecApprovalRequest( params: RequestExecApprovalDecisionParams, -): Promise { - const decisionResult = await callGatewayTool<{ decision: string }>( +): Promise { + // Two-phase registration is critical: the ID must be registered server-side + // before exec returns `approval-pending`, otherwise `/approve` can race and orphan. + const registrationResult = await callGatewayTool<{ + id?: string; + expiresAtMs?: number; + decision?: string; + }>( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, { id: params.id, command: params.command, cwd: params.cwd, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, @@ -34,13 +71,46 @@ export async function requestExecApprovalDecision( resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - return typeof decisionValue === "string" ? decisionValue : null; + const decision = parseDecision(registrationResult); + const id = parseString(registrationResult?.id) ?? params.id; + const expiresAtMs = + parseExpiresAtMs(registrationResult?.expiresAtMs) ?? Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + if (decision.present) { + return { id, expiresAtMs, finalDecision: decision.value }; + } + return { id, expiresAtMs }; +} + +export async function waitForExecApprovalDecision(id: string): Promise { + try { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id }, + ); + return parseDecision(decisionResult).value; + } catch (err) { + // Timeout/cleanup path: treat missing/expired as no decision so askFallback applies. + const message = String(err).toLowerCase(); + if (message.includes("approval expired or not found")) { + return null; + } + throw err; + } +} + +export async function requestExecApprovalDecision( + params: RequestExecApprovalDecisionParams, +): Promise { + const registration = await registerExecApprovalRequest(params); + if (Object.hasOwn(registration, "finalDecision")) { + return registration.finalDecision ?? null; + } + return await waitForExecApprovalDecision(registration.id); } export async function requestExecApprovalDecisionForHost(params: { @@ -48,6 +118,7 @@ export async function requestExecApprovalDecisionForHost(params: { command: string; workdir: string; host: "gateway" | "node"; + nodeId?: string; security: ExecSecurity; ask: ExecAsk; agentId?: string; @@ -58,6 +129,33 @@ export async function requestExecApprovalDecisionForHost(params: { id: params.approvalId, command: params.command, cwd: params.workdir, + nodeId: params.nodeId, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + }); +} + +export async function registerExecApprovalRequestForHost(params: { + approvalId: string; + command: string; + workdir: string; + host: "gateway" | "node"; + nodeId?: string; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; +}): Promise { + return await registerExecApprovalRequest({ + id: params.approvalId, + command: params.command, + cwd: params.workdir, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index be81e703e135..607119109753 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -17,7 +17,10 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, @@ -135,28 +138,42 @@ export async function processGatewayAllowlist( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 9a663c2a088c..5a45c8692924 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -14,7 +14,10 @@ import { import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { logInfo } from "../logger.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, createApprovalSlug, @@ -180,24 +183,39 @@ export async function executeNodeHostCommand( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "node", + nodeId, + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "node", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 39e36b5581e4..05973993cffc 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -29,6 +29,23 @@ import { import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; +// Sanitize inherited host env before merge so dangerous variables from process.env +// are not propagated into non-sandboxed executions. +export function sanitizeHostBaseEnv(env: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(env)) { + const upperKey = key.toUpperCase(); + if (upperKey === "PATH") { + sanitized[key] = value; + continue; + } + if (isDangerousHostEnvVarName(upperKey)) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} // Centralized sanitization helper. // Throws an error if dangerous variables or PATH modifications are detected on the host. export function validateHostEnv(env: Record): void { @@ -482,7 +499,13 @@ export async function runExecProcess(opts: { .then((exit): ExecProcessOutcome => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; - const status: "completed" | "failed" = isNormalExit ? "completed" : "failed"; + const exitCode = exit.exitCode ?? 0; + // Shell exit codes 126 (not executable) and 127 (command not found) are + // unrecoverable infrastructure failures that should surface as real errors + // rather than silently completing — e.g. `python: command not found`. + const isShellFailure = exitCode === 126 || exitCode === 127; + const status: "completed" | "failed" = + isNormalExit && !isShellFailure ? "completed" : "failed"; markExited(session, exit.exitCode, exit.exitSignal, status); maybeNotifyOnExit(session, status); @@ -491,7 +514,6 @@ export async function runExecProcess(opts: { } const aggregated = session.aggregated.trim(); if (status === "completed") { - const exitCode = exit.exitCode ?? 0; const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { status: "completed", @@ -502,8 +524,11 @@ export async function runExecProcess(opts: { timedOut: false, }; } - const reason = - exit.reason === "overall-timeout" + const reason = isShellFailure + ? exitCode === 127 + ? "Command not found" + : "Command not executable (permission denied)" + : exit.reason === "overall-timeout" ? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? `Command timed out after ${opts.timeoutSec} seconds` : "Command timed out" diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 4fb5b4bf495d..fc04efc0a632 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -65,7 +65,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "exec.approval.request") { - // Approval request now carries the decision directly. + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { return { decision: "allow-once" }; } if (method === "node.invoke") { @@ -191,6 +193,69 @@ describe("exec approvals", () => { expect(result.details.status).toBe("approval-pending"); await approvalSeen; expect(calls).toContain("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); + }); + + it("waits for approval registration before returning approval-pending", async () => { + const calls: string[] = []; + let resolveRegistration: ((value: unknown) => void) | undefined; + const registrationPromise = new Promise((resolve) => { + resolveRegistration = resolve; + }); + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "exec.approval.request") { + return await registrationPromise; + } + if (method === "exec.approval.waitDecision") { + return { decision: "deny" }; + } + return { ok: true, id: (params as { id?: string })?.id }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + let settled = false; + const executePromise = tool.execute("call-registration-gate", { command: "echo register" }); + void executePromise.finally(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveRegistration?.({ status: "accepted", id: "approval-id" }); + const result = await executePromise; + expect(result.details.status).toBe("approval-pending"); + expect(calls[0]).toBe("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); + }); + + it("fails fast when approval registration fails", async () => { + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + throw new Error("gateway offline"); + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + await expect(tool.execute("call-registration-fail", { command: "echo fail" })).rejects.toThrow( + "Exec approval registration failed", + ); }); it("denies node obfuscated command when approval request times out", async () => { @@ -204,6 +269,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { calls.push(method); if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } if (method === "node.invoke") { @@ -237,6 +305,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } return { ok: true }; diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 9bdbe07524c4..041ee86723ee 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; import { captureEnv } from "../test-utils/env.js"; @@ -67,7 +70,7 @@ describe("exec PATH login shell merge", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["PATH"]); + envSnapshot = captureEnv(["PATH", "SHELL"]); }); afterEach(() => { @@ -112,6 +115,43 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).not.toHaveBeenCalled(); }); + + it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => { + if (isWin) { + return; + } + process.env.PATH = "/usr/bin"; + const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-env-")); + const unregisteredShellPath = path.join(shellDir, "unregistered-shell"); + fs.writeFileSync(unregisteredShellPath, '#!/bin/sh\nexec /bin/sh "$@"\n', { + encoding: "utf8", + mode: 0o755, + }); + process.env.SHELL = unregisteredShellPath; + + try { + const shellPathMock = vi.mocked(getShellPathFromLoginShell); + shellPathMock.mockClear(); + shellPathMock.mockImplementation((opts) => + opts.env.SHELL?.trim() === unregisteredShellPath ? null : "/custom/bin:/opt/bin", + ); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { command: "echo $PATH" }); + const entries = normalizePathEntries(result.content.find((c) => c.type === "text")?.text); + + expect(entries).toEqual(["/usr/bin"]); + expect(shellPathMock).toHaveBeenCalledTimes(1); + expect(shellPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + timeoutMs: 1234, + }), + ); + } finally { + fs.rmSync(shellDir, { recursive: true, force: true }); + } + }); }); describe("exec host env validation", () => { @@ -126,6 +166,29 @@ describe("exec host env validation", () => { ).rejects.toThrow(/Security Violation: Environment variable 'LD_DEBUG' is forbidden/); }); + it("strips dangerous inherited env vars from host execution", async () => { + if (isWin) { + return; + } + const original = process.env.SSLKEYLOGFILE; + process.env.SSLKEYLOGFILE = "/tmp/openclaw-ssl-keys.log"; + try { + const { createExecTool } = await import("./bash-tools.exec.js"); + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { + command: "printf '%s' \"${SSLKEYLOGFILE:-}\"", + }); + const output = normalizeText(result.content.find((c) => c.type === "text")?.text); + expect(output).not.toContain("/tmp/openclaw-ssl-keys.log"); + } finally { + if (original === undefined) { + delete process.env.SSLKEYLOGFILE; + } else { + process.env.SSLKEYLOGFILE = original; + } + } + }); + it("defaults to sandbox when sandbox runtime is unavailable", async () => { const tool = createExecTool({ security: "full", ask: "off" }); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 7fd16e36eaf6..fac68eb823f4 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -25,6 +25,7 @@ import { renderExecHostLabel, resolveApprovalRunningNoticeMs, runExecProcess, + sanitizeHostBaseEnv, execSchema, validateHostEnv, } from "./bash-tools.exec-runtime.js"; @@ -175,6 +176,9 @@ export function createExecTool( safeBinTrustedDirs: defaults?.safeBinTrustedDirs, safeBinProfiles: defaults?.safeBinProfiles, }, + onWarning: (message) => { + logInfo(message); + }, }); if (unprofiledSafeBins.length > 0) { logInfo( @@ -356,7 +360,8 @@ export function createExecTool( workdir = resolveWorkdir(rawWorkdir, warnings); } - const baseEnv = coerceEnv(process.env); + const inheritedBaseEnv = coerceEnv(process.env); + const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv); // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation. // We validate BEFORE merging to prevent any dangerous vars from entering the stream. diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 25248bf22183..028f56bbb75c 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -331,7 +331,7 @@ export function createProcessTool( const deadline = Date.now() + pollWaitMs; while (!scopedSession.exited && Date.now() < deadline) { await new Promise((resolve) => - setTimeout(resolve, Math.min(250, deadline - Date.now())), + setTimeout(resolve, Math.max(0, Math.min(250, deadline - Date.now()))), ); } } diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index db0a910f2c84..4841038ff304 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -533,7 +533,17 @@ describe("exec PATH handling", () => { const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); - expect(entries.slice(0, prepend.length)).toEqual(prepend); - expect(entries).toContain(basePath); + const prependIndexes = prepend.map((entry) => entries.indexOf(entry)); + for (const index of prependIndexes) { + expect(index).toBeGreaterThanOrEqual(0); + } + for (let i = 1; i < prependIndexes.length; i += 1) { + expect(prependIndexes[i]).toBeGreaterThan(prependIndexes[i - 1]); + } + const baseIndex = entries.indexOf(basePath); + expect(baseIndex).toBeGreaterThanOrEqual(0); + for (const index of prependIndexes) { + expect(index).toBeLessThan(baseIndex); + } }); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 2c93ee0723dc..86bc6bba5a04 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -77,6 +77,18 @@ describe("resolveModelAuthMode", () => { ), ).toBe("aws-sdk"); }); + + it("returns aws-sdk for bedrock alias without explicit auth override", () => { + expect(resolveModelAuthMode("bedrock", undefined, { version: 1, profiles: {} })).toBe( + "aws-sdk", + ); + }); + + it("returns aws-sdk for aws-bedrock alias without explicit auth override", () => { + expect(resolveModelAuthMode("aws-bedrock", undefined, { version: 1, profiles: {} })).toBe( + "aws-sdk", + ); + }); }); describe("requireApiKey", () => { diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index a7404d3042b5..1e11b12437f9 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -41,6 +41,65 @@ function createRegistry(models: Record>): ModelRegistry { } as ModelRegistry; } +describe("normalizeModelCompat — Anthropic baseUrl", () => { + const anthropicBase = (): Model => + ({ + id: "claude-opus-4-6", + name: "claude-opus-4-6", + api: "anthropic-messages", + provider: "anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }) as Model; + + it("strips /v1 suffix from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("strips trailing /v1/ (with slash) from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1/" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves anthropic-messages baseUrl without /v1 unchanged", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves baseUrl undefined unchanged for anthropic-messages", () => { + const model = anthropicBase(); + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBeUndefined(); + }); + + it("does not strip /v1 from non-anthropic-messages models", () => { + const model = { + ...baseModel(), + provider: "openai", + api: "openai-responses" as Api, + baseUrl: "https://api.openai.com/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.openai.com/v1"); + }); + + it("strips /v1 from custom Anthropic proxy baseUrl", () => { + const model = { + ...anthropicBase(), + baseUrl: "https://my-proxy.example.com/anthropic/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://my-proxy.example.com/anthropic"); + }); +}); + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 2b5eba1301c1..fc1c195819a5 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -12,8 +12,34 @@ function isDashScopeCompatibleEndpoint(baseUrl: string): boolean { ); } +function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { + return model.api === "anthropic-messages"; +} + +/** + * pi-ai constructs the Anthropic API endpoint as `${baseUrl}/v1/messages`. + * If a user configures `baseUrl` with a trailing `/v1` (e.g. the previously + * recommended format "https://api.anthropic.com/v1"), the resulting URL + * becomes "…/v1/v1/messages" which the Anthropic API rejects with a 404. + * + * Strip a single trailing `/v1` (with optional trailing slash) from the + * baseUrl for anthropic-messages models so users with either format work. + */ +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; + + // Normalise anthropic-messages baseUrl: strip trailing /v1 that users may + // have included in their config. pi-ai appends /v1/messages itself. + if (isAnthropicMessagesModel(model) && baseUrl) { + const normalised = normalizeAnthropicBaseUrl(baseUrl); + if (normalised !== baseUrl) { + return { ...model, baseUrl: normalised } as Model<"anthropic-messages">; + } + } + const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); const isMoonshot = model.provider === "moonshot" || diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index bb5704be1a75..16592cdb4560 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -8,7 +8,7 @@ import type { AuthProfileStore } from "./auth-profiles.js"; import { saveAuthProfileStore } from "./auth-profiles.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import { isAnthropicBillingError } from "./live-auth-keys.js"; -import { runWithModelFallback } from "./model-fallback.js"; +import { runWithImageModelFallback, runWithModelFallback } from "./model-fallback.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; const makeCfg = makeModelFallbackCfg; @@ -178,18 +178,60 @@ describe("runWithModelFallback", () => { expect(run).toHaveBeenCalledWith("openai-codex", "gpt-5.3-codex"); }); - it("does not fall back on non-auth errors", async () => { + it("falls back on unrecognized errors when candidates remain", async () => { const cfg = makeCfg(); const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok"); + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0].error).toBe("bad request"); + expect(result.attempts[0].reason).toBe("unknown"); + }); + + it("passes original unknown errors to onError during fallback", async () => { + const cfg = makeCfg(); + const unknownError = new Error("provider misbehaved"); + const run = vi.fn().mockRejectedValueOnce(unknownError).mockResolvedValueOnce("ok"); + const onError = vi.fn(); + + await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + onError, + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + provider: "openai", + model: "gpt-4.1-mini", + attempt: 1, + total: 2, + }); + expect(onError.mock.calls[0]?.[0]?.error).toBe(unknownError); + }); + + it("throws unrecognized error on last candidate", async () => { + const cfg = makeCfg(); + const run = vi.fn().mockRejectedValueOnce(new Error("something weird")); + await expect( runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run, + fallbacksOverride: [], }), - ).rejects.toThrow("bad request"); + ).rejects.toThrow("something weird"); expect(run).toHaveBeenCalledTimes(1); }); @@ -237,6 +279,44 @@ describe("runWithModelFallback", () => { ]); }); + it("keeps configured fallback chain when current model is a configured fallback", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"], + }, + }, + }, + }); + + const run = vi.fn().mockImplementation(async (provider: string, model: string) => { + if (provider === "anthropic" && model === "claude-haiku-3-5") { + throw Object.assign(new Error("rate-limited"), { status: 429 }); + } + if (provider === "openrouter" && model === "openrouter/deepseek-chat") { + return "ok"; + } + throw new Error(`unexpected fallback candidate: ${provider}/${model}`); + }); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-haiku-3-5", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("openrouter"); + expect(result.model).toBe("openrouter/deepseek-chat"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-haiku-3-5"], + ["openrouter", "openrouter/deepseek-chat"], + ]); + }); + it("treats normalized default refs as primary and keeps configured fallback chain", async () => { const cfg = makeCfg({ agents: { @@ -373,6 +453,37 @@ describe("runWithModelFallback", () => { }); }); + it("does not skip OpenRouter when legacy cooldown markers exist", async () => { + const provider = "openrouter"; + const cfg = makeProviderFallbackCfg(provider); + const store = makeSingleProviderStore({ + provider, + usageStat: { + cooldownUntil: Date.now() + 5 * 60_000, + disabledUntil: Date.now() + 10 * 60_000, + disabledReason: "billing", + }, + }); + const run = vi.fn().mockImplementation(async (providerId) => { + if (providerId === "openrouter") { + return "ok"; + } + throw new Error(`unexpected provider: ${providerId}`); + }); + + const result = await runWithStoredAuth({ + cfg, + store, + provider, + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0]?.[0]).toBe("openrouter"); + expect(result.attempts).toEqual([]); + }); + it("propagates disabled reason when all profiles are unavailable", async () => { const now = Date.now(); await expectSkippedUnavailableProvider({ @@ -512,6 +623,39 @@ describe("runWithModelFallback", () => { expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]); }); + it("keeps explicit fallbacks reachable when models allowlist is present", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4", + fallbacks: ["openai/gpt-4o", "ollama/llama-3"], + }, + models: { + "anthropic/claude-sonnet-4": {}, + }, + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4", + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-sonnet-4"], + ["openai", "gpt-4o"], + ]); + }); + it("defaults provider/model when missing (regression #946)", async () => { const cfg = makeCfg({ agents: { @@ -652,6 +796,39 @@ describe("runWithModelFallback", () => { }); }); +describe("runWithImageModelFallback", () => { + it("keeps explicit image fallbacks reachable when models allowlist is present", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + imageModel: { + primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-2.5-flash-image-preview"], + }, + models: { + "openai/gpt-image-1": {}, + }, + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce(new Error("rate limited")) + .mockResolvedValueOnce("ok"); + + const result = await runWithImageModelFallback({ + cfg, + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["openai", "gpt-image-1"], + ["google", "gemini-2.5-flash-image-preview"], + ]); + }); +}); + describe("isAnthropicBillingError", () => { it("does not false-positive on plain 'a 402' prose", () => { const samples = [ diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b00506025903..e59d9e9357c7 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -63,7 +63,8 @@ function shouldRethrowAbort(err: unknown): boolean { function createModelCandidateCollector(allowlist: Set | null | undefined): { candidates: ModelCandidate[]; - addCandidate: (candidate: ModelCandidate, enforceAllowlist: boolean) => void; + addExplicitCandidate: (candidate: ModelCandidate) => void; + addAllowlistedCandidate: (candidate: ModelCandidate) => void; } { const seen = new Set(); const candidates: ModelCandidate[] = []; @@ -83,7 +84,14 @@ function createModelCandidateCollector(allowlist: Set | null | undefined candidates.push(candidate); }; - return { candidates, addCandidate }; + const addExplicitCandidate = (candidate: ModelCandidate) => { + addCandidate(candidate, false); + }; + const addAllowlistedCandidate = (candidate: ModelCandidate) => { + addCandidate(candidate, true); + }; + + return { candidates, addExplicitCandidate, addAllowlistedCandidate }; } type ModelFallbackErrorHandler = (attempt: { @@ -138,9 +146,10 @@ function resolveImageFallbackCandidates(params: { cfg: params.cfg, defaultProvider: params.defaultProvider, }); - const { candidates, addCandidate } = createModelCandidateCollector(allowlist); + const { candidates, addExplicitCandidate, addAllowlistedCandidate } = + createModelCandidateCollector(allowlist); - const addRaw = (raw: string, enforceAllowlist: boolean) => { + const addRaw = (raw: string, opts?: { allowlist?: boolean }) => { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: params.defaultProvider, @@ -149,22 +158,28 @@ function resolveImageFallbackCandidates(params: { if (!resolved) { return; } - addCandidate(resolved.ref, enforceAllowlist); + if (opts?.allowlist) { + addAllowlistedCandidate(resolved.ref); + return; + } + addExplicitCandidate(resolved.ref); }; if (params.modelOverride?.trim()) { - addRaw(params.modelOverride, false); + addRaw(params.modelOverride); } else { const primary = resolveAgentModelPrimaryValue(params.cfg?.agents?.defaults?.imageModel); if (primary?.trim()) { - addRaw(primary, false); + addRaw(primary); } } const imageFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.imageModel); for (const raw of imageFallbacks) { - addRaw(raw, true); + // Explicitly configured image fallbacks should remain reachable even when a + // model allowlist is present. + addRaw(raw); } return candidates; @@ -198,20 +213,32 @@ function resolveFallbackCandidates(params: { cfg: params.cfg, defaultProvider, }); - const { candidates, addCandidate } = createModelCandidateCollector(allowlist); + const { candidates, addExplicitCandidate } = createModelCandidateCollector(allowlist); - addCandidate(normalizedPrimary, false); + addExplicitCandidate(normalizedPrimary); const modelFallbacks = (() => { if (params.fallbacksOverride !== undefined) { return params.fallbacksOverride; } - // Skip configured fallback chain when the user runs a non-default override. - // In that case, retry should return directly to configured primary. - if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) { - return []; // Override model failed → go straight to configured default + const configuredFallbacks = resolveAgentModelFallbackValues( + params.cfg?.agents?.defaults?.model, + ); + if (sameModelCandidate(normalizedPrimary, configuredPrimary)) { + return configuredFallbacks; } - return resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model); + // Preserve resilience after failover: when current model is one of the + // configured fallback refs, keep traversing the configured fallback chain. + const isConfiguredFallback = configuredFallbacks.some((raw) => { + const resolved = resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider, + aliasIndex, + }); + return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; + }); + // Keep legacy override behavior for ad-hoc models outside configured chain. + return isConfiguredFallback ? configuredFallbacks : []; })(); for (const raw of modelFallbacks) { @@ -223,11 +250,13 @@ function resolveFallbackCandidates(params: { if (!resolved) { continue; } - addCandidate(resolved.ref, true); + // Fallbacks are explicit user intent; do not silently filter them by the + // model allowlist. + addExplicitCandidate(resolved.ref); } if (params.fallbacksOverride === undefined && primary?.provider && primary.model) { - addCandidate({ provider: primary.provider, model: primary.model }, false); + addExplicitCandidate({ provider: primary.provider, model: primary.model }); } return candidates; @@ -373,24 +402,29 @@ export async function runWithModelFallback(params: { provider: candidate.provider, model: candidate.model, }) ?? err; - if (!isFailoverError(normalized)) { + + // Even unrecognized errors should not abort the fallback loop when + // there are remaining candidates. Only abort/context-overflow errors + // (handled above) are truly non-retryable. + const isKnownFailover = isFailoverError(normalized); + if (!isKnownFailover && i === candidates.length - 1) { throw err; } - lastError = normalized; + lastError = isKnownFailover ? normalized : err; const described = describeFailoverError(normalized); attempts.push({ provider: candidate.provider, model: candidate.model, error: described.message, - reason: described.reason, + reason: described.reason ?? "unknown", status: described.status, code: described.code, }); await params.onError?.({ provider: candidate.provider, model: candidate.model, - error: normalized, + error: isKnownFailover ? normalized : err, attempt: i + 1, total: candidates.length, }); diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index df4298636c70..8a80768c0dbb 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -2,12 +2,15 @@ import { describe, it, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { + buildAllowedModelSet, + inferUniqueProviderFromConfiguredModels, parseModelRef, - resolveModelRefFromString, - resolveConfiguredModelRef, buildModelAliasIndex, - normalizeProviderId, modelKey, + normalizeProviderId, + resolveAllowedModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, } from "./model-selection.js"; describe("model-selection", () => { @@ -19,6 +22,9 @@ describe("model-selection", () => { expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); expect(normalizeProviderId("qwen")).toBe("qwen-portal"); expect(normalizeProviderId("kimi-code")).toBe("kimi-coding"); + expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); + expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); }); }); @@ -129,6 +135,85 @@ describe("model-selection", () => { }); }); + describe("inferUniqueProviderFromConfiguredModels", () => { + it("infers provider when configured model match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBe("anthropic"); + }); + + it("returns undefined when configured matches are ambiguous", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("returns undefined for provider-prefixed model ids", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("infers provider for slash-containing model id when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBe("vercel-ai-gateway"); + }); + }); + describe("buildModelAliasIndex", () => { it("should build alias index from config", () => { const cfg: Partial = { @@ -156,6 +241,71 @@ describe("model-selection", () => { }); }); + describe("buildAllowedModelSet", () => { + it("keeps explicitly allowlisted models even when missing from bundled catalog", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: "anthropic", + }); + + expect(result.allowAny).toBe(false); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); + expect(result.allowedCatalog).toEqual([ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, + ]); + }); + }); + + describe("resolveAllowedModelRef", () => { + it("accepts explicit allowlist refs absent from bundled catalog", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = resolveAllowedModelRef({ + cfg, + catalog, + raw: "anthropic/claude-sonnet-4-6", + defaultProvider: "openai", + defaultModel: "gpt-5.2", + }); + + expect(result).toEqual({ + key: "anthropic/claude-sonnet-4-6", + ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }); + }); + }); + describe("resolveModelRefFromString", () => { it("should resolve from string with alias", () => { const index = { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index acdc2faf119a..ac45200039f3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -50,6 +50,9 @@ export function normalizeProviderId(provider: string): string { if (normalized === "kimi-code") { return "kimi-coding"; } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } // Backward compatibility for older provider naming. if (normalized === "bytedance" || normalized === "doubao") { return "volcengine"; @@ -168,6 +171,42 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef | return normalizeModelRef(providerRaw, model); } +export function inferUniqueProviderFromConfiguredModels(params: { + cfg: OpenClawConfig; + model: string; +}): string | undefined { + const model = params.model.trim(); + if (!model) { + return undefined; + } + const configuredModels = params.cfg.agents?.defaults?.models; + if (!configuredModels) { + return undefined; + } + const normalized = model.toLowerCase(); + const providers = new Set(); + for (const key of Object.keys(configuredModels)) { + const ref = key.trim(); + if (!ref || !ref.includes("/")) { + continue; + } + const parsed = parseModelRef(ref, DEFAULT_PROVIDER); + if (!parsed) { + continue; + } + if (parsed.model === model || parsed.model.toLowerCase() === normalized) { + providers.add(parsed.provider); + if (providers.size > 1) { + return undefined; + } + } + } + if (providers.size !== 1) { + return undefined; + } + return providers.values().next().value; +} + export function normalizeModelSelection(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -397,22 +436,23 @@ export function buildAllowedModelSet(params: { } const allowedKeys = new Set(); - const configuredProviders = (params.cfg.models?.providers ?? {}) as Record; + const syntheticCatalogEntries = new Map(); for (const raw of rawAllowlist) { const parsed = parseModelRef(String(raw), params.defaultProvider); if (!parsed) { continue; } const key = modelKey(parsed.provider, parsed.model); - const providerKey = normalizeProviderId(parsed.provider); - if (isCliProvider(parsed.provider, params.cfg)) { - allowedKeys.add(key); - } else if (catalogKeys.has(key)) { - allowedKeys.add(key); - } else if (configuredProviders[providerKey] != null) { - // Explicitly configured providers should be allowlist-able even when - // they don't exist in the curated model catalog. - allowedKeys.add(key); + // Explicit allowlist entries are always trusted, even when bundled catalog + // data is stale and does not include the configured model yet. + allowedKeys.add(key); + + if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { + syntheticCatalogEntries.set(key, { + id: parsed.model, + name: parsed.model, + provider: parsed.provider, + }); } } @@ -420,9 +460,10 @@ export function buildAllowedModelSet(params: { allowedKeys.add(defaultKey); } - const allowedCatalog = params.catalog.filter((entry) => - allowedKeys.has(modelKey(entry.provider, entry.id)), - ); + const allowedCatalog = [ + ...params.catalog.filter((entry) => allowedKeys.has(modelKey(entry.provider, entry.id))), + ...syntheticCatalogEntries.values(), + ]; if (allowedCatalog.length === 0 && allowedKeys.size === 0) { if (defaultKey) { diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts new file mode 100644 index 000000000000..6a3601aa8945 --- /dev/null +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +type ModelEntry = { + id: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +}; + +type ModelsJson = { + providers: Record; +}; + +describe("models-config: explicit reasoning override", () => { + it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { + // MiniMax-M2.5 has reasoning:true in the built-in catalog. + // User explicitly sets reasoning:false to avoid message-ordering conflicts. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [ + { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + reasoning: false, // explicit override: user wants to disable reasoning + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Must honour the explicit false — built-in true must NOT win. + expect(m25?.reasoning).toBe(false); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); + + it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.5)", async () => { + // When the user does not set reasoning at all, the built-in catalog value + // (true for MiniMax-M2.5) should be used so the model works out of the box. + await withTempHome(async () => { + const prevKey = process.env.MINIMAX_API_KEY; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + try { + // Omit 'reasoning' to simulate a user config that doesn't set it. + const modelWithoutReasoning = { + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 8192, + }; + const cfg: OpenClawConfig = { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + // @ts-expect-error Intentional: emulate user config omitting reasoning. + models: [modelWithoutReasoning], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); + const parsed = JSON.parse(raw) as ModelsJson; + const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + // Built-in catalog has reasoning:true — should be applied as default. + expect(m25?.reasoning).toBe(true); + } finally { + if (prevKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = prevKey; + } + } + }); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 3662ce9a3b1d..4f921b6dd81d 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -685,6 +685,12 @@ function buildOpenrouterProvider(): ProviderConfig { { id: OPENROUTER_DEFAULT_MODEL_ID, name: "OpenRouter Auto", + // reasoning: false here is a catalog default only; it does NOT cause + // `reasoning.effort: "none"` to be sent for the "auto" routing model. + // applyExtraParamsToAgent skips the reasoning effort injection for + // model id "auto" because it dynamically routes to any OpenRouter model + // (including ones where reasoning is mandatory and cannot be disabled). + // See: openclaw/openclaw#24851 reasoning: false, input: ["text", "image"], cost: OPENROUTER_DEFAULT_COST, diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 5ca971646e18..4b38b8243984 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -47,10 +47,14 @@ function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig) // Refresh capability metadata from the implicit catalog while preserving // user-specific fields (cost, headers, compat, etc.) on explicit entries. + // reasoning is treated as user-overridable: if the user has explicitly set + // it in their config (key present), honour that value; otherwise fall back + // to the built-in catalog default so new reasoning models work out of the + // box without requiring every user to configure it. return { ...explicitModel, input: implicitModel.input, - reasoning: implicitModel.reasoning, + reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning, contextWindow: implicitModel.contextWindow, maxTokens: implicitModel.maxTokens, }; diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index d56986b8038c..7def3441ab62 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -45,6 +45,23 @@ function logProgress(message: string): void { console.log(`[live] ${message}`); } +function formatFailurePreview( + failures: Array<{ model: string; error: string }>, + maxItems: number, +): string { + const limit = Math.max(1, maxItems); + const lines = failures.slice(0, limit).map((failure, index) => { + const normalized = failure.error.replace(/\s+/g, " ").trim(); + const clipped = normalized.length > 320 ? `${normalized.slice(0, 317)}...` : normalized; + return `${index + 1}. ${failure.model}: ${clipped}`; + }); + const remaining = failures.length - limit; + if (remaining > 0) { + lines.push(`... and ${remaining} more`); + } + return lines.join("\n"); +} + function isGoogleModelNotFoundError(err: unknown): boolean { const msg = String(err); if (!/not found/i.test(msg)) { @@ -91,6 +108,20 @@ function isInstructionsRequiredError(raw: string): boolean { return /instructions are required/i.test(raw); } +function isModelTimeoutError(raw: string): boolean { + return /model call timed out after \d+ms/i.test(raw); +} + +function isProviderUnavailableErrorMessage(raw: string): boolean { + const msg = raw.toLowerCase(); + return ( + msg.includes("no allowed providers are available") || + msg.includes("provider unavailable") || + msg.includes("upstream provider unavailable") || + msg.includes("upstream error from google") + ); +} + function toInt(value: string | undefined, fallback: number): number { const trimmed = value?.trim(); if (!trimmed) { @@ -100,6 +131,49 @@ function toInt(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function capByProviderSpread( + items: T[], + maxItems: number, + providerOf: (item: T) => string, +): T[] { + if (maxItems <= 0 || items.length <= maxItems) { + return items; + } + const providerOrder: string[] = []; + const grouped = new Map(); + for (const item of items) { + const provider = providerOf(item); + const bucket = grouped.get(provider); + if (bucket) { + bucket.push(item); + continue; + } + providerOrder.push(provider); + grouped.set(provider, [item]); + } + + const selected: T[] = []; + while (selected.length < maxItems && grouped.size > 0) { + for (const provider of providerOrder) { + const bucket = grouped.get(provider); + if (!bucket || bucket.length === 0) { + continue; + } + const item = bucket.shift(); + if (item) { + selected.push(item); + } + if (bucket.length === 0) { + grouped.delete(provider); + } + if (selected.length >= maxItems) { + break; + } + } + } + return selected; +} + function resolveTestReasoning( model: Model, ): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { @@ -122,16 +196,32 @@ async function completeSimpleWithTimeout( options: Parameters>[2], timeoutMs: number, ) { + const maxTimeoutMs = Math.max(1, timeoutMs); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs)); - timer.unref?.(); + const abortTimer = setTimeout(() => { + controller.abort(); + }, maxTimeoutMs); + abortTimer.unref?.(); + let hardTimer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + hardTimer = setTimeout(() => { + reject(new Error(`model call timed out after ${maxTimeoutMs}ms`)); + }, maxTimeoutMs); + hardTimer.unref?.(); + }); try { - return await completeSimple(model, context, { - ...options, - signal: controller.signal, - }); + return await Promise.race([ + completeSimple(model, context, { + ...options, + signal: controller.signal, + }), + timeout, + ]); } finally { - clearTimeout(timer); + clearTimeout(abortTimer); + if (hardTimer) { + clearTimeout(hardTimer); + } } } @@ -205,6 +295,7 @@ describeLive("live models (profile keys)", () => { const allowNotFoundSkip = useModern; const providers = parseProviderFilter(process.env.OPENCLAW_LIVE_PROVIDERS); const perModelTimeoutMs = toInt(process.env.OPENCLAW_LIVE_MODEL_TIMEOUT_MS, 30_000); + const maxModels = toInt(process.env.OPENCLAW_LIVE_MAX_MODELS, 0); const failures: Array<{ model: string; error: string }> = []; const skipped: Array<{ model: string; reason: string }> = []; @@ -246,11 +337,21 @@ describeLive("live models (profile keys)", () => { return; } + const selectedCandidates = capByProviderSpread( + candidates, + maxModels > 0 ? maxModels : candidates.length, + (entry) => entry.model.provider, + ); logProgress(`[live-models] selection=${useExplicit ? "explicit" : "modern"}`); - logProgress(`[live-models] running ${candidates.length} models`); - const total = candidates.length; + if (selectedCandidates.length < candidates.length) { + logProgress( + `[live-models] capped to ${selectedCandidates.length}/${candidates.length} via OPENCLAW_LIVE_MAX_MODELS=${maxModels}`, + ); + } + logProgress(`[live-models] running ${selectedCandidates.length} models`); + const total = selectedCandidates.length; - for (const [index, entry] of candidates.entries()) { + for (const [index, entry] of selectedCandidates.entries()) { const { model, apiKeyInfo } = entry; const id = `${model.provider}/${model.id}`; const progressLabel = `[live-models] ${index + 1}/${total} ${id}`; @@ -513,6 +614,16 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (instructions required)`); break; } + if (allowNotFoundSkip && isModelTimeoutError(message)) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (timeout)`); + break; + } + if (allowNotFoundSkip && isProviderUnavailableErrorMessage(message)) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (provider unavailable)`); + break; + } logProgress(`${progressLabel}: failed`); failures.push({ model: id, error: message }); break; @@ -521,11 +632,10 @@ describeLive("live models (profile keys)", () => { } if (failures.length > 0) { - const preview = failures - .slice(0, 10) - .map((f) => `- ${f.model}: ${f.error}`) - .join("\n"); - throw new Error(`live model failures (${failures.length}):\n${preview}`); + const preview = formatFailurePreview(failures, 20); + throw new Error( + `live model failures (${failures.length}, showing ${Math.min(failures.length, 20)}):\n${preview}`, + ); } void skipped; diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index fb927d338880..3082c849609f 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -165,6 +165,7 @@ describe("nodes run", () => { expect(params).toMatchObject({ id: expect.any(String), command: "echo hi", + nodeId: NODE_ID, host: "node", timeoutMs: 120_000, }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts new file mode 100644 index 000000000000..947c83333fd8 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + maxConcurrent: 8, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-456" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds (config absent)", () => { + it("falls back to 0 (no timeout) when config key is absent", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(0); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts new file mode 100644 index 000000000000..8186b8bde95f --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + runTimeoutSeconds: 900, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-123" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds", () => { + it("uses config default when agent omits runTimeoutSeconds", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(900); + }); + + it("explicit runTimeoutSeconds wins over config default", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(300); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 5a883c7c6c4e..77b948ea5af5 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -245,7 +245,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); expect(second?.message).toContain("subagent task"); const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -297,7 +297,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { // Second call: main agent trigger const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); // No direct send to external channel (main agent handles delivery) const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -365,8 +365,8 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const announceParams = agentCalls[1]?.params as | { accountId?: string; channel?: string; deliver?: boolean } | undefined; - expect(announceParams?.deliver).toBe(true); - expect(announceParams?.channel).toBe("whatsapp"); - expect(announceParams?.accountId).toBe("kev"); + expect(announceParams?.deliver).toBe(false); + expect(announceParams?.channel).toBeUndefined(); + expect(announceParams?.accountId).toBeUndefined(); }); }); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f4ae781e8c35..638b6c24bb82 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -69,6 +69,40 @@ describe("isBillingErrorMessage", () => { expect(isBillingErrorMessage(sample)).toBe(false); } }); + it("does not false-positive on long assistant responses mentioning billing keywords", () => { + // Simulate a multi-paragraph assistant response that mentions billing terms + const longResponse = + "Sure! Here's how to set up billing for your SaaS application.\n\n" + + "## Payment Integration\n\n" + + "First, you'll need to configure your payment gateway. Most providers offer " + + "a dashboard where you can manage credits, view invoices, and upgrade your plan. " + + "The billing page typically shows your current balance and payment history.\n\n" + + "## Managing Credits\n\n" + + "Users can purchase credits through the billing portal. When their credit balance " + + "runs low, send them a notification to upgrade their plan or add more credits. " + + "You should also handle insufficient balance cases gracefully.\n\n" + + "## Subscription Plans\n\n" + + "Offer multiple plan tiers with different features. Allow users to upgrade or " + + "downgrade their plan at any time. Make sure the billing cycle is clear.\n\n" + + "Let me know if you need more details on any of these topics!"; + expect(longResponse.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longResponse)).toBe(false); + }); + it("still matches explicit 402 markers in long payloads", () => { + const longStructuredError = + '{"error":{"code":402,"message":"payment required","details":"' + "x".repeat(700) + '"}}'; + expect(longStructuredError.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longStructuredError)).toBe(true); + }); + it("does not match long numeric text that is not a billing error", () => { + const longNonError = + "Quarterly report summary: subsystem A returned 402 records after retry. " + + "This is an analytics count, not an HTTP/API billing failure. " + + "Notes: " + + "x".repeat(700); + expect(longNonError.length).toBeGreaterThan(512); + expect(isBillingErrorMessage(longNonError)).toBe(false); + }); it("still matches real HTTP 402 billing errors", () => { const realErrors = [ "HTTP 402 Payment Required", @@ -393,8 +427,18 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("invalid api key")).toBe("auth"); expect(classifyFailoverReason("no credentials found")).toBe("auth"); expect(classifyFailoverReason("no api key found")).toBe("auth"); + expect(classifyFailoverReason("You have insufficient permissions for this operation.")).toBe( + "auth", + ); + expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); + expect( + classifyFailoverReason("model_cooldown: All credentials for model gpt-5 are cooling down"), + ).toBe("rate_limit"); + expect(classifyFailoverReason("all credentials for model x are cooling down")).toBe( + "rate_limit", + ); expect( classifyFailoverReason( '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 80ba2219868b..6eea521ede17 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -161,6 +161,8 @@ const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; const BILLING_ERROR_HEAD_RE = /^(?:error[:\s-]+)?billing(?:\s+error)?(?:[:\s-]+|$)|^(?:error[:\s-]+)?(?:credit balance|insufficient credits?|payment required|http\s*402\b)/i; +const BILLING_ERROR_HARD_402_RE = + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|^\s*402\s+payment/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; const HTML_ERROR_PREFIX_RE = /^\s*(?: BILLING_ERROR_MAX_LENGTH) { + // Keep explicit status/code 402 detection for providers that wrap errors in + // larger payloads (for example nested JSON bodies or prefixed metadata). + return BILLING_ERROR_HARD_402_RE.test(value); + } if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) { return true; } diff --git a/src/agents/pi-embedded-payloads.ts b/src/agents/pi-embedded-payloads.ts index 1be29b5a3afe..1186111db107 100644 --- a/src/agents/pi-embedded-payloads.ts +++ b/src/agents/pi-embedded-payloads.ts @@ -2,6 +2,7 @@ export type BlockReplyPayload = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + isReasoning?: boolean; replyToId?: string; replyToTag?: boolean; replyToCurrent?: boolean; diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 38c500cf60d8..4116476c71f2 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -6,9 +6,13 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { applyExtraParamsToAgent } from "./pi-embedded-runner.js"; const OPENAI_KEY = process.env.OPENAI_API_KEY ?? ""; +const GEMINI_KEY = process.env.GEMINI_API_KEY ?? ""; const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); +const GEMINI_LIVE = + isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip; +const describeGeminiLive = GEMINI_LIVE && GEMINI_KEY ? describe : describe.skip; describeLive("pi embedded extra params (live)", () => { it("applies config maxTokens to openai streamFn", async () => { @@ -62,3 +66,167 @@ describeLive("pi embedded extra params (live)", () => { expect(outputTokens ?? 0).toBeLessThanOrEqual(20); }, 30_000); }); + +describeGeminiLive("pi embedded extra params (gemini live)", () => { + function isGoogleModelUnavailableError(raw: string | undefined): boolean { + const msg = (raw ?? "").toLowerCase(); + if (!msg) { + return false; + } + return ( + msg.includes("not found") || + msg.includes("404") || + msg.includes("not_available") || + msg.includes("permission denied") || + msg.includes("unsupported model") + ); + } + + function isGoogleImageProcessingError(raw: string | undefined): boolean { + const msg = (raw ?? "").toLowerCase(); + if (!msg) { + return false; + } + return ( + msg.includes("unable to process input image") || + msg.includes("invalid_argument") || + msg.includes("bad request") + ); + } + + async function runGeminiProbe(params: { + agentStreamFn: typeof streamSimple; + model: Model<"google-generative-ai">; + apiKey: string; + oneByOneRedPngBase64: string; + includeImage?: boolean; + prompt: string; + onPayload?: (payload: Record) => void; + }): Promise<{ sawDone: boolean; stopReason?: string; errorMessage?: string }> { + const userContent: Array< + { type: "text"; text: string } | { type: "image"; mimeType: string; data: string } + > = [{ type: "text", text: params.prompt }]; + if (params.includeImage ?? true) { + userContent.push({ + type: "image", + mimeType: "image/png", + data: params.oneByOneRedPngBase64, + }); + } + + const stream = params.agentStreamFn( + params.model, + { + messages: [ + { + role: "user", + content: userContent, + timestamp: Date.now(), + }, + ], + }, + { + apiKey: params.apiKey, + reasoning: "high", + maxTokens: 64, + onPayload: (payload) => { + params.onPayload?.(payload as Record); + }, + }, + ); + + let sawDone = false; + let stopReason: string | undefined; + let errorMessage: string | undefined; + + for await (const event of stream) { + if (event.type === "done") { + sawDone = true; + stopReason = event.reason; + } else if (event.type === "error") { + stopReason = event.reason; + errorMessage = event.error?.errorMessage; + } + } + + return { sawDone, stopReason, errorMessage }; + } + + it("sanitizes Gemini 3.1 thinking payload and keeps image parts with reasoning enabled", async () => { + const model = getModel("google", "gemini-2.5-pro") as unknown as Model<"google-generative-ai">; + + const agent = { streamFn: streamSimple }; + applyExtraParamsToAgent(agent, undefined, "google", model.id, undefined, "high"); + + const oneByOneRedPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP4zwAAAgIBAJBzWgkAAAAASUVORK5CYII="; + + let capturedPayload: Record | undefined; + const imageResult = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: true, + prompt: "What color is this image? Reply with one word.", + onPayload: (payload) => { + capturedPayload = payload; + }, + }); + + expect(capturedPayload).toBeDefined(); + const thinkingConfig = ( + capturedPayload?.config as { thinkingConfig?: Record } | undefined + )?.thinkingConfig; + expect(thinkingConfig?.thinkingBudget).toBeUndefined(); + expect(thinkingConfig?.thinkingLevel).toBe("HIGH"); + + const imagePart = ( + capturedPayload?.contents as + | Array<{ parts?: Array<{ inlineData?: { mimeType?: string; data?: string } }> }> + | undefined + )?.[0]?.parts?.find((part) => part.inlineData !== undefined)?.inlineData; + expect(imagePart).toEqual({ + mimeType: "image/png", + data: oneByOneRedPngBase64, + }); + + if (!imageResult.sawDone && !isGoogleModelUnavailableError(imageResult.errorMessage)) { + expect(isGoogleImageProcessingError(imageResult.errorMessage)).toBe(true); + } + + const textResult = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: false, + prompt: "Reply with exactly OK.", + }); + + if (!textResult.sawDone && isGoogleModelUnavailableError(textResult.errorMessage)) { + // Some keys/regions do not expose Gemini 3.1 preview. Fall back to a + // stable model to keep live reasoning verification active. + const fallbackModel = getModel( + "google", + "gemini-2.5-pro", + ) as unknown as Model<"google-generative-ai">; + const fallback = await runGeminiProbe({ + agentStreamFn: agent.streamFn, + model: fallbackModel, + apiKey: GEMINI_KEY, + oneByOneRedPngBase64, + includeImage: false, + prompt: "Reply with exactly OK.", + }); + expect(fallback.sawDone).toBe(true); + expect(fallback.stopReason).toBeDefined(); + expect(fallback.stopReason).not.toBe("error"); + return; + } + + expect(textResult.sawDone).toBe(true); + expect(textResult.stopReason).toBeDefined(); + expect(textResult.stopReason).not.toBe("error"); + }, 45_000); +}); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index a6d3e9191e88..404d4439da44 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -202,6 +202,272 @@ describe("applyExtraParamsToAgent", () => { return calls[0]?.headers; } + it("does not inject reasoning when thinkingLevel is off (default) for OpenRouter", () => { + // Regression: "off" is a truthy string, so the old code injected + // reasoning: { effort: "none" }, causing a 400 on models that require + // reasoning (e.g. deepseek/deepseek-r1). + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { model: "deepseek/deepseek-r1" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "openrouter", + "deepseek/deepseek-r1", + undefined, + "off", + ); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "deepseek/deepseek-r1", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning"); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + }); + + it("injects reasoning.effort when thinkingLevel is non-off for OpenRouter", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); + }); + + it("removes legacy reasoning_effort and keeps reasoning unset when thinkingLevel is off", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning_effort: "high" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + expect(payloads[0]).not.toHaveProperty("reasoning"); + }); + + it("does not inject effort when payload already has reasoning.max_tokens", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning: { max_tokens: 256 } }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } }); + }); + + it("normalizes thinking=off to null for SiliconFlow Pro models", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { thinking: "off" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "siliconflow", + "Pro/MiniMaxAI/MiniMax-M2.1", + undefined, + "off", + ); + + const model = { + api: "openai-completions", + provider: "siliconflow", + id: "Pro/MiniMaxAI/MiniMax-M2.1", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toBeNull(); + }); + + it("keeps thinking=off unchanged for non-Pro SiliconFlow model IDs", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { thinking: "off" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "siliconflow", + "deepseek-ai/DeepSeek-V3.2", + undefined, + "off", + ); + + const model = { + api: "openai-completions", + provider: "siliconflow", + id: "deepseek-ai/DeepSeek-V3.2", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toBe("off"); + }); + + it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + contents: [ + { + role: "user", + parts: [ + { text: "describe image" }, + { + inlineData: { + mimeType: "image/png", + data: "ZmFrZQ==", + }, + }, + ], + }, + ], + config: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: -1, + }, + }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "atproxy", "gemini-3.1-pro-high", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "atproxy", + id: "gemini-3.1-pro-high", + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + const thinkingConfig = ( + payloads[0]?.config as { thinkingConfig?: Record } | undefined + )?.thinkingConfig; + expect(thinkingConfig).toEqual({ + includeThoughts: true, + thinkingLevel: "HIGH", + }); + expect( + ( + payloads[0]?.contents as + | Array<{ parts?: Array<{ inlineData?: { mimeType?: string; data?: string } }> }> + | undefined + )?.[0]?.parts?.[1]?.inlineData, + ).toEqual({ + mimeType: "image/png", + data: "ZmFrZQ==", + }); + }); + + it("keeps valid Google thinkingBudget unchanged", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + config: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 2048, + }, + }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "atproxy", "gemini-3.1-pro-high", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "atproxy", + id: "gemini-3.1-pro-high", + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.config).toEqual({ + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 2048, + }, + }); + }); it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index b254df7430be..ca66ad4c7f7a 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -109,6 +109,45 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): OpenClawC }, }) satisfies OpenClawConfig; +const makeAgentOverrideOnlyFallbackConfig = (agentId: string): OpenClawConfig => + ({ + agents: { + defaults: { + model: { + fallbacks: [], + }, + }, + list: [ + { + id: agentId, + model: { + fallbacks: ["openai/mock-2"], + }, + }, + ], + }, + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "sk-test", + baseUrl: "https://example.com", + models: [ + { + id: "mock-1", + name: "Mock 1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_000, + maxTokens: 2048, + }, + ], + }, + }, + }, + }) satisfies OpenClawConfig; + const writeAuthStore = async ( agentDir: string, opts?: { @@ -516,6 +555,42 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); + it("treats agent-level fallbacks as configured when defaults have none", async () => { + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, + "openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 }, + }, + }); + + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:support:cooldown-failover", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeAgentOverrideOnlyFallbackConfig("support"), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:agent-override-fallback", + agentId: "support", + }), + ).rejects.toMatchObject({ + name: "FailoverError", + reason: "rate_limit", + provider: "openai", + model: "mock-1", + }); + + expect(runEmbeddedAttemptMock).not.toHaveBeenCalled(); + }); + }); + it("fails over with disabled reason when all profiles are unavailable", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index e9cd5065d3dd..6e401b92e0aa 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -298,7 +298,7 @@ describe("sanitizeSessionHistory", () => { expect(result[1]?.role).toBe("assistant"); }); - it("does not synthesize tool results for openai-responses", async () => { + it("synthesizes missing tool results for openai-responses after repair", async () => { const messages = [ { role: "assistant", @@ -314,8 +314,11 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(result).toHaveLength(1); + // repairToolUseResultPairing now runs for all providers (including OpenAI) + // to fix orphaned function_call_output items that OpenAI would reject. + expect(result).toHaveLength(2); expect(result[0]?.role).toBe("assistant"); + expect(result[1]?.role).toBe("toolResult"); }); it("drops malformed tool calls missing input or arguments", async () => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8ebacf6df68a..2e87dcee608b 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -408,6 +408,42 @@ function mapThinkingLevelToOpenRouterReasoningEffort( return thinkingLevel; } +function shouldApplySiliconFlowThinkingOffCompat(params: { + provider: string; + modelId: string; + thinkingLevel?: ThinkLevel; +}): boolean { + return ( + params.provider === "siliconflow" && + params.thinkingLevel === "off" && + params.modelId.startsWith("Pro/") + ); +} + +/** + * SiliconFlow's Pro/* models reject string thinking modes (including "off") + * with HTTP 400 invalid-parameter errors. Normalize to `thinking: null` to + * preserve "thinking disabled" intent without sending an invalid enum value. + */ +function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + const payloadObj = payload as Record; + if (payloadObj.thinking === "off") { + payloadObj.thinking = null; + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + /** * Create a streamFn wrapper that adds OpenRouter app attribution headers * and injects reasoning.effort based on the configured thinking level. @@ -435,24 +471,31 @@ function createOpenRouterWrapper( // only the nested one is sent. delete payloadObj.reasoning_effort; - const existingReasoning = payloadObj.reasoning; - - // OpenRouter treats reasoning.effort and reasoning.max_tokens as - // alternative controls. If max_tokens is already present, do not - // inject effort and do not overwrite caller-supplied reasoning. - if ( - existingReasoning && - typeof existingReasoning === "object" && - !Array.isArray(existingReasoning) - ) { - const reasoningObj = existingReasoning as Record; - if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { - reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + // When thinking is "off", do not inject reasoning at all. + // Some models (e.g. deepseek/deepseek-r1) require reasoning and reject + // { effort: "none" } with "Reasoning is mandatory for this endpoint and + // cannot be disabled." Omitting the field lets each model use its own + // default reasoning behavior. + if (thinkingLevel !== "off") { + const existingReasoning = payloadObj.reasoning; + + // OpenRouter treats reasoning.effort and reasoning.max_tokens as + // alternative controls. If max_tokens is already present, do not + // inject effort and do not overwrite caller-supplied reasoning. + if ( + existingReasoning && + typeof existingReasoning === "object" && + !Array.isArray(existingReasoning) + ) { + const reasoningObj = existingReasoning as Record; + if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { + reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + } + } else if (!existingReasoning) { + payloadObj.reasoning = { + effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), + }; } - } else if (!existingReasoning) { - payloadObj.reasoning = { - effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), - }; } } onPayload?.(payload); @@ -461,6 +504,94 @@ function createOpenRouterWrapper( }; } +function isGemini31Model(modelId: string): boolean { + const normalized = modelId.toLowerCase(); + return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); +} + +function mapThinkLevelToGoogleThinkingLevel( + thinkingLevel: ThinkLevel, +): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined { + switch (thinkingLevel) { + case "minimal": + return "MINIMAL"; + case "low": + return "LOW"; + case "medium": + return "MEDIUM"; + case "high": + case "xhigh": + return "HIGH"; + default: + return undefined; + } +} + +function sanitizeGoogleThinkingPayload(params: { + payload: unknown; + modelId?: string; + thinkingLevel?: ThinkLevel; +}): void { + if (!params.payload || typeof params.payload !== "object") { + return; + } + const payloadObj = params.payload as Record; + const config = payloadObj.config; + if (!config || typeof config !== "object") { + return; + } + const configObj = config as Record; + const thinkingConfig = configObj.thinkingConfig; + if (!thinkingConfig || typeof thinkingConfig !== "object") { + return; + } + const thinkingConfigObj = thinkingConfig as Record; + const thinkingBudget = thinkingConfigObj.thinkingBudget; + if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) { + return; + } + + // pi-ai can emit thinkingBudget=-1 for some Gemini 3.1 IDs; a negative budget + // is invalid for Google-compatible backends and can lead to malformed handling. + delete thinkingConfigObj.thinkingBudget; + + if ( + typeof params.modelId === "string" && + isGemini31Model(params.modelId) && + params.thinkingLevel && + params.thinkingLevel !== "off" && + thinkingConfigObj.thinkingLevel === undefined + ) { + const mappedLevel = mapThinkLevelToGoogleThinkingLevel(params.thinkingLevel); + if (mappedLevel) { + thinkingConfigObj.thinkingLevel = mappedLevel; + } + } +} + +function createGoogleThinkingPayloadWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const onPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (model.api === "google-generative-ai") { + sanitizeGoogleThinkingPayload({ + payload, + modelId: model.id, + thinkingLevel, + }); + } + onPayload?.(payload); + }, + }); + }; +} + /** * Create a streamFn wrapper that injects tool_stream=true for Z.AI providers. * @@ -537,9 +668,23 @@ export function applyExtraParamsToAgent( agent.streamFn = createAnthropicBetaHeadersWrapper(agent.streamFn, anthropicBetas); } + if (shouldApplySiliconFlowThinkingOffCompat({ provider, modelId, thinkingLevel })) { + log.debug( + `normalizing thinking=off to thinking=null for SiliconFlow compatibility (${provider}/${modelId})`, + ); + agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn); + } + if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); - agent.streamFn = createOpenRouterWrapper(agent.streamFn, thinkingLevel); + // "auto" is a dynamic routing model — we don't know which underlying model + // OpenRouter will select, and it may be a reasoning-required endpoint. + // Omit the thinkingLevel so we never inject `reasoning.effort: "none"`, + // which would cause a 400 on models where reasoning is mandatory. + // Users who need reasoning control should target a specific model ID. + // See: openclaw/openclaw#24851 + const openRouterThinkingLevel = modelId === "auto" ? undefined : thinkingLevel; + agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel); agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn); } @@ -558,6 +703,10 @@ export function applyExtraParamsToAgent( } } + // Guard Google payloads against invalid negative thinking budgets emitted by + // upstream model-ID heuristics for Gemini 3.1 variants. + agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); + // Work around upstream pi-ai hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI/OpenAI Codex providers so multi-turn // server-side conversation state is preserved. diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index dcfcc852a875..d56c4a191816 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,13 +1,13 @@ import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; +import { hasConfiguredModelFallbacks } from "../agent-scope.js"; import { isProfileInCooldown, markAuthProfileFailure, @@ -231,8 +231,11 @@ export async function runEmbeddedPiAgent( let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - const fallbackConfigured = - resolveAgentModelFallbackValues(params.config?.agents?.defaults?.model).length > 0; + const fallbackConfigured = hasConfiguredModelFallbacks({ + cfg: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); await ensureOpenClawModelsJson(params.config, agentDir); // Run before_model_resolve hooks early so plugins can override the @@ -516,6 +519,8 @@ export async function runEmbeddedPiAgent( const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: Parameters[0]["reason"] | null; + config?: RunEmbeddedPiAgentParams["config"]; + agentDir?: RunEmbeddedPiAgentParams["agentDir"]; }) => { const { profileId, reason } = failure; if (!profileId || !reason || reason === "timeout") { diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 8dcd25a415aa..97a881cf849c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,7 +1,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { injectHistoryImagesIntoMessages, resolvePromptBuildHookResult } from "./attempt.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { + injectHistoryImagesIntoMessages, + resolveAttemptFsWorkspaceOnly, + resolvePromptBuildHookResult, + resolvePromptModeForSession, +} from "./attempt.js"; describe("injectHistoryImagesIntoMessages", () => { const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; @@ -103,3 +109,56 @@ describe("resolvePromptBuildHookResult", () => { expect(result.prependContext).toBe("from-hook"); }); }); + +describe("resolvePromptModeForSession", () => { + it("uses minimal mode for subagent sessions", () => { + expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal"); + }); + + it("uses full mode for cron sessions", () => { + expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full"); + expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full"); + }); +}); + +describe("resolveAttemptFsWorkspaceOnly", () => { + it("uses global tools.fs.workspaceOnly when agent has no override", () => { + const cfg: OpenClawConfig = { + tools: { + fs: { workspaceOnly: true }, + }, + }; + + expect( + resolveAttemptFsWorkspaceOnly({ + config: cfg, + sessionAgentId: "main", + }), + ).toBe(true); + }); + + it("prefers agent-specific tools.fs.workspaceOnly override", () => { + const cfg: OpenClawConfig = { + tools: { + fs: { workspaceOnly: true }, + }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: false }, + }, + }, + ], + }, + }; + + expect( + resolveAttemptFsWorkspaceOnly({ + config: cfg, + sessionAgentId: "main", + }), + ).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 45cc5094a93a..3aad8fe78a6d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -11,6 +11,7 @@ import { } from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; +import type { OpenClawConfig } from "../../../config/config.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; @@ -19,7 +20,7 @@ import type { PluginHookBeforeAgentStartResult, PluginHookBeforePromptBuildResult, } from "../../../plugins/types.js"; -import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -75,6 +76,7 @@ import { import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; +import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; @@ -222,6 +224,23 @@ export async function resolvePromptBuildHookResult(params: { }; } +export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" { + if (!sessionKey) { + return "full"; + } + return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; +} + +export function resolveAttemptFsWorkspaceOnly(params: { + config?: OpenClawConfig; + sessionAgentId: string; +}): boolean { + return resolveEffectiveToolFsWorkspaceOnly({ + cfg: params.config, + agentId: params.sessionAgentId, + }); +} + function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -360,6 +379,10 @@ export async function runEmbeddedAttempt( config: params.config, agentId: params.agentId, }); + const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({ + config: params.config, + sessionAgentId, + }); // Check if the model supports native image input const modelHasVision = params.model.input?.includes("image") ?? false; const toolsRaw = params.disableTools @@ -500,10 +523,7 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = - isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) - ? "minimal" - : "full"; + const promptMode = resolvePromptModeForSession(params.sessionKey); const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], @@ -1089,6 +1109,7 @@ export async function runEmbeddedAttempt( historyMessages: activeSession.messages, maxBytes: MAX_IMAGE_BYTES, maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx, + workspaceOnly: effectiveFsWorkspaceOnly, // Enforce sandbox path restrictions when sandbox is enabled sandbox: sandbox?.enabled && sandbox?.fsBridge diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index d19ae3bd8998..f9cb846da40e 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; +import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js"; import { detectAndLoadPromptImages, detectImageReferences, @@ -275,4 +276,76 @@ describe("detectAndLoadPromptImages", () => { expect(result.images).toHaveLength(0); expect(result.historyImagesByIndex.size).toBe(0); }); + + it("blocks prompt image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "Inspect /agent/secret.png", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.images).toHaveLength(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("blocks history image refs outside workspace when sandbox workspaceOnly is enabled", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-image-sandbox-")); + const sandboxRoot = path.join(stateDir, "sandbox"); + const agentRoot = path.join(stateDir, "agent"); + await fs.mkdir(sandboxRoot, { recursive: true }); + await fs.mkdir(agentRoot, { recursive: true }); + const pngB64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; + await fs.writeFile(path.join(agentRoot, "secret.png"), Buffer.from(pngB64, "base64")); + const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot }); + const bridge = sandbox.fsBridge; + if (!bridge) { + throw new Error("sandbox fs bridge missing"); + } + + try { + const result = await detectAndLoadPromptImages({ + prompt: "No inline image in this turn.", + workspaceDir: sandboxRoot, + model: { input: ["text", "image"] }, + workspaceOnly: true, + historyMessages: [ + { + role: "user", + content: [{ type: "text", text: "Previous image /agent/secret.png" }], + }, + ], + sandbox: { root: sandbox.workspaceDir, bridge }, + }); + + expect(result.detectedRefs).toHaveLength(1); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.historyImagesByIndex.size).toBe(0); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index c11f191e4f4a..897e8ca16e22 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -4,6 +4,8 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveUserPath } from "../../../utils.js"; import { loadWebMedia } from "../../../web/media.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; +import { resolveSandboxedBridgeMediaPath } from "../../sandbox-media-paths.js"; +import { assertSandboxPath } from "../../sandbox-paths.js"; import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; import { sanitizeImageBlocks } from "../../tool-images.js"; import { log } from "../logger.js"; @@ -181,6 +183,7 @@ export async function loadImageFromRef( workspaceDir: string, options?: { maxBytes?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }, ): Promise { @@ -197,11 +200,15 @@ export async function loadImageFromRef( if (ref.type === "path") { if (options?.sandbox) { try { - const resolved = options.sandbox.bridge.resolvePath({ - filePath: targetPath, - cwd: options.sandbox.root, + const resolved = await resolveSandboxedBridgeMediaPath({ + sandbox: { + root: options.sandbox.root, + bridge: options.sandbox.bridge, + workspaceOnly: options.workspaceOnly, + }, + mediaPath: targetPath, }); - targetPath = resolved.hostPath; + targetPath = resolved.resolved; } catch (err) { log.debug( `Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, @@ -211,6 +218,14 @@ export async function loadImageFromRef( } else if (!path.isAbsolute(targetPath)) { targetPath = path.resolve(workspaceDir, targetPath); } + if (options?.workspaceOnly && !options?.sandbox) { + const root = options?.sandbox?.root ?? workspaceDir; + await assertSandboxPath({ + filePath: targetPath, + cwd: root, + root, + }); + } } // loadWebMedia handles local file paths (including file:// URLs) @@ -361,6 +376,7 @@ export async function detectAndLoadPromptImages(params: { historyMessages?: unknown[]; maxBytes?: number; maxDimensionPx?: number; + workspaceOnly?: boolean; sandbox?: { root: string; bridge: SandboxFsBridge }; }): Promise<{ /** Images for the current prompt (existingImages + detected in current prompt) */ @@ -422,6 +438,7 @@ export async function detectAndLoadPromptImages(params: { for (const ref of allRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, + workspaceOnly: params.workspaceOnly, sandbox: params.sandbox, }); if (image) { diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 5d950f2ee10b..ee8acd1d43e6 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -60,4 +60,26 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { absentDetail, }); }); + + it("suppresses sessions_send errors to avoid leaking transient relay failures", () => { + const payloads = buildPayloads({ + lastToolError: { toolName: "sessions_send", error: "delivery timeout" }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); + + it("suppresses sessions_send errors even when marked mutating", () => { + const payloads = buildPayloads({ + lastToolError: { + toolName: "sessions_send", + error: "delivery timeout", + mutatingAction: true, + }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index f1ff4dda724f..c3c878454513 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -67,6 +67,12 @@ function resolveToolErrorWarningPolicy(params: { if ((normalizedToolName === "exec" || normalizedToolName === "bash") && !includeDetails) { return { showWarning: false, includeDetails }; } + // sessions_send timeouts and errors are transient inter-session communication + // issues — the message may still have been delivered. Suppress warnings to + // prevent raw error text from leaking into the chat surface (#23989). + if (normalizedToolName === "sessions_send") { + return { showWarning: false, includeDetails }; + } const isMutatingToolError = params.lastToolError.mutatingAction ?? isLikelyMutatingToolName(params.lastToolError.toolName); if (isMutatingToolError) { @@ -102,6 +108,7 @@ export function buildEmbeddedRunPayloads(params: { mediaUrls?: string[]; replyToId?: string; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToTag?: boolean; replyToCurrent?: boolean; @@ -110,6 +117,7 @@ export function buildEmbeddedRunPayloads(params: { text: string; media?: string[]; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToId?: string; replyToTag?: boolean; @@ -181,7 +189,7 @@ export function buildEmbeddedRunPayloads(params: { ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { - replyItems.push({ text: reasoningText }); + replyItems.push({ text: reasoningText, isReasoning: true }); } const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : ""; diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index a9dda4110e00..a8072bf2e1a8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -24,8 +24,12 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) { .runBeforeCompaction( { messageCount: ctx.params.session.messages?.length ?? 0, + messages: ctx.params.session.messages, + sessionFile: ctx.params.session.sessionFile, + }, + { + sessionKey: ctx.params.sessionKey, }, - {}, ) .catch((err) => { ctx.log.warn(`before_compaction hook failed: ${String(err)}`); diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 845ded9f9b9e..a32c9fdf2195 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -339,7 +339,7 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = formattedReasoning; - void onBlockReply?.({ text: formattedReasoning }); + void onBlockReply?.({ text: formattedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index c03eb00da57e..96a988e5bc61 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -88,6 +88,37 @@ describe("handleToolExecutionStart read path checks", () => { expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path"); }); + + it("awaits onBlockReplyFlush before continuing tool start processing", async () => { + const { ctx, onBlockReplyFlush } = createTestContext(); + let releaseFlush: (() => void) | undefined; + onBlockReplyFlush.mockImplementation( + () => + new Promise((resolve) => { + releaseFlush = resolve; + }), + ); + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "exec", + toolCallId: "tool-await-flush", + args: { command: "echo hi" }, + }; + + const pending = handleToolExecutionStart(ctx, evt); + // Let the async function reach the awaited flush Promise. + await Promise.resolve(); + + // If flush isn't awaited, tool metadata would already be recorded here. + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(false); + expect(releaseFlush).toBeTypeOf("function"); + + releaseFlush?.(); + await pending; + + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true); + }); }); describe("handleToolExecutionEnd cron.add commitment tracking", () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index ea3031a6cc47..18dc11193f03 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -174,7 +174,7 @@ export async function handleToolExecutionStart( // Flush pending block replies to preserve message boundaries before tool execution. ctx.flushBlockReplyBuffer(); if (ctx.params.onBlockReplyFlush) { - void ctx.params.onBlockReplyFlush(); + await ctx.params.onBlockReplyFlush(); } const rawToolName = String(evt.toolName); diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index 4e002b4083a5..cd99ee6b6741 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -35,4 +35,16 @@ describe("extractMessagingToolSend", () => { expect(result?.provider).toBe("slack"); expect(result?.to).toBe("channel:C1"); }); + + it("accepts target alias when to is omitted", () => { + const result = extractMessagingToolSend("message", { + action: "send", + channel: "telegram", + target: "123", + }); + + expect(result?.tool).toBe("message"); + expect(result?.provider).toBe("telegram"); + expect(result?.to).toBe("telegram:123"); + }); }); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index f162d0cbd761..08a5e5f80c44 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -286,6 +286,14 @@ export function extractToolErrorMessage(result: unknown): string | undefined { return normalizeToolErrorText(text); } +function resolveMessageToolTarget(args: Record): string | undefined { + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (toRaw) { + return toRaw; + } + return typeof args.target === "string" ? args.target : undefined; +} + export function extractMessagingToolSend( toolName: string, args: Record, @@ -298,7 +306,7 @@ export function extractMessagingToolSend( if (action !== "send" && action !== "thread-reply") { return undefined; } - const toRaw = typeof args.to === "string" ? args.to : undefined; + const toRaw = resolveMessageToolTarget(args); if (!toRaw) { return undefined; } diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 93abd66f2d55..4fe53c3317c0 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -353,6 +353,7 @@ export const CLAUDE_PARAM_GROUPS = { { keys: ["newText", "new_string"], label: "newText (newText or new_string)", + allowEmpty: true, }, ], } as const; @@ -570,7 +571,7 @@ function mapContainerPathToWorkspaceRoot(params: { return params.filePath; } - let candidate = params.filePath; + let candidate = params.filePath.startsWith("@") ? params.filePath.slice(1) : params.filePath; if (/^file:\/\//i.test(candidate)) { try { candidate = fileURLToPath(candidate); diff --git a/src/agents/pi-tools.read.workspace-root-guard.test.ts b/src/agents/pi-tools.read.workspace-root-guard.test.ts index 0e6f76109f61..3757e7a1f4bd 100644 --- a/src/agents/pi-tools.read.workspace-root-guard.test.ts +++ b/src/agents/pi-tools.read.workspace-root-guard.test.ts @@ -61,6 +61,36 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => { }); }); + it("maps @-prefixed container workspace paths to host workspace root", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-container", { path: "@/workspace/docs/readme.md" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: path.resolve(root, "docs", "readme.md"), + cwd: root, + root, + }); + }); + + it("normalizes @-prefixed absolute paths before guard checks", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-absolute", { path: "@/etc/passwd" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: "/etc/passwd", + cwd: root, + root, + }); + }); + it("does not remap absolute paths outside the configured container workdir", async () => { const { tool } = createToolHarness(); const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index c383c8dff060..9db63efd9827 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -49,7 +49,7 @@ import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.sc import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; -import { createToolFsPolicy } from "./tool-fs-policy.js"; +import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -124,16 +124,6 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { }; } -function resolveFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }) { - const cfg = params.cfg; - const globalFs = cfg?.tools?.fs; - const agentFs = - cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined; - return { - workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly, - }; -} - export function resolveToolLoopDetectionConfig(params: { cfg?: OpenClawConfig; agentId?: string; @@ -295,7 +285,7 @@ export function createOpenClawCodingTools(options?: { subagentPolicy, ]); const execConfig = resolveExecConfig({ cfg: options?.config, agentId }); - const fsConfig = resolveFsConfig({ cfg: options?.config, agentId }); + const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId }); const fsPolicy = createToolFsPolicy({ workspaceOnly: fsConfig.workspaceOnly, }); diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 625c04227d3d..6fe98ff03f8f 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js"; @@ -60,6 +61,31 @@ describe("workspace path resolution", () => { }); }); + it("allows deletion edits with empty newText", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + await withTempDir("openclaw-cwd-", async (otherDir) => { + const testFile = "delete.txt"; + await fs.writeFile(path.join(workspaceDir, testFile), "hello world", "utf8"); + + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir); + try { + const tools = createOpenClawCodingTools({ workspaceDir }); + const { editTool } = expectReadWriteEditTools(tools); + + await editTool.execute("ws-edit-delete", { + path: testFile, + oldText: " world", + newText: "", + }); + + expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe("hello"); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + }); + it("defaults exec cwd to workspaceDir when workdir is omitted", async () => { await withTempDir("openclaw-ws-", async (workspaceDir) => { const tools = createOpenClawCodingTools({ @@ -112,6 +138,19 @@ describe("workspace path resolution", () => { }); }); }); + + it("rejects @-prefixed absolute paths outside workspace when workspaceOnly is enabled", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } }; + const tools = createOpenClawCodingTools({ workspaceDir, config: cfg }); + const { readTool } = expectReadWriteEditTools(tools); + + const outsideAbsolute = path.resolve(path.parse(workspaceDir).root, "outside-openclaw.txt"); + await expect( + readTool.execute("ws-read-at-prefix", { path: `@${outsideAbsolute}` }), + ).rejects.toThrow(/Path escapes sandbox root/i); + }); + }); }); describe("sandboxed workspace paths", () => { diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 2347b88fc3ed..9bc005471439 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -181,6 +181,12 @@ describe("buildSandboxCreateArgs", () => { cfg: createSandboxConfig({ network: "host" }), expected: /network mode "host" is blocked/, }, + { + name: "network container namespace join", + containerName: "openclaw-sbx-container-network", + cfg: createSandboxConfig({ network: "container:peer" }), + expected: /network mode "container:peer" is blocked by default/, + }, { name: "seccomp unconfined", containerName: "openclaw-sbx-seccomp", @@ -271,4 +277,18 @@ describe("buildSandboxCreateArgs", () => { }); expect(args).toEqual(expect.arrayContaining(["-v", "/tmp/override:/workspace:rw"])); }); + + it("allows container namespace join with explicit dangerous override", () => { + const cfg = createSandboxConfig({ + network: "container:peer", + dangerouslyAllowContainerNamespaceJoin: true, + }); + const args = buildSandboxCreateArgs({ + name: "openclaw-sbx-container-network-override", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }); + expect(args).toEqual(expect.arrayContaining(["--network", "container:peer"])); + }); }); diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index de317320a806..305da9eb40a7 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolveSandboxedMediaSource } from "./sandbox-paths.js"; async function withSandboxRoot(run: (sandboxDir: string) => Promise) { @@ -23,23 +24,70 @@ function isPathInside(root: string, target: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +function makeTmpProbePath(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`; +} + +async function withOutsideHardlinkInOpenClawTmp( + params: { + openClawTmpDir: string; + hardlinkPrefix: string; + symlinkPrefix?: string; + }, + run: (paths: { hardlinkPath: string; symlinkPath?: string }) => Promise, +): Promise { + const outsideDir = await fs.mkdtemp(path.join(process.cwd(), "sandbox-media-hardlink-outside-")); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join(params.openClawTmpDir, makeTmpProbePath(params.hardlinkPrefix)); + const symlinkPath = params.symlinkPrefix + ? path.join(params.openClawTmpDir, makeTmpProbePath(params.symlinkPrefix)) + : undefined; + try { + if (isPathInside(params.openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(params.openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + if (symlinkPath) { + await fs.symlink(hardlinkPath, symlinkPath); + } + await run({ hardlinkPath, symlinkPath }); + } finally { + if (symlinkPath) { + await fs.rm(symlinkPath, { force: true }); + } + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } +} + describe("resolveSandboxedMediaSource", () => { + const openClawTmpDir = resolvePreferredOpenClawTmpDir(); + // Group 1: /tmp paths (the bug fix) it.each([ { - name: "absolute paths under os.tmpdir()", - media: path.join(os.tmpdir(), "image.png"), - expected: path.join(os.tmpdir(), "image.png"), + name: "absolute paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "image.png"), + expected: path.join(openClawTmpDir, "image.png"), }, { - name: "file:// URLs pointing to os.tmpdir()", - media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href, - expected: path.join(os.tmpdir(), "photo.png"), + name: "file:// URLs pointing to preferred OpenClaw tmp root", + media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href, + expected: path.join(openClawTmpDir, "photo.png"), }, { - name: "nested paths under os.tmpdir()", - media: path.join(os.tmpdir(), "subdir", "deep", "file.png"), - expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"), + name: "nested paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "subdir", "deep", "file.png"), + expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"), }, ])("allows $name", async ({ media, expected }) => { await withSandboxRoot(async (sandboxDir) => { @@ -47,7 +95,7 @@ describe("resolveSandboxedMediaSource", () => { media, sandboxRoot: sandboxDir, }); - expect(result).toBe(expected); + expect(result).toBe(path.resolve(expected)); }); }); @@ -96,7 +144,12 @@ describe("resolveSandboxedMediaSource", () => { }, { name: "path traversal through tmpdir", - media: path.join(os.tmpdir(), "..", "etc", "passwd"), + media: path.join(openClawTmpDir, "..", "etc", "passwd"), + expected: /sandbox/i, + }, + { + name: "absolute paths under host tmp outside openclaw tmp root", + media: path.join(os.tmpdir(), "outside-openclaw", "passwd"), expected: /sandbox/i, }, { @@ -120,23 +173,66 @@ describe("resolveSandboxedMediaSource", () => { }); }); - it("rejects symlinked tmpdir paths escaping tmpdir", async () => { + it("rejects symlinked OpenClaw tmp paths escaping tmp root", async () => { if (process.platform === "win32") { return; } const outsideTmpTarget = path.resolve(process.cwd(), "package.json"); - if (isPathInside(os.tmpdir(), outsideTmpTarget)) { + if (isPathInside(openClawTmpDir, outsideTmpTarget)) { return; } await withSandboxRoot(async (sandboxDir) => { await fs.access(outsideTmpTarget); - const symlinkPath = path.join(sandboxDir, "tmp-link-escape"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + const symlinkPath = path.join(openClawTmpDir, `tmp-link-escape-${process.pid}`); await fs.symlink(outsideTmpTarget, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + try { + await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.unlink(symlinkPath).catch(() => {}); + } }); }); + it("rejects hardlinked OpenClaw tmp paths to outside files", async () => { + if (process.platform === "win32") { + return; + } + await withOutsideHardlinkInOpenClawTmp( + { + openClawTmpDir, + hardlinkPrefix: "sandbox-media-hardlink", + }, + async ({ hardlinkPath }) => { + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + }, + ); + }); + + it("rejects symlinked OpenClaw tmp paths to hardlinked outside files", async () => { + if (process.platform === "win32") { + return; + } + await withOutsideHardlinkInOpenClawTmp( + { + openClawTmpDir, + hardlinkPrefix: "sandbox-media-hardlink-target", + symlinkPrefix: "sandbox-media-hardlink-symlink", + }, + async ({ symlinkPath }) => { + if (!symlinkPath) { + return; + } + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(symlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + }, + ); + }); + // Group 4: Passthrough it("passes HTTP URLs through unchanged", async () => { const result = await resolveSandboxedMediaSource({ diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 31203715f99a..761106e85740 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const HTTP_URL_RE = /^https?:\/\//i; @@ -13,8 +14,12 @@ function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); } +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(filePath); + const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); if (normalized === "~") { return os.homedir(); } @@ -177,14 +182,42 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot)); - const tmpDir = path.resolve(os.tmpdir()); - if (!isPathInside(tmpDir, resolved)) { + const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir()); + if (!isPathInside(openClawTmpDir, resolved)) { return undefined; } - await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); + await assertNoTmpAliasEscape({ filePath: resolved, tmpRoot: openClawTmpDir }); return resolved; } +async function assertNoTmpAliasEscape(params: { + filePath: string; + tmpRoot: string; +}): Promise { + await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot); + await assertNoHardlinkedFinalPath(params.filePath, params.tmpRoot); +} + +async function assertNoHardlinkedFinalPath(filePath: string, tmpRoot: string): Promise { + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch (err) { + if (isNotFoundPathError(err)) { + return; + } + throw err; + } + if (!stat.isFile()) { + return; + } + if (stat.nlink > 1) { + throw new Error( + `Hardlinked tmp media path is not allowed under tmp root (${shortPath(tmpRoot)}): ${shortPath(filePath)}`, + ); + } +} + async function assertNoSymlinkEscape( relative: string, root: string, diff --git a/src/agents/sandbox/bind-spec.test.ts b/src/agents/sandbox/bind-spec.test.ts new file mode 100644 index 000000000000..30d86551cc4f --- /dev/null +++ b/src/agents/sandbox/bind-spec.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { splitSandboxBindSpec } from "./bind-spec.js"; + +describe("splitSandboxBindSpec", () => { + it("splits POSIX bind specs with and without mode", () => { + expect(splitSandboxBindSpec("/tmp/a:/workspace-a:ro")).toEqual({ + host: "/tmp/a", + container: "/workspace-a", + options: "ro", + }); + expect(splitSandboxBindSpec("/tmp/b:/workspace-b")).toEqual({ + host: "/tmp/b", + container: "/workspace-b", + options: "", + }); + }); + + it("preserves Windows drive-letter host paths", () => { + expect(splitSandboxBindSpec("C:\\Users\\kai\\workspace:/workspace:ro")).toEqual({ + host: "C:\\Users\\kai\\workspace", + container: "/workspace", + options: "ro", + }); + }); + + it("returns null when no host/container separator exists", () => { + expect(splitSandboxBindSpec("/tmp/no-separator")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/bind-spec.ts b/src/agents/sandbox/bind-spec.ts new file mode 100644 index 000000000000..4ce53c251a47 --- /dev/null +++ b/src/agents/sandbox/bind-spec.ts @@ -0,0 +1,34 @@ +type SplitBindSpec = { + host: string; + container: string; + options: string; +}; + +export function splitSandboxBindSpec(spec: string): SplitBindSpec | null { + const separator = getHostContainerSeparatorIndex(spec); + if (separator === -1) { + return null; + } + + const host = spec.slice(0, separator); + const rest = spec.slice(separator + 1); + const optionsStart = rest.indexOf(":"); + if (optionsStart === -1) { + return { host, container: rest, options: "" }; + } + return { + host, + container: rest.slice(0, optionsStart), + options: rest.slice(optionsStart + 1), + }; +} + +function getHostContainerSeparatorIndex(spec: string): number { + const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(spec); + for (let i = hasDriveLetterPrefix ? 2 : 0; i < spec.length; i += 1) { + if (spec[i] === ":") { + return i; + } + } + return -1; +} diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index f96261bfab71..c4459b19bdd2 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -36,6 +36,7 @@ import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js"; import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js"; import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; +import { validateNetworkMode } from "./validate-sandbox-security.js"; const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000; const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE"; @@ -107,14 +108,15 @@ async function ensureSandboxBrowserImage(image: string) { ); } -async function ensureDockerNetwork(network: string) { +async function ensureDockerNetwork( + network: string, + opts?: { allowContainerNamespaceJoin?: boolean }, +) { + validateNetworkMode(network, { + allowContainerNamespaceJoin: opts?.allowContainerNamespaceJoin === true, + }); const normalized = network.trim().toLowerCase(); - if ( - !normalized || - normalized === "bridge" || - normalized === "none" || - normalized.startsWith("container:") - ) { + if (!normalized || normalized === "bridge" || normalized === "none") { return; } const inspect = await execDocker(["network", "inspect", network], { allowFailure: true }); @@ -216,7 +218,9 @@ export async function ensureSandboxBrowser(params: { if (noVncEnabled) { noVncPassword = generateNoVncPassword(); } - await ensureDockerNetwork(browserDockerCfg.network); + await ensureDockerNetwork(browserDockerCfg.network, { + allowContainerNamespaceJoin: browserDockerCfg.dangerouslyAllowContainerNamespaceJoin === true, + }); await ensureSandboxBrowserImage(browserImage); const args = buildSandboxCreateArgs({ name: containerName, diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index 0fcb50999e42..b7595ae8c4b3 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -24,6 +24,26 @@ import type { SandboxScope, } from "./types.js"; +export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ + "dangerouslyAllowReservedContainerTargets", + "dangerouslyAllowExternalBindSources", + "dangerouslyAllowContainerNamespaceJoin", +] as const; + +type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number]; +type DangerousSandboxDockerBooleans = Pick; + +function resolveDangerousSandboxDockerBooleans( + agentDocker?: Partial, + globalDocker?: Partial, +): DangerousSandboxDockerBooleans { + const resolved = {} as DangerousSandboxDockerBooleans; + for (const key of DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS) { + resolved[key] = agentDocker?.[key] ?? globalDocker?.[key]; + } + return resolved; +} + export function resolveSandboxBrowserDockerCreateConfig(params: { docker: SandboxDockerConfig; browser: SandboxBrowserConfig; @@ -95,6 +115,7 @@ export function resolveSandboxDockerConfig(params: { dns: agentDocker?.dns ?? globalDocker?.dns, extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, binds: binds.length ? binds : undefined, + ...resolveDangerousSandboxDockerBooleans(agentDocker, globalDocker), }; } diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 270e8b761d43..efaa4b0e22e6 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -267,6 +267,7 @@ export function buildSandboxCreateArgs(params: { bindSourceRoots?: string[]; allowSourcesOutsideAllowedRoots?: boolean; allowReservedContainerTargets?: boolean; + allowContainerNamespaceJoin?: boolean; }) { // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. validateSandboxSecurity({ @@ -278,6 +279,9 @@ export function buildSandboxCreateArgs(params: { allowReservedContainerTargets: params.allowReservedContainerTargets ?? params.cfg.dangerouslyAllowReservedContainerTargets === true, + dangerouslyAllowContainerNamespaceJoin: + params.allowContainerNamespaceJoin ?? + params.cfg.dangerouslyAllowContainerNamespaceJoin === true, }); const createdAtMs = params.createdAtMs ?? Date.now(); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index ca4dd9d62bb0..d3bcd735e9ea 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -13,6 +13,28 @@ import { createSandboxTestContext } from "./test-fixtures.js"; import type { SandboxContext } from "./types.js"; const mockedExecDockerRaw = vi.mocked(execDockerRaw); +const DOCKER_SCRIPT_INDEX = 5; +const DOCKER_FIRST_SCRIPT_ARG_INDEX = 7; + +function getDockerScript(args: string[]): string { + return String(args[DOCKER_SCRIPT_INDEX] ?? ""); +} + +function getDockerArg(args: string[], position: number): string { + return String(args[DOCKER_FIRST_SCRIPT_ARG_INDEX + position - 1] ?? ""); +} + +function getDockerPathArg(args: string[]): string { + return getDockerArg(args, 1); +} + +function getScriptsFromCalls(): string[] { + return mockedExecDockerRaw.mock.calls.map(([args]) => getDockerScript(args)); +} + +function findCallByScriptFragment(fragment: string) { + return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment)); +} function createSandbox(overrides?: Partial): SandboxContext { return createSandboxTestContext({ @@ -31,10 +53,10 @@ describe("sandbox fs bridge shell compatibility", () => { beforeEach(() => { mockedExecDockerRaw.mockClear(); mockedExecDockerRaw.mockImplementation(async (args) => { - const script = args[5] ?? ""; + const script = getDockerScript(args); if (script.includes('readlink -f -- "$cursor"')) { return { - stdout: Buffer.from(`${String(args.at(-2) ?? "")}\n`), + stdout: Buffer.from(`${getDockerArg(args, 1)}\n`), stderr: Buffer.alloc(0), code: 0, }; @@ -73,14 +95,51 @@ describe("sandbox fs bridge shell compatibility", () => { expect(mockedExecDockerRaw).toHaveBeenCalled(); - const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const scripts = getScriptsFromCalls(); const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); expect(executables.every((shell) => shell === "sh")).toBe(true); - expect(scripts.every((script) => script.includes("set -eu;"))).toBe(true); + expect(scripts.every((script) => /set -eu[;\n]/.test(script))).toBe(true); expect(scripts.some((script) => script.includes("pipefail"))).toBe(false); }); + it("resolveCanonicalContainerPath script is valid POSIX sh (no do; token)", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + + await bridge.readFile({ filePath: "a.txt" }); + + const scripts = getScriptsFromCalls(); + const canonicalScript = scripts.find((script) => script.includes("allow_final")); + expect(canonicalScript).toBeDefined(); + // "; " joining can create "do; cmd", which is invalid in POSIX sh. + expect(canonicalScript).not.toMatch(/\bdo;/); + // Keep command on the next line after "do" for POSIX-sh safety. + expect(canonicalScript).toMatch(/\bdo\n\s*parent=/); + }); + + it("reads inbound media-style filenames with triple-dash ids", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + const inboundPath = "media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.ogg"; + + await bridge.readFile({ filePath: inboundPath }); + + const readCall = findCallByScriptFragment('cat -- "$1"'); + expect(readCall).toBeDefined(); + const readPath = readCall ? getDockerPathArg(readCall[0]) : ""; + expect(readPath).toContain("file_1095---"); + }); + + it("resolves dash-leading basenames into absolute container paths", async () => { + const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); + + await bridge.readFile({ filePath: "--leading.txt" }); + + const readCall = findCallByScriptFragment('cat -- "$1"'); + expect(readCall).toBeDefined(); + const readPath = readCall ? getDockerPathArg(readCall[0]) : ""; + expect(readPath).toBe("/workspace/--leading.txt"); + }); + it("resolves bind-mounted absolute container paths for reads", async () => { const sandbox = createSandbox({ docker: { @@ -96,7 +155,7 @@ describe("sandbox fs bridge shell compatibility", () => { expect(args).toEqual( expect.arrayContaining(["moltbot-sbx-test", "sh", "-c", 'set -eu; cat -- "$1"']), ); - expect(args.at(-1)).toBe("/workspace-two/README.md"); + expect(getDockerPathArg(args)).toBe("/workspace-two/README.md"); }); it("blocks writes into read-only bind mounts", async () => { @@ -118,9 +177,11 @@ describe("sandbox fs bridge shell compatibility", () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-")); const workspaceDir = path.join(stateDir, "workspace"); const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); - await fs.symlink(path.join(outsideDir, "secret.txt"), path.join(workspaceDir, "link.txt")); + await fs.writeFile(outsideFile, "classified"); + await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt")); const bridge = createSandboxFsBridge({ sandbox: createSandbox({ @@ -136,7 +197,7 @@ describe("sandbox fs bridge shell compatibility", () => { it("rejects container-canonicalized paths outside allowed mounts", async () => { mockedExecDockerRaw.mockImplementation(async (args) => { - const script = args[5] ?? ""; + const script = getDockerScript(args); if (script.includes('readlink -f -- "$cursor"')) { return { stdout: Buffer.from("/etc/passwd\n"), @@ -160,7 +221,7 @@ describe("sandbox fs bridge shell compatibility", () => { const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/escapes allowed mounts/i); - const scripts = mockedExecDockerRaw.mock.calls.map(([args]) => args[5] ?? ""); + const scripts = getScriptsFromCalls(); expect(scripts.some((script) => script.includes('cat -- "$1"'))).toBe(false); }); }); diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index fdcaf0cc46ce..226fc39ca1d4 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -8,6 +8,7 @@ import { type SandboxResolvedFsPath, type SandboxFsMount, } from "./fs-paths.js"; +import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js"; import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js"; type RunCommandOptions = { @@ -277,7 +278,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private resolveMountByContainerPath(containerPath: string): SandboxFsMount | null { const normalized = normalizeContainerPath(containerPath); for (const mount of this.mountsByContainer) { - if (isPathInsidePosix(normalizeContainerPath(mount.containerRoot), normalized)) { + if (isPathInsideContainerRoot(normalizeContainerPath(mount.containerRoot), normalized)) { return mount; } } @@ -305,7 +306,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { "done", 'canonical=$(readlink -f -- "$cursor")', 'printf "%s%s\\n" "$canonical" "$suffix"', - ].join("; "); + ].join("\n"); const result = await this.runCommand(script, { args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], }); @@ -351,18 +352,6 @@ function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { return "other"; } -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value); - return normalized === "." ? "/" : normalized; -} - -function isPathInsidePosix(root: string, target: string): boolean { - if (root === "/") { - return true; - } - return target === root || target.startsWith(`${root}/`); -} - async function assertNoHostSymlinkEscape(params: { absolutePath: string; rootPath: string; diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 11b5d7120403..7cd239ce0f33 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -1,6 +1,9 @@ import path from "node:path"; import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js"; +import { splitSandboxBindSpec } from "./bind-spec.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js"; +import { isPathInsideContainerRoot, normalizeContainerPath } from "./path-utils.js"; import type { SandboxContext } from "./types.js"; export type SandboxFsMount = { @@ -23,19 +26,13 @@ type ParsedBindMount = { writable: boolean; }; -type SplitBindSpec = { - host: string; - container: string; - options: string; -}; - export function parseSandboxBindMount(spec: string): ParsedBindMount | null { const trimmed = spec.trim(); if (!trimmed) { return null; } - const parsed = splitBindSpec(trimmed); + const parsed = splitSandboxBindSpec(trimmed); if (!parsed) { return null; } @@ -60,35 +57,6 @@ export function parseSandboxBindMount(spec: string): ParsedBindMount | null { }; } -function splitBindSpec(spec: string): SplitBindSpec | null { - const separator = getHostContainerSeparatorIndex(spec); - if (separator === -1) { - return null; - } - - const host = spec.slice(0, separator); - const rest = spec.slice(separator + 1); - const optionsStart = rest.indexOf(":"); - if (optionsStart === -1) { - return { host, container: rest, options: "" }; - } - return { - host, - container: rest.slice(0, optionsStart), - options: rest.slice(optionsStart + 1), - }; -} - -function getHostContainerSeparatorIndex(spec: string): number { - const hasDriveLetterPrefix = /^[A-Za-z]:[\\/]/.test(spec); - for (let i = hasDriveLetterPrefix ? 2 : 0; i < spec.length; i += 1) { - if (spec[i] === ":") { - return i; - } - } - return -1; -} - export function buildSandboxFsMounts(sandbox: SandboxContext): SandboxFsMount[] { const mounts: SandboxFsMount[] = [ { @@ -234,7 +202,7 @@ function dedupeMounts(mounts: SandboxFsMount[]): SandboxFsMount[] { function findMountByContainerPath(mounts: SandboxFsMount[], target: string): SandboxFsMount | null { for (const mount of mounts) { - if (isPathInsidePosix(mount.containerRoot, target)) { + if (isPathInsideContainerRoot(mount.containerRoot, target)) { return mount; } } @@ -250,16 +218,16 @@ function findMountByHostPath(mounts: SandboxFsMount[], target: string): SandboxF return null; } -function isPathInsidePosix(root: string, target: string): boolean { - const rel = path.posix.relative(root, target); - if (!rel) { - return true; - } - return !(rel.startsWith("..") || path.posix.isAbsolute(rel)); -} - function isPathInsideHost(root: string, target: string): boolean { - const rel = path.relative(root, target); + const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root)); + const resolvedTarget = path.resolve(target); + // Preserve the final path segment so pre-existing symlink leaves are validated + // by the dedicated symlink guard later in the bridge flow. + const canonicalTargetParent = resolveSandboxHostPathViaExistingAncestor( + path.dirname(resolvedTarget), + ); + const canonicalTarget = path.resolve(canonicalTargetParent, path.basename(resolvedTarget)); + const rel = path.relative(canonicalRoot, canonicalTarget); if (!rel) { return true; } @@ -284,11 +252,6 @@ function toDisplayRelative(params: { return params.containerPath; } -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value); - return normalized === "." ? "/" : normalized; -} - function normalizePosixInput(value: string): string { return value.replace(/\\/g, "/").trim(); } diff --git a/src/agents/sandbox/host-paths.test.ts b/src/agents/sandbox/host-paths.test.ts new file mode 100644 index 000000000000..30933a5e03e0 --- /dev/null +++ b/src/agents/sandbox/host-paths.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, mkdirSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + normalizeSandboxHostPath, + resolveSandboxHostPathViaExistingAncestor, +} from "./host-paths.js"; + +describe("normalizeSandboxHostPath", () => { + it("normalizes dot segments and strips trailing slash", () => { + expect(normalizeSandboxHostPath("/tmp/a/../b//")).toBe("/tmp/b"); + }); +}); + +describe("resolveSandboxHostPathViaExistingAncestor", () => { + it("keeps non-absolute paths unchanged", () => { + expect(resolveSandboxHostPathViaExistingAncestor("relative/path")).toBe("relative/path"); + }); + + it("resolves symlink parents when the final leaf does not exist", () => { + if (process.platform === "win32") { + return; + } + + const root = mkdtempSync(join(tmpdir(), "openclaw-host-paths-")); + const workspace = join(root, "workspace"); + const outside = join(root, "outside"); + mkdirSync(workspace, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const link = join(workspace, "alias-out"); + symlinkSync(outside, link); + + const unresolved = join(link, "missing-leaf"); + const resolved = resolveSandboxHostPathViaExistingAncestor(unresolved); + expect(resolved).toBe(join(realpathSync.native(outside), "missing-leaf")); + }); +}); diff --git a/src/agents/sandbox/host-paths.ts b/src/agents/sandbox/host-paths.ts new file mode 100644 index 000000000000..7b99ed0a53cb --- /dev/null +++ b/src/agents/sandbox/host-paths.ts @@ -0,0 +1,47 @@ +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +/** + * Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeSandboxHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * Resolve a path through the deepest existing ancestor so parent symlinks are honored + * even when the final source leaf does not exist yet. + */ +export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): string { + if (!sourcePath.startsWith("/")) { + return sourcePath; + } + + const normalized = normalizeSandboxHostPath(sourcePath); + let current = normalized; + const missingSegments: string[] = []; + + while (current !== "/" && !existsSync(current)) { + missingSegments.unshift(posix.basename(current)); + const parent = posix.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + if (!existsSync(current)) { + return normalized; + } + + try { + const resolvedAncestor = normalizeSandboxHostPath(realpathSync.native(current)); + if (missingSegments.length === 0) { + return resolvedAncestor; + } + return normalizeSandboxHostPath(posix.join(resolvedAncestor, ...missingSegments)); + } catch { + return normalized; + } +} diff --git a/src/agents/sandbox/network-mode.ts b/src/agents/sandbox/network-mode.ts new file mode 100644 index 000000000000..6fe5ee6ac828 --- /dev/null +++ b/src/agents/sandbox/network-mode.ts @@ -0,0 +1,28 @@ +export type NetworkModeBlockReason = "host" | "container_namespace_join"; + +export function normalizeNetworkMode(network: string | undefined): string | undefined { + const normalized = network?.trim().toLowerCase(); + return normalized || undefined; +} + +export function getBlockedNetworkModeReason(params: { + network: string | undefined; + allowContainerNamespaceJoin?: boolean; +}): NetworkModeBlockReason | null { + const normalized = normalizeNetworkMode(params.network); + if (!normalized) { + return null; + } + if (normalized === "host") { + return "host"; + } + if (normalized.startsWith("container:") && params.allowContainerNamespaceJoin !== true) { + return "container_namespace_join"; + } + return null; +} + +export function isDangerousNetworkMode(network: string | undefined): boolean { + const normalized = normalizeNetworkMode(network); + return normalized === "host" || normalized?.startsWith("container:") === true; +} diff --git a/src/agents/sandbox/path-utils.ts b/src/agents/sandbox/path-utils.ts new file mode 100644 index 000000000000..7bbc840fef1a --- /dev/null +++ b/src/agents/sandbox/path-utils.ts @@ -0,0 +1,15 @@ +import path from "node:path"; + +export function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value); + return normalized === "." ? "/" : normalized; +} + +export function isPathInsideContainerRoot(root: string, target: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedTarget = normalizeContainerPath(target); + if (normalizedRoot === "/") { + return true; + } + return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}/`); +} diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index fae66cc79249..cc3bd2e00a70 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -117,6 +117,44 @@ describe("validateBindMounts", () => { expect(run).toThrow(/blocked path/); }); + it("blocks symlink-parent escapes with non-existent leaf outside allowed roots", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const link = join(workspace, "alias-out"); + symlinkSync(outside, link); + const missingLeaf = join(link, "not-yet-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/data:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/outside allowed roots/); + }); + + it("blocks symlink-parent escapes into blocked paths when leaf does not exist", () => { + if (process.platform === "win32") { + // Windows source paths (e.g. C:\\...) are intentionally rejected as non-POSIX. + return; + } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const workspace = join(dir, "workspace"); + mkdirSync(workspace, { recursive: true }); + const link = join(workspace, "run-link"); + symlinkSync("/var/run", link); + const missingLeaf = join(link, "openclaw-not-created"); + expect(() => + validateBindMounts([`${missingLeaf}:/mnt/run:ro`], { + allowedSourceRoots: [workspace], + }), + ).toThrow(/blocked path/); + }); + it("rejects non-absolute source paths (relative or named volumes)", () => { const cases = ["../etc/passwd:/mnt/passwd", "etc/passwd:/mnt/passwd", "myvol:/mnt"] as const; for (const source of cases) { @@ -184,6 +222,30 @@ describe("validateNetworkMode", () => { expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected); } }); + + it("blocks container namespace joins by default", () => { + const cases = [ + { + mode: "container:abc123", + expected: /network mode "container:abc123" is blocked by default/, + }, + { + mode: "CONTAINER:ABC123", + expected: /network mode "CONTAINER:ABC123" is blocked by default/, + }, + ] as const; + for (const testCase of cases) { + expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected); + } + }); + + it("allows container namespace joins with explicit dangerous override", () => { + expect(() => + validateNetworkMode("container:abc123", { + allowContainerNamespaceJoin: true, + }), + ).not.toThrow(); + }); }); describe("validateSeccompProfile", () => { diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index a14fd50d0368..097f883f9882 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -5,9 +5,13 @@ * Enforced at runtime when creating sandbox containers. */ -import { existsSync, realpathSync } from "node:fs"; -import { posix } from "node:path"; +import { splitSandboxBindSpec } from "./bind-spec.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { + normalizeSandboxHostPath, + resolveSandboxHostPathViaExistingAncestor, +} from "./host-paths.js"; +import { getBlockedNetworkModeReason } from "./network-mode.js"; // Targeted denylist: host paths that should never be exposed inside sandbox containers. // Exported for reuse in security audit collectors. @@ -28,7 +32,6 @@ export const BLOCKED_HOST_PATHS = [ "/run/docker.sock", ]; -const BLOCKED_NETWORK_MODES = new Set(["host"]); const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); const RESERVED_CONTAINER_TARGET_PATHS = ["/workspace", SANDBOX_AGENT_WORKSPACE_MOUNT]; @@ -39,6 +42,10 @@ export type ValidateBindMountsOptions = { allowReservedContainerTargets?: boolean; }; +export type ValidateNetworkModeOptions = { + allowContainerNamespaceJoin?: boolean; +}; + export type BlockedBindReason = | { kind: "targets"; blockedPath: string } | { kind: "covers"; blockedPath: string } @@ -53,20 +60,11 @@ type ParsedBindSpec = { function parseBindSpec(bind: string): ParsedBindSpec { const trimmed = bind.trim(); - const firstColon = trimmed.indexOf(":"); - if (firstColon <= 0) { + const parsed = splitSandboxBindSpec(trimmed); + if (!parsed) { return { source: trimmed, target: "" }; } - const source = trimmed.slice(0, firstColon); - const rest = trimmed.slice(firstColon + 1); - const secondColon = rest.indexOf(":"); - if (secondColon === -1) { - return { source, target: rest }; - } - return { - source, - target: rest.slice(0, secondColon), - }; + return { source: parsed.host, target: parsed.container }; } /** @@ -85,8 +83,7 @@ export function parseBindTargetPath(bind: string): string { * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. */ export function normalizeHostPath(raw: string): string { - const trimmed = raw.trim(); - return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; + return normalizeSandboxHostPath(raw); } /** @@ -119,21 +116,6 @@ export function getBlockedReasonForSourcePath(sourceNormalized: string): Blocked return null; } -function tryRealpathAbsolute(path: string): string { - if (!path.startsWith("/")) { - return path; - } - if (!existsSync(path)) { - return path; - } - try { - // Use native when available (keeps platform semantics); normalize for prefix checks. - return normalizeHostPath(realpathSync.native(path)); - } catch { - return path; - } -} - function normalizeAllowedRoots(roots: string[] | undefined): string[] { if (!roots?.length) { return []; @@ -145,7 +127,7 @@ function normalizeAllowedRoots(roots: string[] | undefined): string[] { const expanded = new Set(); for (const root of normalized) { expanded.add(root); - const real = tryRealpathAbsolute(root); + const real = resolveSandboxHostPathViaExistingAncestor(root); if (real !== root) { expanded.add(real); } @@ -197,6 +179,25 @@ function getReservedTargetReason(bind: string): BlockedBindReason | null { return null; } +function enforceSourcePathPolicy(params: { + bind: string; + sourcePath: string; + allowedRoots: string[]; + allowSourcesOutsideAllowedRoots: boolean; +}): void { + const blockedReason = getBlockedReasonForSourcePath(params.sourcePath); + if (blockedReason) { + throw formatBindBlockedError({ bind: params.bind, reason: blockedReason }); + } + if (params.allowSourcesOutsideAllowedRoots) { + return; + } + const allowedReason = getOutsideAllowedRootsReason(params.sourcePath, params.allowedRoots); + if (allowedReason) { + throw formatBindBlockedError({ bind: params.bind, reason: allowedReason }); + } +} + function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { if (params.reason.kind === "non_absolute") { return new Error( @@ -227,7 +228,8 @@ function formatBindBlockedError(params: { bind: string; reason: BlockedBindReaso /** * Validate bind mounts — throws if any source path is dangerous. - * Includes a symlink/realpath pass when the source path exists. + * Includes a symlink/realpath pass via existing ancestors so non-existent leaf + * paths cannot bypass source-root and blocked-path checks. */ export function validateBindMounts( binds: string[] | undefined, @@ -260,39 +262,47 @@ export function validateBindMounts( const sourceRaw = parseBindSourcePath(bind); const sourceNormalized = normalizeHostPath(sourceRaw); - - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceNormalized, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } - } - - // Symlink escape hardening: resolve existing absolute paths and re-check. - const sourceReal = tryRealpathAbsolute(sourceNormalized); - if (sourceReal !== sourceNormalized) { - const reason = getBlockedReasonForSourcePath(sourceReal); - if (reason) { - throw formatBindBlockedError({ bind, reason }); - } - if (!options?.allowSourcesOutsideAllowedRoots) { - const allowedReason = getOutsideAllowedRootsReason(sourceReal, allowedRoots); - if (allowedReason) { - throw formatBindBlockedError({ bind, reason: allowedReason }); - } - } - } + enforceSourcePathPolicy({ + bind, + sourcePath: sourceNormalized, + allowedRoots, + allowSourcesOutsideAllowedRoots: options?.allowSourcesOutsideAllowedRoots === true, + }); + + // Symlink escape hardening: resolve through existing ancestors and re-check. + const sourceCanonical = resolveSandboxHostPathViaExistingAncestor(sourceNormalized); + enforceSourcePathPolicy({ + bind, + sourcePath: sourceCanonical, + allowedRoots, + allowSourcesOutsideAllowedRoots: options?.allowSourcesOutsideAllowedRoots === true, + }); } } -export function validateNetworkMode(network: string | undefined): void { - if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { +export function validateNetworkMode( + network: string | undefined, + options?: ValidateNetworkModeOptions, +): void { + const blockedReason = getBlockedNetworkModeReason({ + network, + allowContainerNamespaceJoin: options?.allowContainerNamespaceJoin, + }); + if (blockedReason === "host") { throw new Error( `Sandbox security: network mode "${network}" is blocked. ` + 'Network "host" mode bypasses container network isolation. ' + 'Use "bridge" or "none" instead.', ); } + + if (blockedReason === "container_namespace_join") { + throw new Error( + `Sandbox security: network mode "${network}" is blocked by default. ` + + 'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' + + "Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + ); + } } export function validateSeccompProfile(profile: string | undefined): void { @@ -321,10 +331,13 @@ export function validateSandboxSecurity( network?: string; seccompProfile?: string; apparmorProfile?: string; + dangerouslyAllowContainerNamespaceJoin?: boolean; } & ValidateBindMountsOptions, ): void { validateBindMounts(cfg.binds, cfg); - validateNetworkMode(cfg.network); + validateNetworkMode(cfg.network, { + allowContainerNamespaceJoin: cfg.dangerouslyAllowContainerNamespaceJoin === true, + }); validateSeccompProfile(cfg.seccompProfile); validateApparmorProfile(cfg.apparmorProfile); } diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 7b6566066467..7df8b8d48df3 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -357,4 +357,61 @@ describe("installSessionToolResultGuard", () => { sourceTool: "sessions_send", }); }); + + // When an assistant message with toolCalls is aborted, no synthetic toolResult + // should be created. Creating synthetic results for aborted/incomplete tool calls + // causes API 400 errors: "unexpected tool_use_id found in tool_result blocks". + it("does NOT create synthetic toolResult for aborted assistant messages with toolCalls", () => { + const sm = SessionManager.inMemory(); + installSessionToolResultGuard(sm); + + // Aborted assistant message with incomplete toolCall + sm.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_aborted", name: "read", arguments: {} }], + stopReason: "aborted", + }), + ); + + // Next message triggers flush of pending tool calls + sm.appendMessage( + asAppendMessage({ + role: "user", + content: "are you stuck?", + timestamp: Date.now(), + }), + ); + + // Should only have assistant + user, NO synthetic toolResult + const messages = getPersistedMessages(sm); + const roles = messages.map((m) => m.role); + expect(roles).toEqual(["assistant", "user"]); + expect(roles).not.toContain("toolResult"); + }); + + it("does NOT create synthetic toolResult for errored assistant messages with toolCalls", () => { + const sm = SessionManager.inMemory(); + const guard = installSessionToolResultGuard(sm); + + // Error assistant message with incomplete toolCall + sm.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }], + stopReason: "error", + }), + ); + + // Explicit flush should NOT create synthetic result for errored messages + guard.flushPendingToolResults(); + + const messages = getPersistedMessages(sm); + const toolResults = messages.filter((m) => m.role === "toolResult"); + // No synthetic toolResults should exist for the errored call + const syntheticForError = toolResults.filter( + (m) => (m as { toolCallId?: string }).toolCallId === "call_error", + ); + expect(syntheticForError).toHaveLength(0); + }); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 689bb816c1e8..dba618a31035 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -166,8 +166,15 @@ export function installSessionToolResultGuard( return originalAppend(persisted as never); } + // Skip tool call extraction for aborted/errored assistant messages. + // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete + // and should not have synthetic tool_results created. Creating synthetic results + // for incomplete tool calls causes API 400 errors: + // "unexpected tool_use_id found in tool_result blocks" + // This matches the behavior in repairToolUseResultPairing (session-transcript-repair.ts) + const stopReason = (nextMessage as { stopReason?: string }).stopReason; const toolCalls = - nextRole === "assistant" + nextRole === "assistant" && stopReason !== "aborted" && stopReason !== "error" ? extractToolCallsFromAssistant(nextMessage as Extract) : []; diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 25be7c7574ec..9716fb73c8d2 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; -import { getShellConfig, resolveShellFromPath } from "./shell-utils.js"; +import { getShellConfig, resolvePowerShellPath, resolveShellFromPath } from "./shell-utils.js"; const isWin = process.platform === "win32"; @@ -42,7 +42,8 @@ describe("getShellConfig", () => { if (isWin) { it("uses PowerShell on Windows", () => { const { shell } = getShellConfig(); - expect(shell.toLowerCase()).toContain("powershell"); + const normalized = shell.toLowerCase(); + expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true); }); return; } @@ -113,3 +114,96 @@ describe("resolveShellFromPath", () => { expect(resolveShellFromPath("bash")).toBeUndefined(); }); }); + +describe("resolvePowerShellPath", () => { + let envSnapshot: ReturnType; + const tempDirs: string[] = []; + + beforeEach(() => { + envSnapshot = captureEnv([ + "ProgramFiles", + "PROGRAMFILES", + "ProgramW6432", + "SystemRoot", + "WINDIR", + "PATH", + ]); + }); + + afterEach(() => { + envSnapshot.restore(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prefers PowerShell 7 in ProgramFiles", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + tempDirs.push(base); + const pwsh7Dir = path.join(base, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = base; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("prefers ProgramW6432 PowerShell 7 when ProgramFiles lacks pwsh", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const programW6432 = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pw6432-")); + tempDirs.push(programFiles, programW6432); + const pwsh7Dir = path.join(programW6432, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.ProgramW6432 = programW6432; + process.env.PATH = ""; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("finds pwsh on PATH when not in standard install locations", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-")); + tempDirs.push(programFiles, binDir); + const pwshPath = path.join(binDir, "pwsh"); + fs.writeFileSync(pwshPath, ""); + fs.chmodSync(pwshPath, 0o755); + + process.env.ProgramFiles = programFiles; + process.env.PATH = binDir; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwshPath); + }); + + it("falls back to Windows PowerShell 5.1 path when pwsh is unavailable", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const sysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-")); + tempDirs.push(programFiles, sysRoot); + const ps51Dir = path.join(sysRoot, "System32", "WindowsPowerShell", "v1.0"); + fs.mkdirSync(ps51Dir, { recursive: true }); + const ps51Path = path.join(ps51Dir, "powershell.exe"); + fs.writeFileSync(ps51Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.SystemRoot = sysRoot; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(ps51Path); + }); +}); diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index ca4faa30195a..a4a5dbc115ab 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -2,7 +2,27 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -function resolvePowerShellPath(): string { +export function resolvePowerShellPath(): string { + // Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support. + const programFiles = process.env.ProgramFiles || process.env.PROGRAMFILES || "C:\\Program Files"; + const pwsh7 = path.join(programFiles, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7)) { + return pwsh7; + } + + const programW6432 = process.env.ProgramW6432; + if (programW6432 && programW6432 !== programFiles) { + const pwsh7Alt = path.join(programW6432, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7Alt)) { + return pwsh7Alt; + } + } + + const pwshInPath = resolveShellFromPath("pwsh"); + if (pwshInPath) { + return pwshInPath; + } + const systemRoot = process.env.SystemRoot || process.env.WINDIR; if (systemRoot) { const candidate = path.join( diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index c3e7999fe875..90c8711cd744 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -12,10 +12,10 @@ import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; const log = createSubsystemLogger("skills"); export function resolvePluginSkillDirs(params: { - workspaceDir: string; + workspaceDir: string | undefined; config?: OpenClawConfig; }): string[] { - const workspaceDir = params.workspaceDir.trim(); + const workspaceDir = (params.workspaceDir ?? "").trim(); if (!workspaceDir) { return []; } diff --git a/src/agents/subagent-announce-queue.test.ts b/src/agents/subagent-announce-queue.test.ts index 6e673cd2fda2..b638b2fad3f9 100644 --- a/src/agents/subagent-announce-queue.test.ts +++ b/src/agents/subagent-announce-queue.test.ts @@ -27,6 +27,7 @@ function createRetryingSend() { describe("subagent-announce-queue", () => { afterEach(() => { + vi.useRealTimers(); resetAnnounceQueuesForTests(); }); @@ -116,4 +117,52 @@ describe("subagent-announce-queue", () => { expect(sender.prompts[1]).toContain("Queued #2"); expect(sender.prompts[1]).toContain("queued item two"); }); + + it("uses debounce floor for retries when debounce exceeds backoff", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const previousFast = process.env.OPENCLAW_TEST_FAST; + delete process.env.OPENCLAW_TEST_FAST; + + try { + const attempts: number[] = []; + const send = vi.fn(async () => { + attempts.push(Date.now()); + if (attempts.length === 1) { + throw new Error("transient timeout"); + } + }); + + enqueueAnnounce({ + key: "announce:test:retry-debounce-floor", + item: { + prompt: "subagent completed", + enqueuedAt: Date.now(), + sessionKey: "agent:main:telegram:dm:u1", + }, + settings: { mode: "followup", debounceMs: 5_000 }, + send, + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4_999); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(2); + const [firstAttempt, secondAttempt] = attempts; + if (firstAttempt === undefined || secondAttempt === undefined) { + throw new Error("expected two retry attempts"); + } + expect(secondAttempt - firstAttempt).toBeGreaterThanOrEqual(5_000); + } finally { + if (previousFast === undefined) { + delete process.env.OPENCLAW_TEST_FAST; + } else { + process.env.OPENCLAW_TEST_FAST = previousFast; + } + } + }); }); diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index c81dd94b1d92..cd99372adc8c 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -48,6 +48,8 @@ type AnnounceQueueState = { droppedCount: number; summaryLines: string[]; send: (item: AnnounceQueueItem) => Promise; + /** Consecutive drain failures — drives exponential backoff on errors. */ + consecutiveFailures: number; }; const ANNOUNCE_QUEUES = new Map(); @@ -89,6 +91,7 @@ function getAnnounceQueue( droppedCount: 0, summaryLines: [], send, + consecutiveFailures: 0, }; applyQueueRuntimeSettings({ target: created, @@ -174,10 +177,17 @@ function scheduleAnnounceDrain(key: string) { break; } } + // Drain succeeded — reset failure counter. + queue.consecutiveFailures = 0; } catch (err) { - // Keep items in queue and retry after debounce; avoid hot-loop retries. - queue.lastEnqueuedAt = Date.now(); - defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`); + queue.consecutiveFailures++; + // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. + const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000); + const retryDelayMs = Math.max(errorBackoffMs, queue.debounceMs); + queue.lastEnqueuedAt = Date.now() + retryDelayMs - queue.debounceMs; + defaultRuntime.error?.( + `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(retryDelayMs / 1000)}s): ${String(err)}`, + ); } finally { queue.draining = false; if (queue.items.length === 0 && queue.droppedCount === 0) { @@ -196,7 +206,8 @@ export function enqueueAnnounce(params: { send: (item: AnnounceQueueItem) => Promise; }): boolean { const queue = getAnnounceQueue(params.key, params.settings, params.send); - queue.lastEnqueuedAt = Date.now(); + // Preserve any retry backoff marker already encoded in lastEnqueuedAt. + queue.lastEnqueuedAt = Math.max(queue.lastEnqueuedAt, Date.now()); const shouldEnqueue = applyQueueDropPolicy({ queue, diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index a612e9fca023..91f4b0d6752d 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -401,6 +401,102 @@ describe("subagent announce formatting", () => { expect(msg).not.toContain("Convert the result above into your normal assistant voice"); }); + it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-skip", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "ANNOUNCE_SKIP", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("suppresses announce flow for whitespace-padded ANNOUNCE_SKIP and still runs cleanup", async () => { + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-skip-whitespace", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + cleanup: "delete", + roundOneReply: " ANNOUNCE_SKIP ", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).not.toHaveBeenCalled(); + expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1); + }); + + it("retries completion direct send on transient channel-unavailable errors", async () => { + sendSpy + .mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)")) + .mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting")) + .mockResolvedValueOnce({ runId: "send-main", status: "ok" }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "whatsapp", to: "+15550000000", accountId: "default" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "final answer", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(3); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("does not retry completion direct send on permanent channel errors", async () => { + sendSpy.mockRejectedValueOnce(new Error("unsupported channel: telegram")); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion-no-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "telegram", to: "telegram:1234" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + roundOneReply: "final answer", + }); + + expect(didAnnounce).toBe(false); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + }); + + it("retries direct agent announce on transient channel-unavailable errors", async () => { + agentSpy + .mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)")) + .mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable")) + .mockResolvedValueOnce({ runId: "run-main", status: "ok" }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-agent-retry", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "whatsapp", to: "+15551112222", accountId: "default" }, + ...defaultOutcomeAnnounce, + roundOneReply: "worker result", + }); + + expect(didAnnounce).toBe(true); + expect(agentSpy).toHaveBeenCalledTimes(3); + expect(sendSpy).not.toHaveBeenCalled(); + }); + it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => { sessionStore = { "agent:main:subagent:test": { @@ -993,6 +1089,77 @@ describe("subagent announce formatting", () => { }); }); + it("falls back to internal requester-session injection when completion route is missing", async () => { + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-no-route", + }, + }; + agentSpy.mockImplementationOnce(async (req: AgentCallRequest) => { + const deliver = req.params?.deliver; + const channel = req.params?.channel; + if (deliver === true && typeof channel !== "string") { + throw new Error("Channel is required when deliver=true"); + } + return { runId: "run-main", status: "ok" }; + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-missing-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(agentSpy).toHaveBeenCalledTimes(1); + expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "agent", + params: { + sessionKey: "agent:main:main", + deliver: false, + }, + }); + }); + + it("uses direct completion delivery when explicit channel+to route is available", async () => { + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-direct-route", + }, + }; + agentSpy.mockImplementationOnce(async () => { + throw new Error("agent fallback should not run when direct route exists"); + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-explicit-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).toHaveBeenCalledTimes(0); + expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "send", + params: { + sessionKey: "agent:main:main", + channel: "discord", + to: "channel:12345", + }, + }); + }); + it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index b794824ebae6..7d7fd7ceb48b 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -21,7 +21,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, } from "../utils/delivery-context.js"; -import { isDeliverableMessageChannel } from "../utils/message-channel.js"; +import { isDeliverableMessageChannel, isInternalMessageChannel } from "../utils/message-channel.js"; import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, @@ -37,12 +37,16 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; import { readLatestAssistantReply } from "./tools/agent-step.js"; import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js"; +import { isAnnounceSkip } from "./tools/sessions-send-helpers.js"; const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1"; const FAST_TEST_RETRY_INTERVAL_MS = 8; const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; +const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE + ? ([8, 16, 32] as const) + : ([5_000, 10_000, 20_000] as const); type ToolResultMessage = { role?: unknown; @@ -72,6 +76,9 @@ function buildCompletionDeliveryMessage(params: { outcome?: SubagentRunOutcome; }): string { const findingsText = params.findings.trim(); + if (isAnnounceSkip(findingsText)) { + return ""; + } const hasFindings = findingsText.length > 0 && findingsText !== "(no output)"; const header = (() => { if (params.outcome?.status === "error") { @@ -111,6 +118,92 @@ function summarizeDeliveryError(error: unknown): string { } } +const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ + /\berrorcode=unavailable\b/i, + /\bstatus\s*[:=]\s*"?unavailable\b/i, + /\bUNAVAILABLE\b/, + /no active .* listener/i, + /gateway not connected/i, + /gateway closed \(1006/i, + /gateway timeout/i, + /\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i, +]; + +const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ + /unsupported channel/i, + /unknown channel/i, + /chat not found/i, + /user not found/i, + /bot was blocked by the user/i, + /forbidden: bot was kicked/i, + /recipient is not a valid/i, + /outbound not configured for channel/i, +]; + +function isTransientAnnounceDeliveryError(error: unknown): boolean { + const message = summarizeDeliveryError(error); + if (!message) { + return false; + } + if (PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) { + return false; + } + return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)); +} + +async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) { + return; + } + if (!signal) { + await new Promise((resolve) => setTimeout(resolve, ms)); + return; + } + if (signal.aborted) { + return; + } + await new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function runAnnounceDeliveryWithRetry(params: { + operation: string; + signal?: AbortSignal; + run: () => Promise; +}): Promise { + let retryIndex = 0; + for (;;) { + if (params.signal?.aborted) { + throw new Error("announce delivery aborted"); + } + try { + return await params.run(); + } catch (err) { + const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex]; + if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) { + throw err; + } + const nextAttempt = retryIndex + 2; + const maxAttempts = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS.length + 1; + defaultRuntime.log( + `[warn] Subagent announce ${params.operation} transient failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDeliveryError(err)}`, + ); + retryIndex += 1; + await waitForAnnounceRetryDelay(delayMs, params.signal); + } + } +} + function extractToolResultText(content: unknown): string { if (typeof content === "string") { return sanitizeTextContent(content); @@ -350,9 +443,12 @@ function resolveAnnounceOrigin( ): DeliveryContext | undefined { const normalizedRequester = normalizeDeliveryContext(requesterOrigin); const normalizedEntry = deliveryContextFromSession(entry); - if (normalizedRequester?.channel && !isDeliverableMessageChannel(normalizedRequester.channel)) { - // Ignore internal/non-deliverable channel hints (for example webchat) - // so a valid persisted route can still be used for outbound delivery. + if (normalizedRequester?.channel && isInternalMessageChannel(normalizedRequester.channel)) { + // Ignore internal channel hints (webchat) so a valid persisted route + // can still be used for outbound delivery. Non-standard channels that + // are not in the deliverable list should NOT be stripped here — doing + // so causes the session entry's stale lastChannel (often WhatsApp) to + // override the actual requester origin, leading to delivery failures. return mergeDeliveryContext( { accountId: normalizedRequester.accountId, @@ -709,18 +805,23 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - await callGateway({ - method: "send", - params: { - channel: completionChannel, - to: completionTo, - accountId: completionDirectOrigin?.accountId, - threadId: completionThreadId, - sessionKey: canonicalRequesterSessionKey, - message: params.completionMessage, - idempotencyKey: params.directIdempotencyKey, - }, - timeoutMs: announceTimeoutMs, + await runAnnounceDeliveryWithRetry({ + operation: "completion direct send", + signal: params.signal, + run: async () => + await callGateway({ + method: "send", + params: { + channel: completionChannel, + to: completionTo, + accountId: completionDirectOrigin?.accountId, + threadId: completionThreadId, + sessionKey: canonicalRequesterSessionKey, + message: params.completionMessage, + idempotencyKey: params.directIdempotencyKey, + }, + timeoutMs: announceTimeoutMs, + }), }); return { @@ -731,6 +832,16 @@ async function sendSubagentAnnounceDirectly(params: { } const directOrigin = normalizeDeliveryContext(params.directOrigin); + const directChannelRaw = + typeof directOrigin?.channel === "string" ? directOrigin.channel.trim() : ""; + const directChannel = + directChannelRaw && isDeliverableMessageChannel(directChannelRaw) ? directChannelRaw : ""; + const directTo = typeof directOrigin?.to === "string" ? directOrigin.to.trim() : ""; + const hasDeliverableDirectTarget = + !params.requesterIsSubagent && Boolean(directChannel) && Boolean(directTo); + const shouldDeliverExternally = + !params.requesterIsSubagent && + (!params.expectsCompletionMessage || hasDeliverableDirectTarget); const threadId = directOrigin?.threadId != null && directOrigin.threadId !== "" ? String(directOrigin.threadId) @@ -741,21 +852,26 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - await callGateway({ - method: "agent", - params: { - sessionKey: canonicalRequesterSessionKey, - message: params.triggerMessage, - deliver: !params.requesterIsSubagent, - bestEffortDeliver: params.bestEffortDeliver, - channel: params.requesterIsSubagent ? undefined : directOrigin?.channel, - accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId, - to: params.requesterIsSubagent ? undefined : directOrigin?.to, - threadId: params.requesterIsSubagent ? undefined : threadId, - idempotencyKey: params.directIdempotencyKey, - }, - expectFinal: true, - timeoutMs: announceTimeoutMs, + await runAnnounceDeliveryWithRetry({ + operation: "direct announce agent call", + signal: params.signal, + run: async () => + await callGateway({ + method: "agent", + params: { + sessionKey: canonicalRequesterSessionKey, + message: params.triggerMessage, + deliver: shouldDeliverExternally, + bestEffortDeliver: params.bestEffortDeliver, + channel: shouldDeliverExternally ? directChannel : undefined, + accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined, + to: shouldDeliverExternally ? directTo : undefined, + threadId: shouldDeliverExternally ? threadId : undefined, + idempotencyKey: params.directIdempotencyKey, + }, + expectFinal: true, + timeoutMs: announceTimeoutMs, + }), }); return { @@ -1083,6 +1199,10 @@ export async function runSubagentAnnounceFlow(params: { return false; } + if (isAnnounceSkip(reply)) { + return true; + } + if (!outcome) { outcome = { status: "unknown" }; } diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 5a2bfb2dbecb..8389c53503c6 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -16,7 +16,11 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../config/sessions.js", () => ({ - loadSessionStore: () => ({}), + loadSessionStore: () => ({ + "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, + "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, + "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, + }), resolveAgentIdFromSessionKey: (key: string) => { const match = key.match(/^agent:([^:]+)/); return match?.[1] ?? "main"; diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 9ef2458e35c8..1c3db23672fe 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; import { captureEnv } from "../test-utils/env.js"; import { + addSubagentRunForTests, + clearSubagentRunSteerRestart, initSubagentRegistry, + listSubagentRunsForRequester, registerSubagentRun, resetSubagentRegistryForTests, } from "./subagent-registry.js"; @@ -22,12 +25,93 @@ describe("subagent registry persistence", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; - const writePersistedRegistry = async (persisted: Record) => { + const resolveAgentIdFromSessionKey = (sessionKey: string) => { + const match = sessionKey.match(/^agent:([^:]+):/i); + return (match?.[1] ?? "main").trim().toLowerCase() || "main"; + }; + + const resolveSessionStorePath = (stateDir: string, agentId: string) => + path.join(stateDir, "agents", agentId, "sessions", "sessions.json"); + + const readSessionStore = async (storePath: string) => { + try { + const raw = await fs.readFile(storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record>; + } + } catch { + // ignore + } + return {} as Record>; + }; + + const writeChildSessionEntry = async (params: { + sessionKey: string; + sessionId?: string; + updatedAt?: number; + }) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + store[params.sessionKey] = { + ...store[params.sessionKey], + sessionId: params.sessionId ?? `sess-${agentId}-${Date.now()}`, + updatedAt: params.updatedAt ?? Date.now(), + }; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const removeChildSessionEntry = async (sessionKey: string) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + delete store[sessionKey]; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const seedChildSessionsForPersistedRuns = async (persisted: Record) => { + const runs = (persisted.runs ?? {}) as Record< + string, + { + runId?: string; + childSessionKey?: string; + } + >; + for (const [runId, run] of Object.entries(runs)) { + const childSessionKey = run?.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: `sess-${run.runId ?? runId}`, + }); + } + }; + + const writePersistedRegistry = async ( + persisted: Record, + opts?: { seedChildSessions?: boolean }, + ) => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; const registryPath = path.join(tempStateDir, "subagents", "runs.json"); await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + if (opts?.seedChildSessions !== false) { + await seedChildSessionsForPersistedRuns(persisted); + } return registryPath; }; @@ -90,6 +174,10 @@ describe("subagent registry persistence", () => { task: "do the thing", cleanup: "keep", }); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:test", + sessionId: "sess-test", + }); const registryPath = path.join(tempStateDir, "subagents", "runs.json"); const raw = await fs.readFile(registryPath, "utf8"); @@ -162,6 +250,10 @@ describe("subagent registry persistence", () => { }; await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:two", + sessionId: "sess-two", + }); resetSubagentRegistryForTests({ persist: false }); initSubagentRegistry(); @@ -268,6 +360,64 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs?.["run-4"]).toBeUndefined(); }); + it("reconciles orphaned restored runs by pruning them from registry", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-orphan-restore", + childSessionKey: "agent:main:subagent:ghost-restore", + task: "orphan restore", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted, { + seedChildSessions: false, + }); + + await restartRegistryAndFlush(); + + expect(announceSpy).not.toHaveBeenCalled(); + const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs?: Record; + }; + expect(after.runs?.["run-orphan-restore"]).toBeUndefined(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + }); + + it("resume guard prunes orphan runs before announce retry", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + const runId = "run-orphan-resume-guard"; + const childSessionKey = "agent:main:subagent:ghost-resume"; + const now = Date.now(); + + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: "sess-resume-guard", + updatedAt: now, + }); + addSubagentRunForTests({ + runId, + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "resume orphan guard", + cleanup: "keep", + createdAt: now - 50, + startedAt: now - 25, + endedAt: now, + suppressAnnounceReason: "steer-restart", + cleanupHandled: false, + }); + await removeChildSessionEntry(childSessionKey); + + const changed = clearSubagentRunSteerRestart(runId); + expect(changed).toBe(true); + await flushQueuedRegistryWork(); + + expect(announceSpy).not.toHaveBeenCalled(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + const persisted = loadSubagentRegistryFromDisk(); + expect(persisted.has(runId)).toBe(false); + }); + it("uses isolated temp state when OPENCLAW_STATE_DIR is unset in tests", async () => { delete process.env.OPENCLAW_STATE_DIR; vi.resetModules(); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 0eed4e055324..6a7e86100c6d 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -38,6 +38,31 @@ vi.mock("../config/config.js", () => ({ })), })); +vi.mock("../config/sessions.js", () => { + const sessionStore = new Proxy>( + {}, + { + get(target, prop, receiver) { + if (typeof prop !== "string" || prop in target) { + return Reflect.get(target, prop, receiver); + } + return { sessionId: `sess-${prop}`, updatedAt: 1 }; + }, + }, + ); + + return { + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: (key: string) => { + const match = key.match(/^agent:([^:]+)/); + return match?.[1] ?? "main"; + }, + resolveMainSessionKey: () => "agent:main:main", + resolveStorePath: () => "/tmp/test-store", + updateSessionStore: vi.fn(), + }; +}); + const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); vi.mock("./subagent-announce.js", () => ({ diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 8506b77d53e4..edb8f228b07b 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,4 +1,10 @@ import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { defaultRuntime } from "../runtime.js"; @@ -59,6 +65,7 @@ const MAX_ANNOUNCE_RETRY_COUNT = 3; * succeeded. Guards against stale registry entries surviving gateway restarts. */ const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes +type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id"; function resolveAnnounceRetryDelayMs(retryCount: number) { const boundedRetryCount = Math.max(0, Math.min(retryCount, 10)); @@ -82,6 +89,119 @@ function persistSubagentRuns() { persistSubagentRunsToDisk(subagentRuns); } +function findSessionEntryByKey(store: Record, sessionKey: string) { + const direct = store[sessionKey]; + if (direct) { + return direct; + } + const normalized = sessionKey.toLowerCase(); + for (const [key, entry] of Object.entries(store)) { + if (key.toLowerCase() === normalized) { + return entry; + } + } + return undefined; +} + +function resolveSubagentRunOrphanReason(params: { + entry: SubagentRunRecord; + storeCache?: Map>; +}): SubagentRunOrphanReason | null { + const childSessionKey = params.entry.childSessionKey?.trim(); + if (!childSessionKey) { + return "missing-session-entry"; + } + try { + const cfg = loadConfig(); + const agentId = resolveAgentIdFromSessionKey(childSessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + let store = params.storeCache?.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.storeCache?.set(storePath, store); + } + const sessionEntry = findSessionEntryByKey(store, childSessionKey); + if (!sessionEntry) { + return "missing-session-entry"; + } + if (typeof sessionEntry.sessionId !== "string" || !sessionEntry.sessionId.trim()) { + return "missing-session-id"; + } + return null; + } catch { + // Best-effort guard: avoid false orphan pruning on transient read/config failures. + return null; + } +} + +function reconcileOrphanedRun(params: { + runId: string; + entry: SubagentRunRecord; + reason: SubagentRunOrphanReason; + source: "restore" | "resume"; +}) { + const now = Date.now(); + let changed = false; + if (typeof params.entry.endedAt !== "number") { + params.entry.endedAt = now; + changed = true; + } + const orphanOutcome: SubagentRunOutcome = { + status: "error", + error: `orphaned subagent run (${params.reason})`, + }; + if (!runOutcomesEqual(params.entry.outcome, orphanOutcome)) { + params.entry.outcome = orphanOutcome; + changed = true; + } + if (params.entry.endedReason !== SUBAGENT_ENDED_REASON_ERROR) { + params.entry.endedReason = SUBAGENT_ENDED_REASON_ERROR; + changed = true; + } + if (params.entry.cleanupHandled !== true) { + params.entry.cleanupHandled = true; + changed = true; + } + if (typeof params.entry.cleanupCompletedAt !== "number") { + params.entry.cleanupCompletedAt = now; + changed = true; + } + const removed = subagentRuns.delete(params.runId); + resumedRuns.delete(params.runId); + if (!removed && !changed) { + return false; + } + defaultRuntime.log( + `[warn] Subagent orphan run pruned source=${params.source} run=${params.runId} child=${params.entry.childSessionKey} reason=${params.reason}`, + ); + return true; +} + +function reconcileOrphanedRestoredRuns() { + const storeCache = new Map>(); + let changed = false; + for (const [runId, entry] of subagentRuns.entries()) { + const orphanReason = resolveSubagentRunOrphanReason({ + entry, + storeCache, + }); + if (!orphanReason) { + continue; + } + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "restore", + }) + ) { + changed = true; + } + } + return changed; +} + const resumedRuns = new Set(); const endedHookInFlightRunIds = new Set(); @@ -225,6 +345,20 @@ function resumeSubagentRun(runId: string) { if (!entry) { return; } + const orphanReason = resolveSubagentRunOrphanReason({ entry }); + if (orphanReason) { + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "resume", + }) + ) { + persistSubagentRuns(); + } + return; + } if (entry.cleanupCompletedAt) { return; } @@ -290,6 +424,12 @@ function restoreSubagentRunsOnce() { if (restoredCount === 0) { return; } + if (reconcileOrphanedRestoredRuns()) { + persistSubagentRuns(); + } + if (subagentRuns.size === 0) { + return; + } // Resume pending work. ensureListener(); if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d033c78bc3e7..7d4f672f2f1e 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -193,14 +193,22 @@ export async function spawnSubagentDirect( threadId: ctx.agentThreadId, }); const hookRunner = getGlobalHookRunner(); + const cfg = loadConfig(); + + // When agent omits runTimeoutSeconds, use the config default. + // Falls back to 0 (no timeout) if config key is also unset, + // preserving current behavior for existing deployments. + const cfgSubagentTimeout = + typeof cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" && + Number.isFinite(cfg.agents.defaults.subagents.runTimeoutSeconds) + ? Math.max(0, Math.floor(cfg.agents.defaults.subagents.runTimeoutSeconds)) + : 0; const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : 0; + : cfgSubagentTimeout; let modelApplied = false; let threadBindingReady = false; - - const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = ctx.agentSessionKey; const requesterInternalKey = requesterSessionKey diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fa6d4de65633..b45c64e72eca 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -108,7 +108,8 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).not.toContain("## Authorized Senders"); - expect(prompt).not.toContain("## Skills"); + // Skills are included even in minimal mode when skillsPrompt is provided (cron sessions need them) + expect(prompt).toContain("## Skills"); expect(prompt).not.toContain("## Memory Recall"); expect(prompt).not.toContain("## Documentation"); expect(prompt).not.toContain("## Reply Tags"); @@ -131,6 +132,29 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Subagent details"); }); + it("includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)", () => { + // Isolated cron sessions use promptMode="minimal" but must still receive skills. + const skillsPrompt = + "\n \n demo\n \n"; + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + skillsPrompt, + }); + + expect(prompt).toContain("## Skills (mandatory)"); + expect(prompt).toContain(""); + }); + + it("omits skills in minimal prompt mode when skillsPrompt is absent", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + }); + + expect(prompt).not.toContain("## Skills"); + }); + it("includes safety guardrails in full prompts", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 433deb7a66e8..69ac974ff336 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -17,14 +17,7 @@ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type PromptMode = "full" | "minimal" | "none"; type OwnerIdDisplay = "raw" | "hash"; -function buildSkillsSection(params: { - skillsPrompt?: string; - isMinimal: boolean; - readToolName: string; -}) { - if (params.isMinimal) { - return []; - } +function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) { const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { return []; @@ -401,7 +394,6 @@ export function buildAgentSystemPrompt(params: { ]; const skillsSection = buildSkillsSection({ skillsPrompt, - isMinimal, readToolName, }); const memorySection = buildMemorySection({ diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index b81d848919e9..1fb3dbff1ee7 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ label: "cron", description: "Schedule tasks", sectionId: "automation", - profiles: [], + profiles: ["coding"], includeInOpenClawGroup: true, }, { diff --git a/src/agents/tool-fs-policy.test.ts b/src/agents/tool-fs-policy.test.ts new file mode 100644 index 000000000000..e0fd6a953015 --- /dev/null +++ b/src/agents/tool-fs-policy.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveEffectiveToolFsWorkspaceOnly } from "./tool-fs-policy.js"; + +describe("resolveEffectiveToolFsWorkspaceOnly", () => { + it("returns false by default when tools.fs.workspaceOnly is unset", () => { + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg: {}, agentId: "main" })).toBe(false); + }); + + it("uses global tools.fs.workspaceOnly when no agent override exists", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: true } }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(true); + }); + + it("prefers agent-specific tools.fs.workspaceOnly override over global setting", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: true } }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: false }, + }, + }, + ], + }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(false); + }); + + it("supports agent-specific enablement when global workspaceOnly is off", () => { + const cfg: OpenClawConfig = { + tools: { fs: { workspaceOnly: false } }, + agents: { + list: [ + { + id: "main", + tools: { + fs: { workspaceOnly: true }, + }, + }, + ], + }, + }; + expect(resolveEffectiveToolFsWorkspaceOnly({ cfg, agentId: "main" })).toBe(true); + }); +}); diff --git a/src/agents/tool-fs-policy.ts b/src/agents/tool-fs-policy.ts index 20ce5a447a62..59d04c56e676 100644 --- a/src/agents/tool-fs-policy.ts +++ b/src/agents/tool-fs-policy.ts @@ -1,3 +1,6 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentConfig } from "./agent-scope.js"; + export type ToolFsPolicy = { workspaceOnly: boolean; }; @@ -7,3 +10,22 @@ export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsP workspaceOnly: params.workspaceOnly === true, }; } + +export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): { + workspaceOnly?: boolean; +} { + const cfg = params.cfg; + const globalFs = cfg?.tools?.fs; + const agentFs = + cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined; + return { + workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly, + }; +} + +export function resolveEffectiveToolFsWorkspaceOnly(params: { + cfg?: OpenClawConfig; + agentId?: string; +}): boolean { + return resolveToolFsConfig(params).workspaceOnly === true; +} diff --git a/src/agents/tool-policy.test.ts b/src/agents/tool-policy.test.ts index e2fe0a4d1123..9a9f512189b1 100644 --- a/src/agents/tool-policy.test.ts +++ b/src/agents/tool-policy.test.ts @@ -56,6 +56,8 @@ describe("tool-policy", () => { it("resolves known profiles and ignores unknown ones", () => { const coding = resolveToolProfilePolicy("coding"); expect(coding?.allow).toContain("read"); + expect(coding?.allow).toContain("cron"); + expect(coding?.allow).not.toContain("gateway"); expect(resolveToolProfilePolicy("nope")).toBeUndefined(); }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 3188d7dc1b80..c17ff9f9c488 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -482,6 +482,7 @@ export function createNodesTool(options?: { id: approvalId, command: cmdText, cwd, + nodeId, host: "node", agentId, sessionKey, diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 95e8e878bc7b..8c4960569ea0 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -7,6 +7,7 @@ const { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey, @@ -93,6 +94,28 @@ describe("web_search perplexity model normalization", () => { }); }); +describe("web_search brave language param normalization", () => { + it("normalizes and auto-corrects swapped Brave language params", () => { + expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ + search_lang: "tr", + ui_lang: "tr-TR", + }); + expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({ + search_lang: "en", + ui_lang: "en-US", + }); + }); + + it("flags invalid Brave language formats", () => { + expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({ + invalidField: "search_lang", + }); + expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({ + invalidField: "ui_lang", + }); + }); +}); + describe("web_search freshness normalization", () => { it("accepts Brave shortcut values", () => { expect(normalizeFreshness("pd")).toBe("pd"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 2f9d73beb6e0..db0919e5a44f 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; -import { defaultRuntime } from "../../runtime.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -43,6 +43,8 @@ const KIMI_WEB_SEARCH_TOOL = { const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; +const BRAVE_SEARCH_LANG_CODE = /^[a-z]{2}$/i; +const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const; const WebSearchSchema = Type.Object({ @@ -62,12 +64,14 @@ const WebSearchSchema = Type.Object({ ), search_lang: Type.Optional( Type.String({ - description: "ISO language code for search results (e.g., 'de', 'en', 'fr').", + description: + "Short ISO language code for search results (e.g., 'de', 'en', 'fr', 'tr'). Must be a 2-letter code, NOT a locale.", }), ), ui_lang: Type.Optional( Type.String({ - description: "ISO language code for UI elements.", + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", }), ), freshness: Type.Optional( @@ -382,7 +386,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE if (raw === "") { // 1. Brave if (resolveSearchApiKey(search)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "brave" from available API keys', ); return "brave"; @@ -390,7 +394,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 2. Gemini const geminiConfig = resolveGeminiConfig(search); if (resolveGeminiApiKey(geminiConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "gemini" from available API keys', ); return "gemini"; @@ -398,7 +402,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 3. Kimi const kimiConfig = resolveKimiConfig(search); if (resolveKimiApiKey(kimiConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "kimi" from available API keys', ); return "kimi"; @@ -407,7 +411,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "perplexity" from available API keys', ); return "perplexity"; @@ -415,7 +419,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // 5. Grok const grokConfig = resolveGrokConfig(search); if (resolveGrokApiKey(grokConfig)) { - defaultRuntime.log( + logVerbose( 'web_search: no provider configured, auto-detected "grok" from available API keys', ); return "grok"; @@ -790,6 +794,62 @@ function resolveSearchCount(value: unknown, fallback: number): number { return clamped; } +function normalizeBraveSearchLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || !BRAVE_SEARCH_LANG_CODE.test(trimmed)) { + return undefined; + } + return trimmed.toLowerCase(); +} + +function normalizeBraveUiLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(BRAVE_UI_LANG_LOCALE); + if (!match) { + return undefined; + } + const [, language, region] = match; + return `${language.toLowerCase()}-${region.toUpperCase()}`; +} + +function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { + search_lang?: string; + ui_lang?: string; + invalidField?: "search_lang" | "ui_lang"; +} { + const rawSearchLang = params.search_lang?.trim() || undefined; + const rawUiLang = params.ui_lang?.trim() || undefined; + let searchLangCandidate = rawSearchLang; + let uiLangCandidate = rawUiLang; + + // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. + if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { + searchLangCandidate = rawUiLang; + uiLangCandidate = rawSearchLang; + } + + const search_lang = normalizeBraveSearchLang(searchLangCandidate); + if (searchLangCandidate && !search_lang) { + return { invalidField: "search_lang" }; + } + + const ui_lang = normalizeBraveUiLang(uiLangCandidate); + if (uiLangCandidate && !ui_lang) { + return { invalidField: "ui_lang" }; + } + + return { search_lang, ui_lang }; +} + function normalizeFreshness(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -1455,8 +1515,29 @@ export function createWebSearchTool(options?: { const count = readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; const country = readStringParam(params, "country"); - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); + const rawSearchLang = readStringParam(params, "search_lang"); + const rawUiLang = readStringParam(params, "ui_lang"); + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ search_lang: rawSearchLang, ui_lang: rawUiLang }) + : { search_lang: rawSearchLang, ui_lang: rawUiLang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + return jsonResult({ + error: "invalid_search_lang", + message: + "search_lang must be a 2-letter ISO language code like 'en' (not a locale like 'en-US').", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return jsonResult({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const search_lang = normalizedBraveLanguageParams.search_lang; + const ui_lang = normalizedBraveLanguageParams.ui_lang; const rawFreshness = readStringParam(params, "freshness"); if (rawFreshness && provider !== "brave" && provider !== "perplexity") { return jsonResult({ @@ -1510,6 +1591,7 @@ export const __testing = { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 0ffe8b586911..b129581f5a0a 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -128,7 +128,7 @@ describe("web_search country and language parameters", () => { it.each([ { key: "country", value: "DE" }, { key: "search_lang", value: "de" }, - { key: "ui_lang", value: "de" }, + { key: "ui_lang", value: "de-DE" }, { key: "freshness", value: "pw" }, ])("passes $key parameter to Brave API", async ({ key, value }) => { const url = await runBraveSearchAndGetUrl({ [key]: value }); diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 4ef038c81b73..5f7d151ee9ad 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -53,6 +53,19 @@ describe("resolveTranscriptPolicy", () => { expect(policy.validateAnthropicTurns).toBe(true); }); + it("enables Anthropic-compatible policies for Bedrock provider", () => { + const policy = resolveTranscriptPolicy({ + provider: "amazon-bedrock", + modelId: "us.anthropic.claude-opus-4-6-v1", + modelApi: "bedrock-converse-stream", + }); + expect(policy.repairToolUseResultPairing).toBe(true); + expect(policy.validateAnthropicTurns).toBe(true); + expect(policy.allowSyntheticToolResults).toBe(true); + expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.sanitizeMode).toBe("full"); + }); + it("keeps OpenRouter on its existing turn-validation path", () => { const policy = resolveTranscriptPolicy({ provider: "openrouter", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 0672bf1e8409..baa12eda96ab 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -55,12 +55,12 @@ function isOpenAiProvider(provider?: string | null): boolean { } function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { - if (modelApi === "anthropic-messages") { + if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") { return true; } const normalized = normalizeProviderId(provider ?? ""); // MiniMax now uses openai-completions API, not anthropic-messages - return normalized === "anthropic"; + return normalized === "anthropic" || normalized === "amazon-bedrock"; } function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { @@ -108,7 +108,10 @@ export function resolveTranscriptPolicy(params: { : sanitizeToolCallIds ? "strict" : undefined; - const repairToolUseResultPairing = isGoogle || isAnthropic; + // All providers need orphaned tool_result repair after history truncation. + // OpenAI rejects function_call_output items whose call_id has no matching + // function_call in the conversation, so the repair must run universally. + const repairToolUseResultPairing = true; const sanitizeThoughtSignatures = isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; @@ -116,7 +119,7 @@ export function resolveTranscriptPolicy(params: { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, toolCallIdMode, - repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, + repairToolUseResultPairing, preserveSignatures: false, sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts index 8c12c395d456..ade9e151d8da 100644 --- a/src/agents/usage.test.ts +++ b/src/agents/usage.test.ts @@ -54,6 +54,40 @@ describe("normalizeUsage", () => { }); }); + it("handles Moonshot/Kimi cached_tokens field", () => { + // Moonshot v1 returns cached_tokens instead of cache_read_input_tokens + const usage = normalizeUsage({ + prompt_tokens: 30, + completion_tokens: 9, + total_tokens: 39, + cached_tokens: 19, + }); + expect(usage).toEqual({ + input: 30, + output: 9, + cacheRead: 19, + cacheWrite: undefined, + total: 39, + }); + }); + + it("handles Kimi K2 prompt_tokens_details.cached_tokens field", () => { + // Kimi K2 uses automatic prefix caching and returns cached_tokens in prompt_tokens_details + const usage = normalizeUsage({ + prompt_tokens: 1113, + completion_tokens: 5, + total_tokens: 1118, + prompt_tokens_details: { cached_tokens: 1024 }, + }); + expect(usage).toEqual({ + input: 1113, + output: 5, + cacheRead: 1024, + cacheWrite: undefined, + total: 1118, + }); + }); + it("returns undefined when no valid fields are provided", () => { const usage = normalizeUsage(null); expect(usage).toBeUndefined(); diff --git a/src/agents/usage.ts b/src/agents/usage.ts index eaf48d5f1ac7..be23df971166 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -15,6 +15,10 @@ export type UsageLike = { completion_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; + // Moonshot/Kimi uses cached_tokens for cache read count (explicit caching API). + cached_tokens?: number; + // Kimi K2 uses prompt_tokens_details.cached_tokens for automatic prefix caching. + prompt_tokens_details?: { cached_tokens?: number }; // Some agents/logs emit alternate naming. totalTokens?: number; total_tokens?: number; @@ -64,7 +68,13 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi raw.completionTokens ?? raw.completion_tokens, ); - const cacheRead = asFiniteNumber(raw.cacheRead ?? raw.cache_read ?? raw.cache_read_input_tokens); + const cacheRead = asFiniteNumber( + raw.cacheRead ?? + raw.cache_read ?? + raw.cache_read_input_tokens ?? + raw.cached_tokens ?? + raw.prompt_tokens_details?.cached_tokens, + ); const cacheWrite = asFiniteNumber( raw.cacheWrite ?? raw.cache_write ?? raw.cache_creation_input_tokens, ); diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2fef954c1f76..0c8541789177 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -11,8 +11,10 @@ import { DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, + filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles, resolveDefaultAgentWorkspaceDir, + type WorkspaceBootstrapFile, } from "./workspace.js"; describe("resolveDefaultAgentWorkspaceDir", () => { @@ -141,3 +143,52 @@ describe("loadWorkspaceBootstrapFiles", () => { expect(getMemoryEntries(files)).toHaveLength(0); }); }); + +describe("filterBootstrapFilesForSession", () => { + const mockFiles: WorkspaceBootstrapFile[] = [ + { name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false }, + { name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false }, + { name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false }, + { name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false }, + { name: "USER.md", path: "/w/USER.md", content: "", missing: false }, + { name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false }, + { name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false }, + { name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false }, + ]; + + it("returns all files for main session (no sessionKey)", () => { + const result = filterBootstrapFilesForSession(mockFiles); + expect(result).toHaveLength(mockFiles.length); + }); + + it("returns all files for normal (non-subagent, non-cron) session key", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:chat:main"); + expect(result).toHaveLength(mockFiles.length); + }); + + it("filters to allowlist for subagent sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); + + it("filters to allowlist for cron sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index c0bd5d63386b..dbef9c6517da 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -494,7 +494,13 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise { installDirectiveBehaviorE2EHooks(); - it("shows /think defaults for reasoning and non-reasoning models", async () => { + it("covers /think status and reasoning defaults for reasoning and non-reasoning models", async () => { await withTempHome(async (home) => { await expectThinkStatusForReasoningModel({ home, @@ -125,6 +125,25 @@ describe("directive behavior", () => { expectedLevel: "off", }); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + vi.mocked(runEmbeddedPiAgent).mockClear(); + + for (const scenario of [ + { + expectedThinkLevel: "low" as const, + expectedReasoningLevel: "off" as const, + }, + { + expectedThinkLevel: "off" as const, + expectedReasoningLevel: "on" as const, + thinkingDefault: "off" as const, + }, + ]) { + await runReasoningDefaultCase({ + home, + ...scenario, + }); + } }); }); it("renders model list and status variants across catalog/config combinations", async () => { @@ -282,26 +301,6 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); - it("applies reasoning defaults based on thinkingDefault configuration", async () => { - await withTempHome(async (home) => { - for (const scenario of [ - { - expectedThinkLevel: "low" as const, - expectedReasoningLevel: "off" as const, - }, - { - expectedThinkLevel: "off" as const, - expectedReasoningLevel: "on" as const, - thinkingDefault: "off" as const, - }, - ]) { - await runReasoningDefaultCase({ - home, - ...scenario, - }); - } - }); - }); it("passes elevated defaults when sender is approved", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index f5bca4b677aa..68bb923fd163 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -122,25 +122,84 @@ describe("abort detection", () => { expect(result.triggerBodyNormalized).toBe("/stop"); }); - it("isAbortTrigger matches bare word triggers (without slash)", () => { - expect(isAbortTrigger("stop")).toBe(true); - expect(isAbortTrigger("esc")).toBe(true); - expect(isAbortTrigger("abort")).toBe(true); - expect(isAbortTrigger("wait")).toBe(true); - expect(isAbortTrigger("exit")).toBe(true); - expect(isAbortTrigger("interrupt")).toBe(true); + it("isAbortTrigger matches standalone abort trigger phrases", () => { + const positives = [ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "do not do that", + "please stop", + "stop please", + "STOP OPENCLAW", + "stop openclaw!!!", + "stop don’t do anything", + "detente", + "detén", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", + ]; + for (const candidate of positives) { + expect(isAbortTrigger(candidate)).toBe(true); + } + expect(isAbortTrigger("hello")).toBe(false); - // /stop is NOT matched by isAbortTrigger - it's handled separately + expect(isAbortTrigger("please do not do that")).toBe(false); + // /stop is NOT matched by isAbortTrigger - it's handled separately. expect(isAbortTrigger("/stop")).toBe(false); }); it("isAbortRequestText aligns abort command semantics", () => { expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("/STOP")).toBe(true); + expect(isAbortRequestText("/stop!!!")).toBe(true); + expect(isAbortRequestText("/Stop!!!")).toBe(true); expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("Stop")).toBe(true); + expect(isAbortRequestText("STOP")).toBe(true); + expect(isAbortRequestText("stop action")).toBe(true); + expect(isAbortRequestText("stop openclaw!!!")).toBe(true); + expect(isAbortRequestText("やめて")).toBe(true); + expect(isAbortRequestText("остановись")).toBe(true); + expect(isAbortRequestText("halt")).toBe(true); + expect(isAbortRequestText("stopp")).toBe(true); + expect(isAbortRequestText("pare")).toBe(true); + expect(isAbortRequestText(" توقف ")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); + expect(isAbortRequestText("/Stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); - expect(isAbortRequestText("stop please")).toBe(false); + expect(isAbortRequestText("do not do that")).toBe(true); + expect(isAbortRequestText("please do not do that")).toBe(false); expect(isAbortRequestText("/abort")).toBe(false); }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 4cb894830779..3c05fa097b16 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -23,15 +23,69 @@ import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; -const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interrupt"]); +const ABORT_TRIGGERS = new Set([ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "detente", + "deten", + "detén", + "arrete", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "do not do that", + "please stop", + "stop please", +]); const ABORT_MEMORY = new Map(); const ABORT_MEMORY_MAX = 2000; +const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; + +function normalizeAbortTriggerText(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[’`]/g, "'") + .replace(/\s+/g, " ") + .replace(TRAILING_ABORT_PUNCTUATION_RE, "") + .trim(); +} export function isAbortTrigger(text?: string): boolean { if (!text) { return false; } - const normalized = text.trim().toLowerCase(); + const normalized = normalizeAbortTriggerText(text); return ABORT_TRIGGERS.has(normalized); } @@ -43,7 +97,12 @@ export function isAbortRequestText(text?: string, options?: CommandNormalizeOpti if (!normalized) { return false; } - return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized); + const normalizedLower = normalized.toLowerCase(); + return ( + normalizedLower === "/stop" || + normalizeAbortTriggerText(normalizedLower) === "/stop" || + isAbortTrigger(normalizedLower) + ); } export function getAbortMemory(key: string): boolean | undefined { diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 88f7d41a4c93..9b62db984e8e 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -71,4 +71,41 @@ describe("buildReplyPayloads media filter integration", () => { expect(replyPayloads).toHaveLength(1); expect(replyPayloads[0]?.mediaUrl).toBe("file:///tmp/photo.jpg"); }); + + it("suppresses same-target replies when messageProvider is synthetic but originatingChannel is set", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "telegram", + originatingTo: "268300329", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], + }); + + expect(replyPayloads).toHaveLength(0); + }); + + it("does not suppress same-target replies when accountId differs", () => { + const { replyPayloads } = buildReplyPayloads({ + ...baseParams, + payloads: [{ text: "hello world!" }], + messageProvider: "heartbeat", + originatingChannel: "telegram", + originatingTo: "268300329", + accountId: "personal", + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [ + { + tool: "telegram", + provider: "telegram", + to: "268300329", + accountId: "work", + }, + ], + }); + + expect(replyPayloads).toHaveLength(1); + expect(replyPayloads[0]?.text).toBe("hello world!"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index a1de8c1d163d..38737171c35f 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -6,6 +6,11 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js"; import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; import { normalizeReplyPayloadDirectives } from "./reply-delivery.js"; import { applyReplyThreading, @@ -32,6 +37,7 @@ export function buildReplyPayloads(params: { messagingToolSentTargets?: Parameters< typeof shouldSuppressMessagingToolReplies >[0]["messagingToolSentTargets"]; + originatingChannel?: OriginatingChannelType; originatingTo?: string; accountId?: string; }): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } { @@ -86,10 +92,17 @@ export function buildReplyPayloads(params: { const messagingToolSentTexts = params.messagingToolSentTexts ?? []; const messagingToolSentTargets = params.messagingToolSentTargets ?? []; const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: params.messageProvider, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.originatingChannel, + provider: params.messageProvider, + }), messagingToolSentTargets, - originatingTo: params.originatingTo, - accountId: params.accountId, + originatingTo: resolveOriginMessageTo({ + originatingTo: params.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: params.accountId, + }), }); // Only dedupe against messaging tool sends for the same origin target. // Cross-target sends (for example posting to another channel) must not diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 0650f5d65200..350c6b63e47b 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -2,19 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FollowupRun } from "./queue.js"; const hoisted = vi.hoisted(() => { - const resolveAgentModelFallbacksOverrideMock = vi.fn(); - const resolveAgentIdFromSessionKeyMock = vi.fn(); - return { resolveAgentModelFallbacksOverrideMock, resolveAgentIdFromSessionKeyMock }; + const resolveRunModelFallbacksOverrideMock = vi.fn(); + return { resolveRunModelFallbacksOverrideMock }; }); vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentModelFallbacksOverride: (...args: unknown[]) => - hoisted.resolveAgentModelFallbacksOverrideMock(...args), -})); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentIdFromSessionKey: (...args: unknown[]) => - hoisted.resolveAgentIdFromSessionKeyMock(...args), + resolveRunModelFallbacksOverride: (...args: unknown[]) => + hoisted.resolveRunModelFallbacksOverrideMock(...args), })); const { @@ -50,22 +44,20 @@ function makeRun(overrides: Partial = {}): FollowupRun["run" describe("agent-runner-utils", () => { beforeEach(() => { - hoisted.resolveAgentModelFallbacksOverrideMock.mockClear(); - hoisted.resolveAgentIdFromSessionKeyMock.mockClear(); + hoisted.resolveRunModelFallbacksOverrideMock.mockClear(); }); it("resolves model fallback options from run context", () => { - hoisted.resolveAgentIdFromSessionKeyMock.mockReturnValue("agent-id"); - hoisted.resolveAgentModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); const run = makeRun(); const resolved = resolveModelFallbackOptions(run); - expect(hoisted.resolveAgentIdFromSessionKeyMock).toHaveBeenCalledWith(run.sessionKey); - expect(hoisted.resolveAgentModelFallbacksOverrideMock).toHaveBeenCalledWith( - run.config, - "agent-id", - ); + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }); expect(resolved).toEqual({ cfg: run.config, provider: run.provider, @@ -75,6 +67,20 @@ describe("agent-runner-utils", () => { }); }); + it("passes through missing agentId for helper-based fallback resolution", () => { + hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]); + const run = makeRun({ agentId: undefined }); + + const resolved = resolveModelFallbackOptions(run); + + expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({ + cfg: run.config, + agentId: undefined, + sessionKey: run.sessionKey, + }); + expect(resolved.fallbacksOverride).toEqual(["fallback-model"]); + }); + it("builds embedded run base params with auth profile and run metadata", () => { const run = makeRun({ enforceFinalTag: true }); const authProfile = resolveProviderScopedAuthProfile({ @@ -149,4 +155,22 @@ describe("agent-runner-utils", () => { senderE164: undefined, }); }); + + it("prefers OriginatingChannel over Provider for messageProvider", () => { + const run = makeRun(); + + const resolved = buildEmbeddedRunContexts({ + run, + sessionCtx: { + Provider: "heartbeat", + OriginatingChannel: "Telegram", + OriginatingTo: "268300329", + }, + hasRepliedRef: undefined, + provider: "openai", + }); + + expect(resolved.embeddedContext.messageProvider).toBe("telegram"); + expect(resolved.embeddedContext.messageTo).toBe("268300329"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 994a74db8362..ca52a2e5ad54 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,14 +1,14 @@ -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentIdFromSessionKey } from "../../config/sessions.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import type { TemplateContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; +import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import type { FollowupRun } from "./queue.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; @@ -161,10 +161,11 @@ export function resolveModelFallbackOptions(run: FollowupRun["run"]) { provider: run.provider, model: run.model, agentDir: run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride( - run.config, - resolveAgentIdFromSessionKey(run.sessionKey), - ), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: run.config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }), }; } @@ -206,9 +207,15 @@ export function buildEmbeddedContextFromTemplate(params: { sessionId: params.run.sessionId, sessionKey: params.run.sessionKey, agentId: params.run.agentId, - messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.sessionCtx.OriginatingChannel, + provider: params.sessionCtx.Provider, + }), agentAccountId: params.sessionCtx.AccountId, - messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, + messageTo: resolveOriginMessageTo({ + originatingTo: params.sessionCtx.OriginatingTo, + to: params.sessionCtx.To, + }), messageThreadId: params.sessionCtx.MessageThreadId ?? undefined, // Provider threading context for tool auto-injection ...buildThreadingToolContext({ diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 3590a624ce81..52d1e4550c20 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -8,7 +8,7 @@ import type { TypingMode } from "../../config/types.js"; import { withStateDirEnv } from "../../test-helpers/state-dir-env.js"; import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; +import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; type AgentRunParams = { @@ -86,6 +86,7 @@ beforeAll(async () => { beforeEach(() => { state.runEmbeddedPiAgentMock.mockClear(); state.runCliAgentMock.mockClear(); + vi.mocked(enqueueFollowupRun).mockClear(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); }); @@ -98,6 +99,9 @@ function createMinimalRun(params?: { storePath?: string; typingMode?: TypingMode; blockStreamingEnabled?: boolean; + isActive?: boolean; + shouldFollowup?: boolean; + resolvedQueueMode?: string; runOverrides?: Partial; }) { const typing = createMockTypingController(); @@ -106,7 +110,9 @@ function createMinimalRun(params?: { Provider: "whatsapp", MessageSid: "msg", } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const resolvedQueue = { + mode: params?.resolvedQueueMode ?? "interrupt", + } as unknown as QueueSettings; const sessionKey = params?.sessionKey ?? "main"; const followupRun = { prompt: "hello", @@ -147,8 +153,8 @@ function createMinimalRun(params?: { queueKey: "main", resolvedQueue, shouldSteer: false, - shouldFollowup: false, - isActive: false, + shouldFollowup: params?.shouldFollowup ?? false, + isActive: params?.isActive ?? false, isStreaming: false, opts, typing, @@ -274,6 +280,39 @@ async function runReplyAgentWithBase(params: { }); } +describe("runReplyAgent heartbeat followup guard", () => { + it("drops heartbeat runs when another run is active", async () => { + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledTimes(1); + }); + + it("still enqueues non-heartbeat runs when another run is active", async () => { + const { run } = createMinimalRun({ + opts: { isHeartbeat: false }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); +}); + describe("runReplyAgent typing (heartbeat)", () => { async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { return await withStateDirEnv( diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b00dcd969f8a..8628fe33a514 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -43,6 +43,7 @@ import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.j import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js"; import { resolveBlockStreamingCoalescing } from "./block-streaming.js"; import { createFollowupRunner } from "./followup-runner.js"; +import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import { auditPostCompactionReads, extractReadPaths, @@ -50,6 +51,7 @@ import { readSessionMessages, } from "./post-compaction-audit.js"; import { readPostCompactionContext } from "./post-compaction-context.js"; +import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; @@ -179,11 +181,10 @@ export async function runReplyAgent(params: { const pendingToolTasks = new Set>(); const blockReplyTimeoutMs = opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS; - const replyToChannel = - sessionCtx.OriginatingChannel ?? - ((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as - | OriginatingChannelType - | undefined); + const replyToChannel = resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Surface ?? sessionCtx.Provider, + }) as OriginatingChannelType | undefined; const replyToMode = resolveReplyToMode( followupRun.run.config, replyToChannel, @@ -235,7 +236,19 @@ export async function runReplyAgent(params: { } } - if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) { + const activeRunQueueAction = resolveActiveRunQueueAction({ + isActive, + isHeartbeat, + shouldFollowup, + queueMode: resolvedQueue.mode, + }); + + if (activeRunQueueAction === "drop") { + typing.cleanup(); + return undefined; + } + + if (activeRunQueueAction === "enqueue-followup") { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); await touchActiveSessionEntry(); typing.cleanup(); @@ -514,7 +527,11 @@ export async function runReplyAgent(params: { messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls, messagingToolSentTargets: runResult.messagingToolSentTargets, - originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To, + originatingChannel: sessionCtx.OriginatingChannel, + originatingTo: resolveOriginMessageTo({ + originatingTo: sessionCtx.OriginatingTo, + to: sessionCtx.To, + }), accountId: sessionCtx.AccountId, }); const { replyPayloads } = payloadResult; diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 40f1d49e75b8..229cf7f9eb10 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -39,6 +39,96 @@ import { routeReply } from "./route-reply.js"; let HANDLERS: CommandHandler[] | null = null; +export type ResetCommandAction = "new" | "reset"; + +export async function emitResetCommandHooks(params: { + action: ResetCommandAction; + ctx: HandleCommandsParams["ctx"]; + cfg: HandleCommandsParams["cfg"]; + command: Pick< + HandleCommandsParams["command"], + "surface" | "senderId" | "channel" | "from" | "to" | "resetHookTriggered" + >; + sessionKey?: string; + sessionEntry?: HandleCommandsParams["sessionEntry"]; + previousSessionEntry?: HandleCommandsParams["previousSessionEntry"]; + workspaceDir: string; +}): Promise { + const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", { + sessionEntry: params.sessionEntry, + previousSessionEntry: params.previousSessionEntry, + commandSource: params.command.surface, + senderId: params.command.senderId, + cfg: params.cfg, // Pass config for LLM slug generation + }); + await triggerInternalHook(hookEvent); + params.command.resetHookTriggered = true; + + // Send hook messages immediately if present + if (hookEvent.messages.length > 0) { + // Use OriginatingChannel/To if available, otherwise fall back to command channel/from + // oxlint-disable-next-line typescript/no-explicit-any + const channel = params.ctx.OriginatingChannel || (params.command.channel as any); + // For replies, use 'from' (the sender) not 'to' (which might be the bot itself) + const to = params.ctx.OriginatingTo || params.command.from || params.command.to; + + if (channel && to) { + const hookReply = { text: hookEvent.messages.join("\n\n") }; + await routeReply({ + payload: hookReply, + channel: channel, + to: to, + sessionKey: params.sessionKey, + accountId: params.ctx.AccountId, + threadId: params.ctx.MessageThreadId, + cfg: params.cfg, + }); + } + } + + // Fire before_reset plugin hook — extract memories before session history is lost + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("before_reset")) { + const prevEntry = params.previousSessionEntry; + const sessionFile = prevEntry?.sessionFile; + // Fire-and-forget: read old session messages and run hook + void (async () => { + try { + const messages: unknown[] = []; + if (sessionFile) { + const content = await fs.readFile(sessionFile, "utf-8"); + for (const line of content.split("\n")) { + if (!line.trim()) { + continue; + } + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message) { + messages.push(entry.message); + } + } catch { + // skip malformed lines + } + } + } else { + logVerbose("before_reset: no session file available, firing hook with empty messages"); + } + await hookRunner.runBeforeReset( + { sessionFile, messages, reason: params.action }, + { + agentId: params.sessionKey?.split(":")[0] ?? "main", + sessionKey: params.sessionKey, + sessionId: prevEntry?.sessionId, + workspaceDir: params.workspaceDir, + }, + ); + } catch (err: unknown) { + logVerbose(`before_reset hook failed: ${String(err)}`); + } + })(); + } +} + export async function handleCommands(params: HandleCommandsParams): Promise { if (HANDLERS === null) { HANDLERS = [ @@ -79,79 +169,17 @@ export async function handleCommands(params: HandleCommandsParams): Promise 0) { - // Use OriginatingChannel/To if available, otherwise fall back to command channel/from - // oxlint-disable-next-line typescript/no-explicit-any - const channel = params.ctx.OriginatingChannel || (params.command.channel as any); - // For replies, use 'from' (the sender) not 'to' (which might be the bot itself) - const to = params.ctx.OriginatingTo || params.command.from || params.command.to; - - if (channel && to) { - const hookReply = { text: hookEvent.messages.join("\n\n") }; - await routeReply({ - payload: hookReply, - channel: channel, - to: to, - sessionKey: params.sessionKey, - accountId: params.ctx.AccountId, - threadId: params.ctx.MessageThreadId, - cfg: params.cfg, - }); - } - } - - // Fire before_reset plugin hook — extract memories before session history is lost - const hookRunner = getGlobalHookRunner(); - if (hookRunner?.hasHooks("before_reset")) { - const prevEntry = params.previousSessionEntry; - const sessionFile = prevEntry?.sessionFile; - // Fire-and-forget: read old session messages and run hook - void (async () => { - try { - const messages: unknown[] = []; - if (sessionFile) { - const content = await fs.readFile(sessionFile, "utf-8"); - for (const line of content.split("\n")) { - if (!line.trim()) { - continue; - } - try { - const entry = JSON.parse(line); - if (entry.type === "message" && entry.message) { - messages.push(entry.message); - } - } catch { - // skip malformed lines - } - } - } else { - logVerbose("before_reset: no session file available, firing hook with empty messages"); - } - await hookRunner.runBeforeReset( - { sessionFile, messages, reason: commandAction }, - { - agentId: params.sessionKey?.split(":")[0] ?? "main", - sessionKey: params.sessionKey, - sessionId: prevEntry?.sessionId, - workspaceDir: params.workspaceDir, - }, - ); - } catch (err: unknown) { - logVerbose(`before_reset hook failed: ${String(err)}`); - } - })(); - } } const allowTextCommands = shouldHandleTextCommands({ diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts index 6ff476b8c20d..4662bf12a22b 100644 --- a/src/auto-reply/reply/commands-types.ts +++ b/src/auto-reply/reply/commands-types.ts @@ -20,6 +20,8 @@ export type CommandContext = { commandBodyNormalized: string; from?: string; to?: string; + /** Internal marker to prevent duplicate reset-hook emission across command pipelines. */ + resetHookTriggered?: boolean; }; export type HandleCommandsParams = { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0c4d40ec7eb0..921081921e06 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -890,6 +890,37 @@ describe("handleCommands hooks", () => { expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" })); spy.mockRestore(); }); + + it("triggers hooks for native /new routed to target sessions", async () => { + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/new", cfg, { + Provider: "telegram", + Surface: "telegram", + CommandSource: "native", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + SessionKey: "telegram:slash:123", + SenderId: "123", + From: "telegram:123", + To: "slash:123", + CommandAuthorized: true, + }); + params.sessionKey = "agent:main:telegram:direct:123"; + const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); + + await handleCommands(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "command", + action: "new", + sessionKey: "agent:main:telegram:direct:123", + }), + ); + spy.mockRestore(); + }); }); describe("handleCommands context", () => { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 2a69f506a7f5..aac29ce49df0 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -407,6 +407,8 @@ describe("dispatchReplyFromConfig", () => { SenderUsername: "alice", SenderE164: "+15555550123", AccountId: "acc-1", + GroupSpace: "guild-123", + GroupChannel: "alerts", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; @@ -425,6 +427,8 @@ describe("dispatchReplyFromConfig", () => { senderName: "Alice", senderUsername: "alice", senderE164: "+15555550123", + guildId: "guild-123", + channelName: "alerts", }), }), expect.objectContaining({ @@ -445,6 +449,8 @@ describe("dispatchReplyFromConfig", () => { SessionKey: "agent:main:main", CommandBody: "/help", MessageSid: "msg-42", + GroupSpace: "guild-456", + GroupChannel: "ops-room", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; @@ -459,6 +465,10 @@ describe("dispatchReplyFromConfig", () => { content: "/help", channelId: "telegram", messageId: "msg-42", + metadata: expect.objectContaining({ + guildId: "guild-456", + channelName: "ops-room", + }), }), ); expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); @@ -538,4 +548,47 @@ describe("dispatchReplyFromConfig", () => { }), ); }); + + it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const replyResolver = async () => + [ + { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls).toHaveLength(1); + expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" }); + }); + + it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + // Simulate block reply with reasoning payload + await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return { text: "The answer is 42" }; + }; + // Capture what actually gets dispatched as block replies + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).toContain("The answer is 42"); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e4e66c16a574..234ab1e5a0ed 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -17,6 +17,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; +import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i; @@ -186,6 +187,8 @@ export async function dispatchReplyFromConfig(params: { senderName: ctx.SenderName, senderUsername: ctx.SenderUsername, senderE164: ctx.SenderE164, + guildId: ctx.GroupSpace, + channelName: ctx.GroupChannel, }, }, { @@ -219,6 +222,8 @@ export async function dispatchReplyFromConfig(params: { senderName: ctx.SenderName, senderUsername: ctx.SenderUsername, senderE164: ctx.SenderE164, + guildId: ctx.GroupSpace, + channelName: ctx.GroupChannel, }, }), ).catch((err) => { @@ -363,6 +368,12 @@ export async function dispatchReplyFromConfig(params: { }, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Suppress reasoning payloads — channels using this generic dispatch + // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. + // Telegram has its own dispatch path that handles reasoning splitting. + if (shouldSuppressReasoningPayload(payload)) { + return; + } // Accumulate block text for TTS generation after streaming if (payload.text) { if (accumulatedBlockText.length > 0) { @@ -396,6 +407,11 @@ export async function dispatchReplyFromConfig(params: { let queuedFinal = false; let routedFinalCount = 0; for (const reply of replies) { + // Suppress reasoning payloads from channel delivery — channels using this + // generic dispatch path do not have a dedicated reasoning lane. + if (shouldSuppressReasoningPayload(reply)) { + continue; + } const ttsReply = await maybeApplyTtsToPayload({ payload: reply, cfg, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 0da9b1ff76d2..da5d55fa9dd1 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,12 +1,14 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js"; import type { FollowupRun } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); +const routeReplyMock = vi.fn(); +const isRoutableChannelMock = vi.fn(); vi.mock( "../../agents/model-fallback.js", @@ -17,8 +19,36 @@ vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), })); +vi.mock("./route-reply.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), + routeReply: (...args: unknown[]) => routeReplyMock(...args), + }; +}); + import { createFollowupRunner } from "./followup-runner.js"; +const ROUTABLE_TEST_CHANNELS = new Set([ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", +]); + +beforeEach(() => { + routeReplyMock.mockReset(); + routeReplyMock.mockResolvedValue({ ok: true }); + isRoutableChannelMock.mockReset(); + isRoutableChannelMock.mockImplementation((ch: string | undefined) => + Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())), + ); +}); + const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => ({ prompt: "hello", @@ -49,6 +79,20 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => }, }) as FollowupRun; +function createQueuedRun( + overrides: Partial> & { run?: Partial } = {}, +): FollowupRun { + const base = baseQueuedRun(); + return { + ...base, + ...overrides, + run: { + ...base.run, + ...overrides.run, + }, + }; +} + function mockCompactionRun(params: { willRetry: boolean; result: { @@ -100,32 +144,11 @@ describe("createFollowupRunner compaction", () => { defaultModel: "anthropic/claude-opus-4-5", }); - const queued = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), + const queued = createQueuedRun({ run: { - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", verboseLevel: "on", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", }, - } as FollowupRun; + }); await runner(queued); @@ -204,6 +227,56 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(onBlockReply).not.toHaveBeenCalled(); }); + it("suppresses replies when provider is synthetic but originating channel matches", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + } as FollowupRun); + + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("does not suppress replies for same target when account differs", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["different message"], + messagingToolSentTargets: [ + { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" }, + ], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + originatingAccountId: "personal", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "268300329", + accountId: "personal", + }), + ); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + it("drops media URL from payload when messaging tool already sent it", async () => { const onBlockReply = vi.fn(async () => {}); runEmbeddedPiAgentMock.mockResolvedValueOnce({ @@ -278,6 +351,81 @@ describe("createFollowupRunner messaging tool dedupe", () => { expect(store[sessionKey]?.inputTokens).toBe(1_000); expect(store[sessionKey]?.outputTokens).toBe(50); }); + + it("does not fall back to dispatcher when cross-channel origin routing fails", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + routeReplyMock.mockResolvedValueOnce({ + ok: false, + error: "forced route failure", + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalled(); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + + it("falls back to dispatcher when same-channel origin routing fails", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + routeReplyMock.mockResolvedValueOnce({ + ok: false, + error: "outbound adapter unavailable", + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun(" Feishu "), + originatingChannel: "FEISHU", + originatingTo: "ou_abc123", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply).toHaveBeenCalledWith(expect.objectContaining({ text: "hello world!" })); + }); + + it("routes followups with originating account/thread metadata", async () => { + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + + const runner = createMessagingDedupeRunner(onBlockReply); + + await runner({ + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "work", + originatingThreadId: "1739142736.000100", + } as FollowupRun); + + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + to: "channel:C1", + accountId: "work", + threadId: "1739142736.000100", + }), + ); + expect(onBlockReply).not.toHaveBeenCalled(); + }); }); describe("createFollowupRunner agentDir forwarding", () => { @@ -296,7 +444,7 @@ describe("createFollowupRunner agentDir forwarding", () => { defaultModel: "anthropic/claude-opus-4-5", }); const agentDir = path.join("/tmp", "agent-dir"); - const queued = baseQueuedRun(); + const queued = createQueuedRun(); await runner({ ...queued, run: { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index cdae8d014af5..0c91d543d916 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,10 +1,10 @@ import crypto from "node:crypto"; -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; -import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -14,6 +14,11 @@ import type { OriginatingChannelType } from "../templating.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { resolveRunAuthProfile } from "./agent-runner-utils.js"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; import type { FollowupRun } from "./queue.js"; import { applyReplyThreading, @@ -98,11 +103,21 @@ export function createFollowupRunner(params: { cfg: queued.run.config, }); if (!result.ok) { - // Log error and fall back to dispatcher if available. const errorMsg = result.error ?? "unknown error"; logVerbose(`followup queue: route-reply failed: ${errorMsg}`); - // Fallback: try the dispatcher if routing failed. - if (opts?.onBlockReply) { + // Fall back to the caller-provided dispatcher only when the + // originating channel matches the session's message provider. + // In that case onBlockReply was created by the same channel's + // handler and delivers to the correct destination. For true + // cross-channel routing (origin !== provider), falling back + // would send to the wrong channel, so we drop the payload. + const provider = resolveOriginMessageProvider({ + provider: queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ + originatingChannel, + }); + if (opts?.onBlockReply && origin && origin === provider) { await opts.onBlockReply(payload); } } @@ -131,10 +146,11 @@ export function createFollowupRunner(params: { provider: queued.run.provider, model: queued.run.model, agentDir: queued.run.agentDir, - fallbacksOverride: resolveAgentModelFallbacksOverride( - queued.run.config, - resolveAgentIdFromSessionKey(queued.run.sessionKey), - ), + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: queued.run.config, + agentId: queued.run.agentId, + sessionKey: queued.run.sessionKey, + }), run: (provider, model) => { const authProfile = resolveRunAuthProfile(queued.run, provider); return runEmbeddedPiAgent({ @@ -234,9 +250,10 @@ export function createFollowupRunner(params: { } return [{ ...payload, text: stripped.text }]; }); - const replyToChannel = - queued.originatingChannel ?? - (queued.run.messageProvider?.toLowerCase() as OriginatingChannelType | undefined); + const replyToChannel = resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: queued.run.messageProvider, + }) as OriginatingChannelType | undefined; const replyToMode = resolveReplyToMode( queued.run.config, replyToChannel, @@ -259,10 +276,18 @@ export function createFollowupRunner(params: { sentMediaUrls: runResult.messagingToolSentMediaUrls ?? [], }); const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: queued.run.messageProvider, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: queued.run.messageProvider, + }), messagingToolSentTargets: runResult.messagingToolSentTargets, - originatingTo: queued.originatingTo, - accountId: queued.run.agentAccountId, + originatingTo: resolveOriginMessageTo({ + originatingTo: queued.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: queued.originatingAccountId, + accountId: queued.run.agentAccountId, + }), }); const finalPayloads = suppressMessagingToolReplies ? [] : mediaFilteredPayloads; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index ad9448dce817..e4252fdf12ac 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -40,6 +40,7 @@ import type { InlineDirectives } from "./directive-handling.js"; import { buildGroupChatContext, buildGroupIntro } from "./groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import type { createModelSelectionState } from "./model-selection.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; import { resolveQueueSettings } from "./queue.js"; import { routeReply } from "./route-reply.js"; import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js"; @@ -466,7 +467,10 @@ export async function runPreparedReply( agentDir, sessionId: sessionIdFinal, sessionKey, - messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Provider, + }), agentAccountId: sessionCtx.AccountId, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(), diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts new file mode 100644 index 000000000000..3129bb61cbb3 --- /dev/null +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../templating.js"; + +const mocks = vi.hoisted(() => ({ + resolveReplyDirectives: vi.fn(), + handleInlineActions: vi.fn(), + emitResetCommandHooks: vi.fn(), + initSessionState: vi.fn(), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveSessionAgentId: vi.fn(() => "main"), + resolveAgentSkillsFilter: vi.fn(() => undefined), +})); +vi.mock("../../agents/model-selection.js", () => ({ + resolveModelRefFromString: vi.fn(() => null), +})); +vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 60000), +})); +vi.mock("../../agents/workspace.js", () => ({ + DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace", + ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })), +})); +vi.mock("../../channels/model-overrides.js", () => ({ + resolveChannelModelOverride: vi.fn(() => undefined), +})); +vi.mock("../../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../../link-understanding/apply.js", () => ({ + applyLinkUnderstanding: vi.fn(async () => undefined), +})); +vi.mock("../../media-understanding/apply.js", () => ({ + applyMediaUnderstanding: vi.fn(async () => undefined), +})); +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { log: vi.fn() }, +})); +vi.mock("../command-auth.js", () => ({ + resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), +})); +vi.mock("./commands-core.js", () => ({ + emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), +})); +vi.mock("./directive-handling.js", () => ({ + resolveDefaultModel: vi.fn(() => ({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: new Map(), + })), +})); +vi.mock("./get-reply-directives.js", () => ({ + resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), +})); +vi.mock("./get-reply-inline-actions.js", () => ({ + handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args), +})); +vi.mock("./get-reply-run.js", () => ({ + runPreparedReply: vi.fn(async () => undefined), +})); +vi.mock("./inbound-context.js", () => ({ + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), +})); +vi.mock("./session-reset-model.js", () => ({ + applyResetModelOverride: vi.fn(async () => undefined), +})); +vi.mock("./session.js", () => ({ + initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), +})); +vi.mock("./stage-sandbox-media.js", () => ({ + stageSandboxMedia: vi.fn(async () => undefined), +})); +vi.mock("./typing.js", () => ({ + createTypingController: vi.fn(() => ({ + onReplyStart: async () => undefined, + startTypingLoop: async () => undefined, + startTypingOnText: async () => undefined, + refreshTypingTtl: () => undefined, + isActive: () => false, + markRunComplete: () => undefined, + markDispatchIdle: () => undefined, + cleanup: () => undefined, + })), +})); + +const { getReplyFromConfig } = await import("./get-reply.js"); + +function buildNativeResetContext(): MsgContext { + return { + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: "agent:main:telegram:direct:123", + From: "telegram:123", + To: "slash:123", + }; +} + +describe("getReplyFromConfig reset-hook fallback", () => { + beforeEach(() => { + mocks.resolveReplyDirectives.mockReset(); + mocks.handleInlineActions.mockReset(); + mocks.emitResetCommandHooks.mockReset(); + mocks.initSessionState.mockReset(); + + mocks.initSessionState.mockResolvedValue({ + sessionCtx: buildNativeResetContext(), + sessionEntry: {}, + previousSessionEntry: {}, + sessionStore: {}, + sessionKey: "agent:main:telegram:direct:123", + sessionId: "session-1", + isNewSession: true, + resetTriggered: true, + systemSent: false, + abortedLastRun: false, + storePath: "/tmp/sessions.json", + sessionScope: "per-sender", + groupResolution: undefined, + isGroup: false, + triggerBodyNormalized: "/new", + bodyStripped: "", + }); + + mocks.resolveReplyDirectives.mockResolvedValue({ + kind: "continue", + result: { + commandSource: "/new", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "123", + abortKey: "telegram:slash:123", + rawBodyNormalized: "/new", + commandBodyNormalized: "/new", + from: "telegram:123", + to: "slash:123", + resetHookTriggered: false, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "/new", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }); + }); + + it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => { + mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined }); + + await getReplyFromConfig(buildNativeResetContext(), undefined, {}); + + expect(mocks.emitResetCommandHooks).toHaveBeenCalledTimes(1); + expect(mocks.emitResetCommandHooks).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + sessionKey: "agent:main:telegram:direct:123", + }), + ); + }); + + it("does not emit fallback hooks when resetHookTriggered is already set", async () => { + mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined }); + mocks.resolveReplyDirectives.mockResolvedValue({ + kind: "continue", + result: { + commandSource: "/new", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "123", + abortKey: "telegram:slash:123", + rawBodyNormalized: "/new", + commandBodyNormalized: "/new", + from: "telegram:123", + to: "slash:123", + resetHookTriggered: true, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "/new", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }); + + await getReplyFromConfig(buildNativeResetContext(), undefined, {}); + + expect(mocks.emitResetCommandHooks).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index bca4cb3ce8f9..5c4edd35ac1e 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -16,6 +16,7 @@ import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { emitResetCommandHooks, type ResetCommandAction } from "./commands-core.js"; import { resolveDefaultModel } from "./directive-handling.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; @@ -272,6 +273,27 @@ export async function getReplyFromConfig( provider = resolvedProvider; model = resolvedModel; + const maybeEmitMissingResetHooks = async () => { + if (!resetTriggered || !command.isAuthorizedSender || command.resetHookTriggered) { + return; + } + const resetMatch = command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); + if (!resetMatch) { + return; + } + const action: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new"; + await emitResetCommandHooks({ + action, + ctx, + cfg, + command, + sessionKey, + sessionEntry, + previousSessionEntry, + workspaceDir, + }); + }; + const inlineActionResult = await handleInlineActions({ ctx, sessionCtx, @@ -311,8 +333,10 @@ export async function getReplyFromConfig( skillFilter: mergedSkillFilter, }); if (inlineActionResult.kind === "reply") { + await maybeEmitMissingResetHooks(); return inlineActionResult.reply; } + await maybeEmitMissingResetHooks(); directives = inlineActionResult.directives; abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun; diff --git a/src/auto-reply/reply/origin-routing.test.ts b/src/auto-reply/reply/origin-routing.test.ts new file mode 100644 index 000000000000..c4d18762e37e --- /dev/null +++ b/src/auto-reply/reply/origin-routing.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; + +describe("origin-routing helpers", () => { + it("prefers originating channel over provider for message provider", () => { + const provider = resolveOriginMessageProvider({ + originatingChannel: "Telegram", + provider: "heartbeat", + }); + + expect(provider).toBe("telegram"); + }); + + it("falls back to provider when originating channel is missing", () => { + const provider = resolveOriginMessageProvider({ + provider: " Slack ", + }); + + expect(provider).toBe("slack"); + }); + + it("prefers originating destination over fallback destination", () => { + const to = resolveOriginMessageTo({ + originatingTo: "channel:C1", + to: "channel:C2", + }); + + expect(to).toBe("channel:C1"); + }); + + it("prefers originating account over fallback account", () => { + const accountId = resolveOriginAccountId({ + originatingAccountId: "work", + accountId: "personal", + }); + + expect(accountId).toBe("work"); + }); +}); diff --git a/src/auto-reply/reply/origin-routing.ts b/src/auto-reply/reply/origin-routing.ts new file mode 100644 index 000000000000..ce8936ab6596 --- /dev/null +++ b/src/auto-reply/reply/origin-routing.ts @@ -0,0 +1,29 @@ +import type { OriginatingChannelType } from "../templating.js"; + +function normalizeProviderValue(value?: string): string | undefined { + const normalized = value?.trim().toLowerCase(); + return normalized || undefined; +} + +export function resolveOriginMessageProvider(params: { + originatingChannel?: OriginatingChannelType; + provider?: string; +}): string | undefined { + return ( + normalizeProviderValue(params.originatingChannel) ?? normalizeProviderValue(params.provider) + ); +} + +export function resolveOriginMessageTo(params: { + originatingTo?: string; + to?: string; +}): string | undefined { + return params.originatingTo ?? params.to; +} + +export function resolveOriginAccountId(params: { + originatingAccountId?: string; + accountId?: string; +}): string | undefined { + return params.originatingAccountId ?? params.accountId; +} diff --git a/src/auto-reply/reply/queue-policy.test.ts b/src/auto-reply/reply/queue-policy.test.ts new file mode 100644 index 000000000000..265cc19ff9b6 --- /dev/null +++ b/src/auto-reply/reply/queue-policy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveActiveRunQueueAction } from "./queue-policy.js"; + +describe("resolveActiveRunQueueAction", () => { + it("runs immediately when there is no active run", () => { + expect( + resolveActiveRunQueueAction({ + isActive: false, + isHeartbeat: false, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("run-now"); + }); + + it("drops heartbeat runs while another run is active", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: true, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("drop"); + }); + + it("enqueues followups for non-heartbeat active runs", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: false, + shouldFollowup: true, + queueMode: "collect", + }), + ).toBe("enqueue-followup"); + }); + + it("enqueues steer mode runs while active", () => { + expect( + resolveActiveRunQueueAction({ + isActive: true, + isHeartbeat: false, + shouldFollowup: false, + queueMode: "steer", + }), + ).toBe("enqueue-followup"); + }); +}); diff --git a/src/auto-reply/reply/queue-policy.ts b/src/auto-reply/reply/queue-policy.ts new file mode 100644 index 000000000000..73fc48bdcc67 --- /dev/null +++ b/src/auto-reply/reply/queue-policy.ts @@ -0,0 +1,21 @@ +import type { QueueSettings } from "./queue.js"; + +export type ActiveRunQueueAction = "run-now" | "enqueue-followup" | "drop"; + +export function resolveActiveRunQueueAction(params: { + isActive: boolean; + isHeartbeat: boolean; + shouldFollowup: boolean; + queueMode: QueueSettings["mode"]; +}): ActiveRunQueueAction { + if (!params.isActive) { + return "run-now"; + } + if (params.isHeartbeat) { + return "drop"; + } + if (params.shouldFollowup || params.queueMode === "steer") { + return "enqueue-followup"; + } + return "run-now"; +} diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 75e6ffa07d8a..a048a4e89255 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -13,6 +13,39 @@ import { isRoutableChannel } from "../route-reply.js"; import { FOLLOWUP_QUEUES } from "./state.js"; import type { FollowupRun } from "./types.js"; +type OriginRoutingMetadata = Pick< + FollowupRun, + "originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId" +>; + +function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata { + return { + originatingChannel: items.find((item) => item.originatingChannel)?.originatingChannel, + originatingTo: items.find((item) => item.originatingTo)?.originatingTo, + originatingAccountId: items.find((item) => item.originatingAccountId)?.originatingAccountId, + // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. + originatingThreadId: items.find( + (item) => item.originatingThreadId != null && item.originatingThreadId !== "", + )?.originatingThreadId, + }; +} + +function resolveCrossChannelKey(item: FollowupRun): { cross?: true; key?: string } { + const { originatingChannel: channel, originatingTo: to, originatingAccountId: accountId } = item; + const threadId = item.originatingThreadId; + if (!channel && !to && !accountId && (threadId == null || threadId === "")) { + return {}; + } + if (!isRoutableChannel(channel) || !to) { + return { cross: true }; + } + // Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs. + const threadKey = threadId != null && threadId !== "" ? String(threadId) : ""; + return { + key: [channel, to, accountId || "", threadKey].join("|"), + }; +} + export function scheduleFollowupDrain( key: string, runFollowup: (run: FollowupRun) => Promise, @@ -33,23 +66,7 @@ export function scheduleFollowupDrain( // Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts` // Check if messages span multiple channels. // If so, process individually to preserve per-message routing. - const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { - const channel = item.originatingChannel; - const to = item.originatingTo; - const accountId = item.originatingAccountId; - const threadId = item.originatingThreadId; - if (!channel && !to && !accountId && (threadId == null || threadId === "")) { - return {}; - } - if (!isRoutableChannel(channel) || !to) { - return { cross: true }; - } - // Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs. - const threadKey = threadId != null && threadId !== "" ? String(threadId) : ""; - return { - key: [channel, to, accountId || "", threadKey].join("|"), - }; - }); + const isCrossChannel = hasCrossChannelItems(queue.items, resolveCrossChannelKey); const collectDrainResult = await drainCollectQueueStep({ collectState, @@ -71,16 +88,7 @@ export function scheduleFollowupDrain( break; } - // Preserve originating channel from items when collecting same-channel. - const originatingChannel = items.find((i) => i.originatingChannel)?.originatingChannel; - const originatingTo = items.find((i) => i.originatingTo)?.originatingTo; - const originatingAccountId = items.find( - (i) => i.originatingAccountId, - )?.originatingAccountId; - // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. - const originatingThreadId = items.find( - (i) => i.originatingThreadId != null && i.originatingThreadId !== "", - )?.originatingThreadId; + const routing = resolveOriginRoutingMetadata(items); const prompt = buildCollectPrompt({ title: "[Queued messages while agent was busy]", @@ -92,10 +100,7 @@ export function scheduleFollowupDrain( prompt, run, enqueuedAt: Date.now(), - originatingChannel, - originatingTo, - originatingAccountId, - originatingThreadId, + ...routing, }); queue.items.splice(0, items.length); if (summary) { @@ -111,11 +116,15 @@ export function scheduleFollowupDrain( break; } if ( - !(await drainNextQueueItem(queue.items, async () => { + !(await drainNextQueueItem(queue.items, async (item) => { await runFollowup({ prompt: summaryPrompt, run, enqueuedAt: Date.now(), + originatingChannel: item.originatingChannel, + originatingTo: item.originatingTo, + originatingAccountId: item.originatingAccountId, + originatingThreadId: item.originatingThreadId, }); })) ) { diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index bfc5fa20f0f5..5c015dcd5577 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import type { TypingCallbacks } from "../../channels/typing.js"; import type { HumanDelayConfig } from "../../config/types.js"; import { sleep } from "../../utils.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -57,6 +58,7 @@ export type ReplyDispatcherOptions = { }; export type ReplyDispatcherWithTypingOptions = Omit & { + typingCallbacks?: TypingCallbacks; onReplyStart?: () => Promise | void; onIdle?: () => void; /** Called when the typing controller is cleaned up (e.g., on NO_REPLY). */ @@ -209,28 +211,31 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis export function createReplyDispatcherWithTyping( options: ReplyDispatcherWithTypingOptions, ): ReplyDispatcherWithTypingResult { - const { onReplyStart, onIdle, onCleanup, ...dispatcherOptions } = options; + const { typingCallbacks, onReplyStart, onIdle, onCleanup, ...dispatcherOptions } = options; + const resolvedOnReplyStart = onReplyStart ?? typingCallbacks?.onReplyStart; + const resolvedOnIdle = onIdle ?? typingCallbacks?.onIdle; + const resolvedOnCleanup = onCleanup ?? typingCallbacks?.onCleanup; let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ ...dispatcherOptions, onIdle: () => { typingController?.markDispatchIdle(); - onIdle?.(); + resolvedOnIdle?.(); }, }); return { dispatcher, replyOptions: { - onReplyStart, - onTypingCleanup: onCleanup, + onReplyStart: resolvedOnReplyStart, + onTypingCleanup: resolvedOnCleanup, onTypingController: (typing) => { typingController = typing; }, }, markDispatchIdle: () => { typingController?.markDispatchIdle(); - onIdle?.(); + resolvedOnIdle?.(); }, }; } diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 3f79e3e68033..03ff953be7c3 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1046,6 +1046,54 @@ describe("followup queue collect routing", () => { expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[0]?.prompt).toContain("- first"); }); + + it("preserves routing metadata on overflow summary followups", async () => { + const key = `test-overflow-summary-routing-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + done.resolve(); + }; + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "first", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "work", + originatingThreadId: "1739142736.000100", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "second", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "work", + originatingThreadId: "1739142736.000100", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.originatingChannel).toBe("discord"); + expect(calls[0]?.originatingTo).toBe("channel:C1"); + expect(calls[0]?.originatingAccountId).toBe("work"); + expect(calls[0]?.originatingThreadId).toBe("1739142736.000100"); + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + }); }); const emptyCfg = {} as OpenClawConfig; diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 41906f1227f6..a408e942a2d3 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -68,6 +68,10 @@ export function isRenderablePayload(payload: ReplyPayload): boolean { ); } +export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { + return payload.isReasoning === true; +} + export function applyReplyThreading(params: { payloads: ReplyPayload[]; replyToMode: ReplyToMode; diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 4262b80db0f4..ef5a3a733b05 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -123,7 +123,7 @@ describe("typing controller", () => { ] as const; for (const testCase of cases) { - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -133,7 +133,7 @@ describe("typing controller", () => { await typing.startTypingLoop(); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3); if (testCase.first === "run") { @@ -141,7 +141,7 @@ describe("typing controller", () => { } else { typing.markDispatchIdle(); } - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); if (testCase.second === "run") { @@ -149,14 +149,14 @@ describe("typing controller", () => { } else { typing.markDispatchIdle(); } - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5); } }); it("does not start typing after run completion", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -165,13 +165,13 @@ describe("typing controller", () => { typing.markRunComplete(); await typing.startTypingOnText("late text"); - vi.advanceTimersByTime(2_000); + await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart).not.toHaveBeenCalled(); }); it("does not restart typing after it has stopped", async () => { vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); + const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, @@ -184,12 +184,12 @@ describe("typing controller", () => { typing.markRunComplete(); typing.markDispatchIdle(); - vi.advanceTimersByTime(5_000); + await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); // Late callbacks should be ignored and must not restart the interval. await typing.startTypingOnText("late tool result"); - vi.advanceTimersByTime(5_000); + await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); }); }); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 7712de8c7d6b..c6d726ebaf5a 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -144,6 +144,18 @@ describe("routeReply", () => { expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); + it("suppresses reasoning payloads", async () => { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload: { text: "Reasoning:\n_step_", isReasoning: true }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + it("drops silent token payloads", async () => { mocks.sendMessageSlack.mockClear(); const res = await routeReply({ diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 3b6cc68b7e95..081fd58a04ab 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -15,6 +15,7 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; +import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; export type RouteReplyParams = { /** The reply payload to send. */ @@ -56,6 +57,9 @@ export type RouteReplyResult = { */ export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; + if (shouldSuppressReasoningPayload(payload)) { + return { ok: true }; + } const normalizedChannel = normalizeMessageChannel(channel); const resolvedAgentId = params.sessionKey ? resolveSessionAgentId({ diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index ececcc2fb842..fee32418050d 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -1,3 +1,4 @@ +import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; export type TypingController = { @@ -35,7 +36,6 @@ export function createTypingController(params: { // especially when upstream event emitters don't await async listeners. // Once we stop typing, we "seal" the controller so late events can't restart typing forever. let sealed = false; - let typingTimer: NodeJS.Timeout | undefined; let typingTtlTimer: NodeJS.Timeout | undefined; const typingIntervalMs = typingIntervalSeconds * 1000; @@ -61,10 +61,7 @@ export function createTypingController(params: { clearTimeout(typingTtlTimer); typingTtlTimer = undefined; } - if (typingTimer) { - clearInterval(typingTimer); - typingTimer = undefined; - } + typingLoop.stop(); // Notify the channel to stop its typing indicator (e.g., on NO_REPLY). // This fires only once (sealed prevents re-entry). if (active) { @@ -88,7 +85,7 @@ export function createTypingController(params: { clearTimeout(typingTtlTimer); } typingTtlTimer = setTimeout(() => { - if (!typingTimer) { + if (!typingLoop.isRunning()) { return; } log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`); @@ -105,6 +102,11 @@ export function createTypingController(params: { await onReplyStart?.(); }; + const typingLoop = createTypingKeepaliveLoop({ + intervalMs: typingIntervalMs, + onTick: triggerTyping, + }); + const ensureStart = async () => { if (sealed) { return; @@ -146,16 +148,11 @@ export function createTypingController(params: { if (!onReplyStart) { return; } - if (typingIntervalMs <= 0) { - return; - } - if (typingTimer) { + if (typingLoop.isRunning()) { return; } await ensureStart(); - typingTimer = setInterval(() => { - void triggerTyping(); - }, typingIntervalMs); + typingLoop.start(); }; const startTypingOnText = async (text?: string) => { diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 839fac559774..f522e31042fa 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -66,6 +66,9 @@ export type ReplyPayload = { /** Send audio as voice message (bubble) instead of audio file. Defaults to false. */ audioAsVoice?: boolean; isError?: boolean; + /** Marks this payload as a reasoning/thinking block. Channels that do not + * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ + isReasoning?: boolean; /** Channel-specific payload data (per-channel envelope). */ channelData?: Record; }; diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts new file mode 100644 index 000000000000..23aa6d1ce06f --- /dev/null +++ b/src/browser/chrome-extension-options-validation.test.ts @@ -0,0 +1,113 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +type RelayCheckResponse = { + status?: number; + ok?: boolean; + error?: string; + contentType?: string; + json?: unknown; +}; + +type RelayCheckStatus = + | { action: "throw"; error: string } + | { action: "status"; kind: "ok" | "error"; message: string }; + +type RelayCheckExceptionStatus = { kind: "error"; message: string }; + +type OptionsValidationModule = { + classifyRelayCheckResponse: ( + res: RelayCheckResponse | null | undefined, + port: number, + ) => RelayCheckStatus; + classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus; +}; + +const require = createRequire(import.meta.url); +const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js"; + +async function loadOptionsValidation(): Promise { + try { + return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Unexpected token 'export'")) { + throw error; + } + return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule; + } +} + +const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation(); + +describe("chrome extension options validation", () => { + it("maps 401 response to token rejected error", () => { + const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792); + expect(result).toEqual({ + action: "status", + kind: "error", + message: "Gateway token rejected. Check token and save again.", + }); + }); + + it("maps non-json 200 response to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps json response without CDP keys to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "application/json", json: { ok: true } }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps valid relay json response to success", () => { + const result = classifyRelayCheckResponse( + { + status: 200, + ok: true, + contentType: "application/json", + json: { Browser: "Chrome/136", "Protocol-Version": "1.3" }, + }, + 19004, + ); + expect(result).toEqual({ + action: "status", + kind: "ok", + message: "Relay reachable and authenticated at http://127.0.0.1:19004/", + }); + }); + + it("maps syntax/json exceptions to wrong-endpoint error", () => { + const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps generic exceptions to relay unreachable error", () => { + const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.", + }); + }); +}); diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index 8fddcccc0b8d..7e300fe5aeee 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -64,14 +64,16 @@ describe("browser control server", () => { }); expect(nav.ok).toBe(true); expect(typeof nav.targetId).toBe("string"); - expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - url: "https://example.com", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }); + expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + url: "https://example.com", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); const click = await postJson<{ ok: boolean }>(`${base}/act`, { kind: "click", diff --git a/src/channels/channel-helpers.test.ts b/src/channels/channel-helpers.test.ts deleted file mode 100644 index b6d3ff4fbd8b..000000000000 --- a/src/channels/channel-helpers.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MsgContext } from "../auto-reply/templating.js"; -import { resolveConversationLabel } from "./conversation-label.js"; -import { - formatChannelSelectionLine, - listChatChannels, - normalizeChatChannelId, -} from "./registry.js"; -import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; -import { createTypingCallbacks } from "./typing.js"; - -const flushMicrotasks = async () => { - await Promise.resolve(); - await Promise.resolve(); -}; - -describe("channel registry helpers", () => { - it("normalizes aliases + trims whitespace", () => { - expect(normalizeChatChannelId(" imsg ")).toBe("imessage"); - expect(normalizeChatChannelId("gchat")).toBe("googlechat"); - expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); - expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); - expect(normalizeChatChannelId("telegram")).toBe("telegram"); - expect(normalizeChatChannelId("web")).toBeNull(); - expect(normalizeChatChannelId("nope")).toBeNull(); - }); - - it("keeps Telegram first in the default order", () => { - const channels = listChatChannels(); - expect(channels[0]?.id).toBe("telegram"); - }); - - it("does not include MS Teams by default", () => { - const channels = listChatChannels(); - expect(channels.some((channel) => channel.id === "msteams")).toBe(false); - }); - - it("formats selection lines with docs labels + website extras", () => { - const channels = listChatChannels(); - const first = channels[0]; - if (!first) { - throw new Error("Missing channel metadata."); - } - const line = formatChannelSelectionLine(first, (path, label) => - [label, path].filter(Boolean).join(":"), - ); - expect(line).not.toContain("Docs:"); - expect(line).toContain("/channels/telegram"); - expect(line).toContain("https://openclaw.ai"); - }); -}); - -describe("channel targets", () => { - it("ensureTargetId returns the candidate when it matches", () => { - expect( - ensureTargetId({ - candidate: "U123", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "bad", - }), - ).toBe("U123"); - }); - - it("ensureTargetId throws with the provided message on mismatch", () => { - expect(() => - ensureTargetId({ - candidate: "not-ok", - pattern: /^[A-Z0-9]+$/i, - errorMessage: "Bad target", - }), - ).toThrow(/Bad target/); - }); - - it("requireTargetKind returns the target id when the kind matches", () => { - const target = buildMessagingTarget("channel", "C123", "C123"); - expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); - }); - - it("requireTargetKind throws when the kind is missing or mismatched", () => { - expect(() => - requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), - ).toThrow(/Slack channel id is required/); - const target = buildMessagingTarget("user", "U123", "U123"); - expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( - /Slack channel id is required/, - ); - }); -}); - -describe("resolveConversationLabel", () => { - const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [ - { - name: "prefers ConversationLabel when present", - ctx: { ConversationLabel: "Pinned Label", ChatType: "group" }, - expected: "Pinned Label", - }, - { - name: "prefers ThreadLabel over derived chat labels", - ctx: { - ThreadLabel: "Thread Alpha", - ChatType: "group", - GroupSubject: "Ops", - From: "telegram:group:42", - }, - expected: "Thread Alpha", - }, - { - name: "uses SenderName for direct chats when available", - ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }, - expected: "Ada", - }, - { - name: "falls back to From for direct chats when SenderName is missing", - ctx: { ChatType: "direct", From: "telegram:99" }, - expected: "telegram:99", - }, - { - name: "derives Telegram-like group labels with numeric id suffix", - ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }, - expected: "Ops id:42", - }, - { - name: "does not append ids for #rooms/channels", - ctx: { - ChatType: "channel", - GroupSubject: "#general", - From: "slack:channel:C123", - }, - expected: "#general", - }, - { - name: "does not append ids when the base already contains the id", - ctx: { - ChatType: "group", - GroupSubject: "Family id:123@g.us", - From: "whatsapp:group:123@g.us", - }, - expected: "Family id:123@g.us", - }, - { - name: "appends ids for WhatsApp-like group ids when a subject exists", - ctx: { - ChatType: "group", - GroupSubject: "Family", - From: "whatsapp:group:123@g.us", - }, - expected: "Family id:123@g.us", - }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected); - }); - } -}); - -describe("createTypingCallbacks", () => { - it("invokes start on reply start", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(start).toHaveBeenCalledTimes(1); - expect(onStartError).not.toHaveBeenCalled(); - }); - - it("reports start errors", async () => { - const start = vi.fn().mockRejectedValue(new Error("fail")); - const onStartError = vi.fn(); - const callbacks = createTypingCallbacks({ start, onStartError }); - - await callbacks.onReplyStart(); - - expect(onStartError).toHaveBeenCalledTimes(1); - }); - - it("invokes stop on idle and reports stop errors", async () => { - const start = vi.fn().mockResolvedValue(undefined); - const stop = vi.fn().mockRejectedValue(new Error("stop")); - const onStartError = vi.fn(); - const onStopError = vi.fn(); - const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); - - callbacks.onIdle?.(); - await flushMicrotasks(); - - expect(stop).toHaveBeenCalledTimes(1); - expect(onStopError).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/channels/conversation-label.test.ts b/src/channels/conversation-label.test.ts new file mode 100644 index 000000000000..9d9e042ad0c8 --- /dev/null +++ b/src/channels/conversation-label.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; +import { resolveConversationLabel } from "./conversation-label.js"; + +describe("resolveConversationLabel", () => { + const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [ + { + name: "prefers ConversationLabel when present", + ctx: { ConversationLabel: "Pinned Label", ChatType: "group" }, + expected: "Pinned Label", + }, + { + name: "prefers ThreadLabel over derived chat labels", + ctx: { + ThreadLabel: "Thread Alpha", + ChatType: "group", + GroupSubject: "Ops", + From: "telegram:group:42", + }, + expected: "Thread Alpha", + }, + { + name: "uses SenderName for direct chats when available", + ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }, + expected: "Ada", + }, + { + name: "falls back to From for direct chats when SenderName is missing", + ctx: { ChatType: "direct", From: "telegram:99" }, + expected: "telegram:99", + }, + { + name: "derives Telegram-like group labels with numeric id suffix", + ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }, + expected: "Ops id:42", + }, + { + name: "does not append ids for #rooms/channels", + ctx: { + ChatType: "channel", + GroupSubject: "#general", + From: "slack:channel:C123", + }, + expected: "#general", + }, + { + name: "does not append ids when the base already contains the id", + ctx: { + ChatType: "group", + GroupSubject: "Family id:123@g.us", + From: "whatsapp:group:123@g.us", + }, + expected: "Family id:123@g.us", + }, + { + name: "appends ids for WhatsApp-like group ids when a subject exists", + ctx: { + ChatType: "group", + GroupSubject: "Family", + From: "whatsapp:group:123@g.us", + }, + expected: "Family id:123@g.us", + }, + ]; + + for (const testCase of cases) { + it(testCase.name, () => { + expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected); + }); + } +}); diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts new file mode 100644 index 000000000000..93d65d728a56 --- /dev/null +++ b/src/channels/plugins/config-schema.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { buildChannelConfigSchema } from "./config-schema.js"; + +describe("buildChannelConfigSchema", () => { + it("builds json schema when toJSONSchema is available", () => { + const schema = z.object({ enabled: z.boolean().default(true) }); + const result = buildChannelConfigSchema(schema); + expect(result.schema).toMatchObject({ type: "object" }); + }); + + it("falls back when toJSONSchema is missing (zod v3 plugin compatibility)", () => { + const legacySchema = {} as unknown as Parameters[0]; + const result = buildChannelConfigSchema(legacySchema); + expect(result.schema).toEqual({ type: "object", additionalProperties: true }); + }); + + it("passes draft-07 compatibility options to toJSONSchema", () => { + const toJSONSchema = vi.fn(() => ({ + type: "object", + properties: { enabled: { type: "boolean" } }, + })); + const schema = { toJSONSchema } as unknown as Parameters[0]; + + const result = buildChannelConfigSchema(schema); + + expect(toJSONSchema).toHaveBeenCalledWith({ + target: "draft-07", + unrepresentable: "any", + }); + expect(result.schema).toEqual({ + type: "object", + properties: { enabled: { type: "boolean" } }, + }); + }); +}); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 50b81e83b92d..75074ae569d9 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,11 +1,27 @@ import type { ZodTypeAny } from "zod"; import type { ChannelConfigSchema } from "./types.plugin.js"; +type ZodSchemaWithToJsonSchema = ZodTypeAny & { + toJSONSchema?: (params?: Record) => unknown; +}; + export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { + const schemaWithJson = schema as ZodSchemaWithToJsonSchema; + if (typeof schemaWithJson.toJSONSchema === "function") { + return { + schema: schemaWithJson.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }) as Record, + }; + } + + // Compatibility fallback for plugins built against Zod v3 schemas, + // where `.toJSONSchema()` is unavailable. return { - schema: schema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }) as Record, + schema: { + type: "object", + additionalProperties: true, + }, }; } diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts new file mode 100644 index 000000000000..3051f33b4fac --- /dev/null +++ b/src/channels/registry.helpers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + formatChannelSelectionLine, + listChatChannels, + normalizeChatChannelId, +} from "./registry.js"; + +describe("channel registry helpers", () => { + it("normalizes aliases + trims whitespace", () => { + expect(normalizeChatChannelId(" imsg ")).toBe("imessage"); + expect(normalizeChatChannelId("gchat")).toBe("googlechat"); + expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); + expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); + expect(normalizeChatChannelId("telegram")).toBe("telegram"); + expect(normalizeChatChannelId("web")).toBeNull(); + expect(normalizeChatChannelId("nope")).toBeNull(); + }); + + it("keeps Telegram first in the default order", () => { + const channels = listChatChannels(); + expect(channels[0]?.id).toBe("telegram"); + }); + + it("does not include MS Teams by default", () => { + const channels = listChatChannels(); + expect(channels.some((channel) => channel.id === "msteams")).toBe(false); + }); + + it("formats selection lines with docs labels + website extras", () => { + const channels = listChatChannels(); + const first = channels[0]; + if (!first) { + throw new Error("Missing channel metadata."); + } + const line = formatChannelSelectionLine(first, (path, label) => + [label, path].filter(Boolean).join(":"), + ); + expect(line).not.toContain("Docs:"); + expect(line).toContain("/channels/telegram"); + expect(line).toContain("https://openclaw.ai"); + }); +}); diff --git a/src/channels/targets.test.ts b/src/channels/targets.test.ts new file mode 100644 index 000000000000..cea399998367 --- /dev/null +++ b/src/channels/targets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; + +describe("channel targets", () => { + it("ensureTargetId returns the candidate when it matches", () => { + expect( + ensureTargetId({ + candidate: "U123", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "bad", + }), + ).toBe("U123"); + }); + + it("ensureTargetId throws with the provided message on mismatch", () => { + expect(() => + ensureTargetId({ + candidate: "not-ok", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Bad target", + }), + ).toThrow(/Bad target/); + }); + + it("requireTargetKind returns the target id when the kind matches", () => { + const target = buildMessagingTarget("channel", "C123", "C123"); + expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); + }); + + it("requireTargetKind throws when the kind is missing or mismatched", () => { + expect(() => + requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }), + ).toThrow(/Slack channel id is required/); + const target = buildMessagingTarget("user", "U123", "U123"); + expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( + /Slack channel id is required/, + ); + }); +}); diff --git a/src/channels/typing-lifecycle.ts b/src/channels/typing-lifecycle.ts new file mode 100644 index 000000000000..68cab9113ae9 --- /dev/null +++ b/src/channels/typing-lifecycle.ts @@ -0,0 +1,55 @@ +type AsyncTick = () => Promise | void; + +export type TypingKeepaliveLoop = { + tick: () => Promise; + start: () => void; + stop: () => void; + isRunning: () => boolean; +}; + +export function createTypingKeepaliveLoop(params: { + intervalMs: number; + onTick: AsyncTick; +}): TypingKeepaliveLoop { + let timer: ReturnType | undefined; + let tickInFlight = false; + + const tick = async () => { + if (tickInFlight) { + return; + } + tickInFlight = true; + try { + await params.onTick(); + } finally { + tickInFlight = false; + } + }; + + const start = () => { + if (params.intervalMs <= 0 || timer) { + return; + } + timer = setInterval(() => { + void tick(); + }, params.intervalMs); + }; + + const stop = () => { + if (!timer) { + return; + } + clearInterval(timer); + timer = undefined; + tickInFlight = false; + }; + + const isRunning = () => timer !== undefined; + + return { + tick, + start, + stop, + isRunning, + }; +} diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts new file mode 100644 index 000000000000..c1f314183b84 --- /dev/null +++ b/src/channels/typing.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTypingCallbacks } from "./typing.js"; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("createTypingCallbacks", () => { + it("invokes start on reply start", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStartError).not.toHaveBeenCalled(); + }); + + it("reports start errors", async () => { + const start = vi.fn().mockRejectedValue(new Error("fail")); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(onStartError).toHaveBeenCalledTimes(1); + }); + + it("invokes stop on idle and reports stop errors", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockRejectedValue(new Error("stop")); + const onStartError = vi.fn(); + const onStopError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + + callbacks.onIdle?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onStopError).toHaveBeenCalledTimes(1); + }); + + it("sends typing keepalive pings until idle cleanup", async () => { + vi.useFakeTimers(); + try { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + await callbacks.onReplyStart(); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_999); + expect(start).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(start).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(3_000); + expect(start).toHaveBeenCalledTimes(3); + + callbacks.onIdle?.(); + await flushMicrotasks(); + expect(stop).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(9_000); + expect(start).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("deduplicates stop across idle and cleanup", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + callbacks.onIdle?.(); + callbacks.onCleanup?.(); + await flushMicrotasks(); + + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/channels/typing.ts b/src/channels/typing.ts index 6ab2a975361c..b701dfb72cd7 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -1,3 +1,5 @@ +import { createTypingKeepaliveLoop } from "./typing-lifecycle.js"; + export type TypingCallbacks = { onReplyStart: () => Promise; onIdle?: () => void; @@ -10,9 +12,13 @@ export function createTypingCallbacks(params: { stop?: () => Promise; onStartError: (err: unknown) => void; onStopError?: (err: unknown) => void; + keepaliveIntervalMs?: number; }): TypingCallbacks { const stop = params.stop; - const onReplyStart = async () => { + const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000; + let stopSent = false; + + const fireStart = async () => { try { await params.start(); } catch (err) { @@ -20,11 +26,26 @@ export function createTypingCallbacks(params: { } }; - const fireStop = stop - ? () => { - void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); - } - : undefined; + const keepaliveLoop = createTypingKeepaliveLoop({ + intervalMs: keepaliveIntervalMs, + onTick: fireStart, + }); + + const onReplyStart = async () => { + stopSent = false; + keepaliveLoop.stop(); + await fireStart(); + keepaliveLoop.start(); + }; + + const fireStop = () => { + keepaliveLoop.stop(); + if (!stop || stopSent) { + return; + } + stopSent = true; + void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + }; return { onReplyStart, onIdle: fireStop, onCleanup: fireStop }; } diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts index d71cb9a04347..c3b03404f3ab 100644 --- a/src/cli/browser-cli-state.cookies-storage.ts +++ b/src/cli/browser-cli-state.cookies-storage.ts @@ -4,6 +4,17 @@ import { defaultRuntime } from "../runtime.js"; import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; import { inheritOptionFromParent } from "./command-options.js"; +function resolveUrl(opts: { url?: string }, command: Command): string | undefined { + if (typeof opts.url === "string" && opts.url.trim()) { + return opts.url.trim(); + } + const inherited = inheritOptionFromParent(command, "url"); + if (typeof inherited === "string" && inherited.trim()) { + return inherited.trim(); + } + return undefined; +} + function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined { const local = typeof rawTargetId === "string" ? rawTargetId.trim() : ""; if (local) { @@ -58,12 +69,18 @@ export function registerBrowserCookiesAndStorageCommands( .description("Set a cookie (requires --url or domain+path)") .argument("", "Cookie name") .argument("", "Cookie value") - .requiredOption("--url ", "Cookie URL scope (recommended)") + .option("--url ", "Cookie URL scope (recommended)") .option("--target-id ", "CDP target id (or unique prefix)") .action(async (name: string, value: string, opts, cmd) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; const targetId = resolveTargetId(opts.targetId, cmd); + const url = resolveUrl(opts, cmd); + if (!url) { + defaultRuntime.error(danger("Missing required --url option for cookies set")); + defaultRuntime.exit(1); + return; + } try { const result = await callBrowserRequest( parent, @@ -73,7 +90,7 @@ export function registerBrowserCookiesAndStorageCommands( query: profile ? { profile } : undefined, body: { targetId, - cookie: { name, value, url: opts.url }, + cookie: { name, value, url }, }, }, { timeoutMs: 20000 }, diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 7284a2de048f..917c6c4551ea 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -26,12 +26,15 @@ vi.mock("../runtime.js", () => ({ })); describe("browser state option collisions", () => { - const createBrowserProgram = () => { + const createBrowserProgram = ({ withGatewayUrl = false } = {}) => { const program = new Command(); const browser = program .command("browser") .option("--browser-profile ", "Browser profile") .option("--json", "Output JSON", false); + if (withGatewayUrl) { + browser.option("--url ", "Gateway WebSocket URL"); + } const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserStateCommands(browser, parentOpts); return program; @@ -79,6 +82,40 @@ describe("browser state option collisions", () => { expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1"); }); + it("resolves --url via parent when addGatewayClientOptions captures it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + [ + "browser", + "--url", + "ws://gw", + "cookies", + "set", + "session", + "abc", + "--url", + "https://example.com", + ], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://example.com"); + }); + + it("inherits --url from parent when subcommand does not provide it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + ["browser", "--url", "https://inherited.example.com", "cookies", "set", "session", "abc"], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://inherited.example.com"); + }); + it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => { const request = (await runBrowserCommandAndGetRequest([ "set", diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 2813d486be23..0bffcd4c32d0 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -138,7 +138,7 @@ describe("daemon-cli coverage", () => { OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon-state/openclaw.json", OPENCLAW_GATEWAY_PORT: "19001", }, - sourcePath: "/tmp/bot.molt.gateway.plist", + sourcePath: "/tmp/ai.openclaw.gateway.plist", }); await runDaemonCommand(["daemon", "status", "--json"]); diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 741473f69c4b..41f7da868a32 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -126,6 +126,7 @@ describe("runDaemonRestart health checks", () => { await expect(runDaemonRestart({ json: true })).rejects.toMatchObject({ message: "Gateway restart timed out after 60s waiting for health checks.", + hints: ["openclaw gateway status --deep", "openclaw doctor"], }); expect(terminateStaleGatewayPids).not.toHaveBeenCalled(); expect(renderRestartDiagnostics).toHaveBeenCalledTimes(1); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 413320289457..f6d230f0bb82 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -135,7 +135,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } fail(`Gateway restart timed out after ${restartWaitSeconds}s waiting for health checks.`, [ - formatCliCommand("openclaw gateway status --probe --deep"), + formatCliCommand("openclaw gateway status --deep"), formatCliCommand("openclaw doctor"), ]); }, diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 8a83bc5e906c..3d6dfa7d2a23 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -382,6 +382,49 @@ describe("memory cli", () => { expect(close).toHaveBeenCalled(); }); + it("accepts --query for memory search", async () => { + const close = vi.fn(async () => {}); + const search = vi.fn(async () => []); + mockManager({ search, close }); + + const log = spyRuntimeLogs(); + await runMemoryCli(["search", "--query", "deployment notes"]); + + expect(search).toHaveBeenCalledWith("deployment notes", { + maxResults: undefined, + minScore: undefined, + }); + expect(log).toHaveBeenCalledWith("No matches."); + expect(close).toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("prefers --query when positional and flag are both provided", async () => { + const close = vi.fn(async () => {}); + const search = vi.fn(async () => []); + mockManager({ search, close }); + + spyRuntimeLogs(); + await runMemoryCli(["search", "positional", "--query", "flagged"]); + + expect(search).toHaveBeenCalledWith("flagged", { + maxResults: undefined, + minScore: undefined, + }); + expect(close).toHaveBeenCalled(); + }); + + it("fails when neither positional query nor --query is provided", async () => { + const error = spyRuntimeErrors(); + await runMemoryCli(["search"]); + + expect(error).toHaveBeenCalledWith( + "Missing search query. Provide a positional query or use --query .", + ); + expect(getMemorySearchManager).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + it("prints search results as json when requested", async () => { const close = vi.fn(async () => {}); const search = vi.fn(async () => [ diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 6449653f8ac2..f530d5b510e4 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -702,19 +702,29 @@ export function registerMemoryCli(program: Command) { memory .command("search") .description("Search memory files") - .argument("", "Search query") + .argument("[query]", "Search query") + .option("--query ", "Search query (alternative to positional argument)") .option("--agent ", "Agent id (default: default agent)") .option("--max-results ", "Max results", (value: string) => Number(value)) .option("--min-score ", "Minimum score", (value: string) => Number(value)) .option("--json", "Print JSON") .action( async ( - query: string, + queryArg: string | undefined, opts: MemoryCommandOptions & { + query?: string; maxResults?: number; minScore?: number; }, ) => { + const query = opts.query ?? queryArg; + if (!query) { + defaultRuntime.error( + "Missing search query. Provide a positional query or use --query .", + ); + process.exitCode = 1; + return; + } const cfg = loadConfig(); const agentId = resolveAgent(cfg, opts.agent); await withMemoryManagerForAgent({ diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 2a7ec004f848..a53cc783041e 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -253,6 +253,7 @@ export function registerNodesInvokeCommands(nodes: Command) { id: approvalId, command: rawCommand ?? argv.join(" "), cwd: opts.cwd, + nodeId, host: "node", security: hostSecurity, ask: hostAsk, diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index c583d2c83cf7..bf4184d362a9 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,8 @@ describe("registerPreActionHooks", () => { program.command("update").action(async () => {}); program.command("channels").action(async () => {}); program.command("directory").action(async () => {}); + program.command("configure").action(async () => {}); + program.command("onboard").action(async () => {}); program .command("message") .command("send") @@ -125,6 +127,24 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); }); + it("loads plugin registry for configure command", async () => { + await runCommand({ + parseArgv: ["configure"], + processArgv: ["node", "openclaw", "configure"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + + it("loads plugin registry for onboard command", async () => { + await runCommand({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + it("skips config guard for doctor and completion commands", async () => { await runCommand({ parseArgv: ["doctor"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 3e0580154bd1..6a9abc3e99ed 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -21,7 +21,13 @@ function setProcessTitleForCommand(actionCommand: Command) { } // Commands that need channel plugins loaded -const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]); +const PLUGIN_REQUIRED_COMMANDS = new Set([ + "message", + "channels", + "directory", + "configure", + "onboard", +]); function getRootCommand(command: Command): Command { let current = command; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe158fbb5f54..7edff76fe677 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -636,14 +636,6 @@ describe("update-cli", () => { } }); - it("updateCommand skips restart when --no-restart is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - - await updateCommand({ restart: false }); - - expect(runDaemonRestart).not.toHaveBeenCalled(); - }); - it("updateCommand skips success message when restart does not run", async () => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(false); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 3c672a02d5e1..1cce6c66e8e7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -589,7 +589,7 @@ async function maybeRestartService(params: { } defaultRuntime.log( theme.muted( - `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --probe --deep"), CLI_NAME)}\` for details.`, + `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\` for details.`, ), ); } diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 7d9867cbaf37..baa44213ab46 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -191,6 +191,49 @@ describe("deliverAgentCommandResult", () => { ); }); + it("uses runContext turn source over stale session last route", async () => { + await runDelivery({ + opts: { + message: "hello", + deliver: true, + runContext: { + messageChannel: "whatsapp", + currentChannelId: "+15559876543", + accountId: "work", + }, + }, + sessionEntry: { + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong", + } as SessionEntry, + }); + + expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( + expect.objectContaining({ channel: "whatsapp", to: "+15559876543", accountId: "work" }), + ); + }); + + it("does not reuse session lastTo when runContext source omits currentChannelId", async () => { + await runDelivery({ + opts: { + message: "hello", + deliver: true, + runContext: { + messageChannel: "whatsapp", + }, + }, + sessionEntry: { + lastChannel: "slack", + lastTo: "U_WRONG", + } as SessionEntry, + }); + + expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith( + expect.objectContaining({ channel: "whatsapp", to: undefined }), + ); + }); + it("prefixes nested agent outputs with context", async () => { const runtime = createRuntime(); await runDelivery({ diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 3e26ec3ec000..0118e076365b 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -367,6 +367,48 @@ describe("agentCommand", () => { }); }); + it("keeps stored session model override when models allowlist is empty", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:allow-any": { + sessionId: "session-allow-any", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-custom-foo", + }, + }); + + mockConfig(home, store, { + model: { primary: "anthropic/claude-opus-4-5" }, + models: {}, + }); + + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, + ]); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:allow-any", + }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.provider).toBe("openai"); + expect(callArgs?.model).toBe("gpt-custom-foo"); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { providerOverride?: string; modelOverride?: string } + >; + expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai"); + expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo"); + }); + }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7ca8591faa40..ca4e42d314b8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -390,6 +390,7 @@ export async function agentCommand( let allowedModelKeys = new Set(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | null = null; + let allowAnyModel = false; if (needsModelCatalog) { modelCatalog = await loadModelCatalog({ config: cfg }); @@ -401,6 +402,7 @@ export async function agentCommand( }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; + allowAnyModel = allowed.allowAny ?? false; } if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { @@ -412,7 +414,7 @@ export async function agentCommand( const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if ( !isCliProvider(normalizedOverride.provider, cfg) && - allowedModelKeys.size > 0 && + !allowAnyModel && !allowedModelKeys.has(key) ) { const { updated } = applyModelOverrideToSessionEntry({ @@ -439,7 +441,7 @@ export async function agentCommand( const key = modelKey(normalizedStored.provider, normalizedStored.model); if ( isCliProvider(normalizedStored.provider, cfg) || - allowedModelKeys.size === 0 || + allowAnyModel || allowedModelKeys.has(key) ) { provider = normalizedStored.provider; diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index 24ef360a5862..caecb2a62836 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -71,6 +71,10 @@ export async function deliverAgentCommandResult(params: { const { cfg, deps, runtime, opts, sessionEntry, payloads, result } = params; const deliver = opts.deliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true; + const turnSourceChannel = opts.runContext?.messageChannel ?? opts.messageChannel; + const turnSourceTo = opts.runContext?.currentChannelId ?? opts.to; + const turnSourceAccountId = opts.runContext?.accountId ?? opts.accountId; + const turnSourceThreadId = opts.runContext?.currentThreadTs ?? opts.threadId; const deliveryPlan = resolveAgentDeliveryPlan({ sessionEntry, requestedChannel: opts.replyChannel ?? opts.channel, @@ -78,6 +82,10 @@ export async function deliverAgentCommandResult(params: { explicitThreadId: opts.threadId, accountId: opts.replyAccountId ?? opts.accountId, wantsDelivery: deliver, + turnSourceChannel, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId, }); let deliveryChannel = deliveryPlan.resolvedChannel; const explicitChannelHint = (opts.replyChannel ?? opts.channel)?.trim(); diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 638a1c8eade8..21574090c12a 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -4,7 +4,11 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; +import { + setSessionRuntimeModel, + type SessionEntry, + updateSessionStore, +} from "../../config/sessions.js"; type RunResult = Awaited< ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]> @@ -58,10 +62,12 @@ export async function updateSessionStoreAfterAgentRun(params: { ...entry, sessionId, updatedAt: Date.now(), - modelProvider: providerUsed, - model: modelUsed, contextTokens, }; + setSessionRuntimeModel(next, { + provider: providerUsed, + model: modelUsed, + }); if (isCliProvider(providerUsed, cfg)) { const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); if (cliSessionId) { diff --git a/src/commands/doctor-config-flow.safe-bins.test.ts b/src/commands/doctor-config-flow.safe-bins.test.ts index 3d7a646a8dde..802cfeb8d969 100644 --- a/src/commands/doctor-config-flow.safe-bins.test.ts +++ b/src/commands/doctor-config-flow.safe-bins.test.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; const { noteSpy } = vi.hoisted(() => ({ @@ -86,4 +90,46 @@ describe("doctor config flow safe bins", () => { "Doctor warnings", ); }); + + it("hints safeBinTrustedDirs when safeBins resolve outside default trusted dirs", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-safe-bins-")); + const binPath = path.join(dir, "mydoctorbin"); + try { + await fs.writeFile(binPath, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(binPath, 0o755); + await withEnvAsync( + { + PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + tools: { + exec: { + safeBins: ["mydoctorbin"], + safeBinProfiles: { + mydoctorbin: {}, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("outside trusted safe-bin dirs"), + "Doctor warnings", + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("tools.exec.safeBinTrustedDirs"), + "Doctor warnings", + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index e86dec9e819d..f4a7e4132a8d 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -17,10 +17,16 @@ import { import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; +import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures, } from "../infra/exec-safe-bin-runtime-policy.js"; +import { + getTrustedSafeBinDirs, + isTrustedSafeBinPath, + normalizeTrustedSafeBinDirs, +} from "../infra/exec-safe-bin-trust.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -1001,6 +1007,13 @@ type ExecSafeBinScopeRef = { safeBins: string[]; exec: Record; mergedProfiles: Record; + trustedSafeBinDirs: ReadonlySet; +}; + +type ExecSafeBinTrustedDirHintHit = { + scopePath: string; + bin: string; + resolvedPath: string; }; function normalizeConfiguredSafeBins(entries: unknown): string[] { @@ -1016,9 +1029,19 @@ function normalizeConfiguredSafeBins(entries: unknown): string[] { ).toSorted(); } +function normalizeConfiguredTrustedSafeBinDirs(entries: unknown): string[] { + if (!Array.isArray(entries)) { + return []; + } + return normalizeTrustedSafeBinDirs( + entries.filter((entry): entry is string => typeof entry === "string"), + ); +} + function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { const scopes: ExecSafeBinScopeRef[] = []; const globalExec = asObjectRecord(cfg.tools?.exec); + const globalTrustedDirs = normalizeConfiguredTrustedSafeBinDirs(globalExec?.safeBinTrustedDirs); if (globalExec) { const safeBins = normalizeConfiguredSafeBins(globalExec.safeBins); if (safeBins.length > 0) { @@ -1030,6 +1053,9 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { resolveMergedSafeBinProfileFixtures({ global: globalExec, }) ?? {}, + trustedSafeBinDirs: getTrustedSafeBinDirs({ + extraDirs: globalTrustedDirs, + }), }); } } @@ -1055,6 +1081,12 @@ function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] { global: globalExec, local: agentExec, }) ?? {}, + trustedSafeBinDirs: getTrustedSafeBinDirs({ + extraDirs: [ + ...globalTrustedDirs, + ...normalizeConfiguredTrustedSafeBinDirs(agentExec.safeBinTrustedDirs), + ], + }), }); } return scopes; @@ -1078,6 +1110,32 @@ function scanExecSafeBinCoverage(cfg: OpenClawConfig): ExecSafeBinCoverageHit[] return hits; } +function scanExecSafeBinTrustedDirHints(cfg: OpenClawConfig): ExecSafeBinTrustedDirHintHit[] { + const hits: ExecSafeBinTrustedDirHintHit[] = []; + for (const scope of collectExecSafeBinScopes(cfg)) { + for (const bin of scope.safeBins) { + const resolution = resolveCommandResolutionFromArgv([bin]); + if (!resolution?.resolvedPath) { + continue; + } + if ( + isTrustedSafeBinPath({ + resolvedPath: resolution.resolvedPath, + trustedDirs: scope.trustedSafeBinDirs, + }) + ) { + continue; + } + hits.push({ + scopePath: scope.scopePath, + bin, + resolvedPath: resolution.resolvedPath, + }); + } + } + return hits; +} + function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): { config: OpenClawConfig; changes: string[]; @@ -1488,6 +1546,25 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { ); note(lines.join("\n"), "Doctor warnings"); } + + const safeBinTrustedDirHints = scanExecSafeBinTrustedDirHints(candidate); + if (safeBinTrustedDirHints.length > 0) { + const lines = safeBinTrustedDirHints + .slice(0, 5) + .map( + (hit) => + `- ${hit.scopePath}.safeBins entry '${hit.bin}' resolves to '${hit.resolvedPath}' outside trusted safe-bin dirs.`, + ); + if (safeBinTrustedDirHints.length > 5) { + lines.push( + `- ${safeBinTrustedDirHints.length - 5} more safeBins entries resolve outside trusted safe-bin dirs.`, + ); + } + lines.push( + "- If intentional, add the binary directory to tools.exec.safeBinTrustedDirs (global or agent scope).", + ); + note(lines.join("\n"), "Doctor warnings"); + } } const mutableAllowlistHits = scanMutableAllowlistEntries(candidate); diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index a275fa600983..1c5c7a74d2dc 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -143,7 +143,7 @@ describe("noteMemorySearchHealth", () => { expect(message).toContain("reports memory embeddings are ready"); }); - it("uses configure hint when gateway probe is unavailable and API key is missing", async () => { + it("uses model configure hint when gateway probe is unavailable and API key is missing", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "gemini", local: {}, @@ -160,8 +160,23 @@ describe("noteMemorySearchHealth", () => { const message = note.mock.calls[0]?.[0] as string; expect(message).toContain("Gateway memory probe for default agent is not ready"); - expect(message).toContain("openclaw configure"); - expect(message).not.toContain("auth add"); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); + }); + + it("uses model configure hint in auto mode when no provider credentials are found", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg); + + expect(note).toHaveBeenCalledTimes(1); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); }); }); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 5b5d39dd56fa..aebaef40229b 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -84,7 +84,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", `- Set ${envVar} in your environment`, - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, @@ -125,7 +125,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", "- Set OPENAI_API_KEY, GEMINI_API_KEY, VOYAGE_API_KEY, or MISTRAL_API_KEY in your environment", - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- For local embeddings: configure agents.defaults.memorySearch.provider and local model path`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index aa08fb867326..90790e90737a 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -188,7 +188,16 @@ export async function maybeRepairSandboxImages( const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { - note("Docker not available; skipping sandbox image checks.", "Sandbox"); + const lines = [ + `Sandbox mode is enabled (mode: "${mode}") but Docker is not available.`, + "Docker is required for sandbox mode to function.", + "Isolated sessions (cron jobs, sub-agents) will fail without Docker.", + "", + "Options:", + "- Install Docker and restart the gateway", + "- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off", + ]; + note(lines.join("\n"), "Sandbox"); return cfg; } diff --git a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts new file mode 100644 index 000000000000..106066c511a2 --- /dev/null +++ b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +const runExec = vi.fn(); +const note = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runExec, + runCommandWithTimeout: vi.fn(), +})); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +describe("maybeRepairSandboxImages", () => { + const mockRuntime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const mockPrompter: DoctorPrompter = { + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(false), + } as unknown as DoctorPrompter; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("warns when sandbox mode is enabled but Docker is not available", async () => { + // Simulate Docker not available (command fails) + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "non-main", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // The warning should clearly indicate sandbox is enabled but won't work + expect(note).toHaveBeenCalled(); + const noteCall = note.mock.calls[0]; + const message = noteCall[0] as string; + + // The message should warn that sandbox mode won't function, not just "skipping checks" + expect(message).toMatch(/sandbox.*mode.*enabled|sandbox.*won.*work|docker.*required/i); + // Should NOT just say "skipping sandbox image checks" - that's too mild + expect(message).not.toBe("Docker not available; skipping sandbox image checks."); + }); + + it("warns when sandbox mode is 'all' but Docker is not available", async () => { + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + expect(note).toHaveBeenCalled(); + const noteCall = note.mock.calls[0]; + const message = noteCall[0] as string; + + // Should warn about the impact on sandbox functionality + expect(message).toMatch(/sandbox|docker/i); + }); + + it("does not warn when sandbox mode is off", async () => { + runExec.mockRejectedValue(new Error("Docker not installed")); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "off", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // No warning needed when sandbox is off + expect(note).not.toHaveBeenCalled(); + }); + + it("does not warn when Docker is available", async () => { + // Simulate Docker available + runExec.mockResolvedValue({ stdout: "24.0.0", stderr: "" }); + + const config: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "non-main", + }, + }, + }, + }; + + const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js"); + await maybeRepairSandboxImages(config, mockRuntime, mockPrompter); + + // May have other notes about images, but not the Docker unavailable warning + const dockerUnavailableWarning = note.mock.calls.find( + (call) => + typeof call[0] === "string" && call[0].toLowerCase().includes("docker not available"), + ); + expect(dockerUnavailableWarning).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index bccb04964eb8..2e31da8e76a6 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -261,8 +261,15 @@ export async function noteStateIntegrity( } if (stateDirExists && process.platform !== "win32") { try { - const stat = fs.statSync(stateDir); - if ((stat.mode & 0o077) !== 0) { + const dirLstat = fs.lstatSync(stateDir); + const isDirSymlink = dirLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions instead of the + // symlink itself (which always reports 777). Skip the warning only when + // the target lives in a known immutable store (e.g. /nix/store/). + const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat; + const resolvedDir = isDirSymlink ? fs.realpathSync(stateDir) : stateDir; + const isImmutableStore = resolvedDir.startsWith("/nix/store/"); + if (!isImmutableStore && (stat.mode & 0o077) !== 0) { warnings.push( `- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`, ); @@ -282,10 +289,14 @@ export async function noteStateIntegrity( if (configPath && existsFile(configPath) && process.platform !== "win32") { try { - const linkStat = fs.lstatSync(configPath); - const stat = fs.statSync(configPath); - const isSymlink = linkStat.isSymbolicLink(); - if (!isSymlink && (stat.mode & 0o077) !== 0) { + const configLstat = fs.lstatSync(configPath); + const isSymlink = configLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions. Skip the warning + // only when the target lives in an immutable store (e.g. /nix/store/). + const stat = isSymlink ? fs.statSync(configPath) : configLstat; + const resolvedConfig = isSymlink ? fs.realpathSync(configPath) : configPath; + const isImmutableConfig = resolvedConfig.startsWith("/nix/store/"); + if (!isImmutableConfig && (stat.mode & 0o077) !== 0) { warnings.push( `- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`, ); diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index 95fe4be23f44..4cece369684d 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -54,9 +54,11 @@ describe("doctor command", () => { const remote = gateway.remote as Record; const channels = (written.channels as Record) ?? {}; - expect(channels.whatsapp).toEqual({ - allowFrom: ["+15555550123"], - }); + expect(channels.whatsapp).toEqual( + expect.objectContaining({ + allowFrom: ["+15555550123"], + }), + ); expect(written.routing).toBeUndefined(); expect(remote.token).toBe("legacy-remote-token"); expect(auth).toBeUndefined(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 714a3d2574f5..4aa0241da194 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -301,7 +301,7 @@ export async function doctorCommand( if (fs.existsSync(backupPath)) { runtime.log(`Backup: ${shortenHomePath(backupPath)}`); } - } else { + } else if (!prompter.shouldRepair) { runtime.log(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply changes.`); } diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index 187548ac1b3b..aebe7fb1a872 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; @@ -42,6 +44,15 @@ vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); +vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as Record), + // Allow tests to simulate an empty plugin registry during onboarding. + reloadOnboardingPluginRegistry: vi.fn(() => {}), + }; +}); + describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); @@ -88,6 +99,45 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("continues Telegram onboarding even when plugin registry is empty (avoids 'plugin not available' block)", async () => { + // Simulate missing registry entries (the scenario reported in #25545). + setActivePluginRegistry(createEmptyPluginRegistry()); + // Avoid accidental env-token configuration changing the prompt path. + process.env.TELEGRAM_BOT_TOKEN = ""; + + const note = vi.fn(async (_message?: string, _title?: string) => {}); + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const text = vi.fn(async () => "123:token"); + + const prompter = createPrompter({ + note, + select: select as unknown as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + }); + + const runtime = createExitThrowingRuntime(); + + await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + }); + + // The new flow should not stop setup with a hard "plugin not available" note. + const sawHardStop = note.mock.calls.some((call) => { + const message = call[0]; + const title = call[1]; + return ( + title === "Channel setup" && String(message).trim() === "telegram plugin not available." + ); + }); + expect(sawHardStop).toBe(false); + }); + it("shows explicit dmScope config command in channel primer", async () => { const note = vi.fn(async (_message?: string, _title?: string) => {}); const select = vi.fn(async () => "__done__"); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index e05ca5b1516c..e65b55e0e8ec 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -494,6 +494,20 @@ export async function setupChannels( workspaceDir, }); if (!getChannelPlugin(channel)) { + // Some installs/environments can fail to populate the plugin registry during onboarding, + // even for built-in channels. If the channel supports onboarding, proceed with config + // so setup isn't blocked; the gateway can still load plugins on startup. + const adapter = getChannelOnboardingAdapter(channel); + if (adapter) { + await prompter.note( + `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + "openclaw plugins list", + )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, + "Channel setup", + ); + await refreshStatus(channel); + return true; + } await prompter.note(`${channel} plugin not available.`, "Channel setup"); return false; } diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c1bf8aa0d8d5..c79c30daff26 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -116,6 +116,35 @@ describe("promptCustomApiConfig", () => { expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result }); }); + it("uses expanded max_tokens for openai verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], + select: ["openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + await runPromptCustomApi(prompter); + + const firstCall = fetchMock.mock.calls[0]?.[1] as { body?: string } | undefined; + expect(firstCall?.body).toBeDefined(); + expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + + it("uses expanded max_tokens for anthropic verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], + select: ["unknown"], + }); + const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]); + + await runPromptCustomApi(prompter); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondCall = fetchMock.mock.calls[1]?.[1] as { body?: string } | undefined; + expect(secondCall?.body).toBeDefined(); + expect(JSON.parse(secondCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + it("re-prompts base url when unknown detection fails", async () => { const prompter = createTestPrompter({ text: [ diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index aff71ce7f3dd..a00471701b2c 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -303,7 +303,7 @@ async function requestOpenAiVerification(params: { body: { model: params.modelId, messages: [{ role: "user", content: "Hi" }], - max_tokens: 5, + max_tokens: 1024, }, }); } @@ -329,7 +329,7 @@ async function requestAnthropicVerification(params: { headers: buildAnthropicHeaders(params.apiKey), body: { model: params.modelId, - max_tokens: 16, + max_tokens: 1024, messages: [{ role: "user", content: "Hi" }], }, }); diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index d3fdbef2ce9a..814eab75ea24 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,16 +1,36 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { discordOnboardingAdapter } from "../../channels/plugins/onboarding/discord.js"; +import { imessageOnboardingAdapter } from "../../channels/plugins/onboarding/imessage.js"; +import { signalOnboardingAdapter } from "../../channels/plugins/onboarding/signal.js"; +import { slackOnboardingAdapter } from "../../channels/plugins/onboarding/slack.js"; +import { telegramOnboardingAdapter } from "../../channels/plugins/onboarding/telegram.js"; +import { whatsappOnboardingAdapter } from "../../channels/plugins/onboarding/whatsapp.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const CHANNEL_ONBOARDING_ADAPTERS = () => - new Map( - listChannelPlugins() - .map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null)) - .filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => - Boolean(entry), - ), +const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ + telegramOnboardingAdapter, + whatsappOnboardingAdapter, + discordOnboardingAdapter, + slackOnboardingAdapter, + signalOnboardingAdapter, + imessageOnboardingAdapter, +]; + +const CHANNEL_ONBOARDING_ADAPTERS = () => { + const fromRegistry = listChannelPlugins() + .map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null)) + .filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => Boolean(entry)); + + // Fall back to built-in adapters to keep onboarding working even when the plugin registry + // fails to populate (see #25545). + const fromBuiltins = BUILTIN_ONBOARDING_ADAPTERS.map( + (adapter) => [adapter.channel, adapter] as const, ); + return new Map([...fromBuiltins, ...fromRegistry]); +}; + export function getChannelOnboardingAdapter( channel: ChannelChoice, ): ChannelOnboardingAdapter | undefined { diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts new file mode 100644 index 000000000000..5769bc0d41d0 --- /dev/null +++ b/src/commands/status-all/report-lines.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProgressReporter } from "../../cli/progress.js"; +import { buildStatusAllReportLines } from "./report-lines.js"; + +const diagnosisSpy = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("./diagnosis.js", () => ({ + appendStatusAllDiagnosis: diagnosisSpy, +})); + +describe("buildStatusAllReportLines", () => { + it("renders bootstrap column using file-presence semantics", async () => { + const progress: ProgressReporter = { + setLabel: () => {}, + setPercent: () => {}, + tick: () => {}, + done: () => {}, + }; + const lines = await buildStatusAllReportLines({ + progress, + overviewRows: [{ Item: "Gateway", Value: "ok" }], + channels: { + rows: [], + details: [], + }, + channelIssues: [], + agentStatus: { + agents: [ + { + id: "main", + bootstrapPending: true, + sessionsCount: 1, + lastActiveAgeMs: 12_000, + sessionsPath: "/tmp/main-sessions.json", + }, + { + id: "ops", + bootstrapPending: false, + sessionsCount: 0, + lastActiveAgeMs: null, + sessionsPath: "/tmp/ops-sessions.json", + }, + ], + }, + connectionDetailsForReport: "", + diagnosis: { + snap: null, + remoteUrlMissing: false, + sentinel: null, + lastErr: null, + port: 18789, + portUsage: null, + tailscaleMode: "off", + tailscale: { + backendState: null, + dnsName: null, + ips: [], + error: null, + }, + tailscaleHttpsUrl: null, + skillStatus: null, + channelsStatus: null, + channelIssues: [], + gatewayReachable: false, + health: null, + }, + }); + + const output = lines.join("\n"); + expect(output).toContain("Bootstrap file"); + expect(output).toContain("PRESENT"); + expect(output).toContain("ABSENT"); + }); +}); diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 71dc035ad848..0db503002bd0 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -121,11 +121,11 @@ export async function buildStatusAllReportLines(params: { const agentRows = params.agentStatus.agents.map((a) => ({ Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id, - Bootstrap: + BootstrapFile: a.bootstrapPending === true - ? warn("PENDING") + ? warn("PRESENT") : a.bootstrapPending === false - ? ok("OK") + ? ok("ABSENT") : "unknown", Sessions: String(a.sessionsCount), Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown", @@ -136,7 +136,7 @@ export async function buildStatusAllReportLines(params: { width: tableWidth, columns: [ { key: "Agent", header: "Agent", minWidth: 12 }, - { key: "Bootstrap", header: "Bootstrap", minWidth: 10 }, + { key: "BootstrapFile", header: "Bootstrap file", minWidth: 14 }, { key: "Sessions", header: "Sessions", align: "right", minWidth: 8 }, { key: "Active", header: "Active", minWidth: 10 }, { key: "Store", header: "Store", flex: true, minWidth: 34 }, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index e06feb42af52..e78faa4cc38e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -37,6 +37,33 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +function resolvePairingRecoveryContext(params: { + error?: string | null; + closeReason?: string | null; +}): { requestId: string | null } | null { + const sanitizeRequestId = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + // Keep CLI guidance injection-safe: allow only compact id characters. + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trimmed)) { + return null; + } + return trimmed; + }; + const source = [params.error, params.closeReason] + .filter((part) => typeof part === "string" && part.trim().length > 0) + .join(" "); + if (!source || !/pairing required/i.test(source)) { + return null; + } + const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); + const requestId = + requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null; + return { requestId: requestId || null }; +} + export async function statusCommand( opts: { json?: boolean; @@ -230,12 +257,16 @@ export async function statusCommand( const suffix = self ? ` · ${self}` : ""; return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`; })(); + const pairingRecovery = resolvePairingRecoveryContext({ + error: gatewayProbe?.error ?? null, + closeReason: gatewayProbe?.close?.reason ?? null, + }); const agentsValue = (() => { const pending = agentStatus.bootstrapPendingCount > 0 - ? `${agentStatus.bootstrapPendingCount} bootstrapping` - : "no bootstraps"; + ? `${agentStatus.bootstrapPendingCount} bootstrap file${agentStatus.bootstrapPendingCount === 1 ? "" : "s"} present` + : "no bootstrap files"; const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId); const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown"; const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; @@ -399,6 +430,20 @@ export async function statusCommand( }).trimEnd(), ); + if (pairingRecovery) { + runtime.log(""); + runtime.log(theme.warn("Gateway pairing approval required.")); + if (pairingRecovery.requestId) { + runtime.log( + theme.muted( + `Recovery: ${formatCliCommand(`openclaw devices approve ${pairingRecovery.requestId}`)}`, + ), + ); + } + runtime.log(theme.muted(`Fallback: ${formatCliCommand("openclaw devices approve --latest")}`)); + runtime.log(theme.muted(`Inspect: ${formatCliCommand("openclaw devices list")}`)); + } + runtime.log(""); runtime.log(theme.heading("Security audit")); const fmtSummary = (value: { critical: number; warn: number; info: number }) => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 1275c0bea2c9..f4243b08abc3 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -297,7 +297,7 @@ vi.mock("../daemon/service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 1234 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "gateway"], - sourcePath: "/tmp/Library/LaunchAgents/bot.molt.gateway.plist", + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", }), }), })); @@ -310,7 +310,7 @@ vi.mock("../daemon/node-service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 4321 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "node-host"], - sourcePath: "/tmp/Library/LaunchAgents/bot.molt.node.plist", + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.node.plist", }), }), })); @@ -388,6 +388,7 @@ describe("statusCommand", () => { expect(logs.some((l: string) => l.includes("Memory"))).toBe(true); expect(logs.some((l: string) => l.includes("Channels"))).toBe(true); expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true); + expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true); expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true); expect(logs.some((l: string) => l.includes("+1000"))).toBe(true); expect(logs.some((l: string) => l.includes("50%"))).toBe(true); @@ -479,6 +480,92 @@ describe("statusCommand", () => { expect(logs.join("\n")).toMatch(/WARN/); }); + it("prints requestId-aware recovery guidance when gateway pairing is required", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123)", + close: { code: 1008, reason: "pairing required (requestId: req-123)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).toContain("devices approve req-123"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + + it("prints fallback recovery guidance when pairing requestId is unavailable", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "connect failed" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + + it("does not render unsafe requestId content into approval command hints", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-123;rm -rf /"); + expect(joined).toContain("devices approve --latest"); + }); + + it("extracts requestId from close reason when error text omits it", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("devices approve req-close-456"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); diff --git a/src/config/config.meta-timestamp-coercion.test.ts b/src/config/config.meta-timestamp-coercion.test.ts new file mode 100644 index 000000000000..d87b16b451e9 --- /dev/null +++ b/src/config/config.meta-timestamp-coercion.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("meta.lastTouchedAt numeric timestamp coercion", () => { + it("accepts a numeric Unix timestamp and coerces it to an ISO string", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const numericTimestamp = 1770394758161; + const res = validateConfigObject({ + meta: { + lastTouchedAt: numericTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(typeof res.config.meta?.lastTouchedAt).toBe("string"); + expect(res.config.meta?.lastTouchedAt).toBe(new Date(numericTimestamp).toISOString()); + } + }); + + it("still accepts a string ISO timestamp unchanged", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const isoTimestamp = "2026-02-07T01:39:18.161Z"; + const res = validateConfigObject({ + meta: { + lastTouchedAt: isoTimestamp, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe(isoTimestamp); + } + }); + + it("rejects out-of-range numeric timestamps without throwing", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: 1e20, + }, + }); + expect(res.ok).toBe(false); + }); + + it("passes non-date strings through unchanged (backwards-compatible)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedAt: "not-a-date", + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.lastTouchedAt).toBe("not-a-date"); + } + }); + + it("accepts meta with only lastTouchedVersion (no lastTouchedAt)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + meta: { + lastTouchedVersion: "2026.2.6", + }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index b9fb08e4d8d3..d9e6b3190e17 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -103,6 +103,48 @@ describe("config plugin validation", () => { } }); + it("warns for removed legacy plugin ids instead of failing validation", async () => { + const home = await createCaseHome(); + const removedId = "google-antigravity-auth"; + const res = validateInHome(home, { + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: false, + entries: { [removedId]: { enabled: true } }, + allow: [removedId], + deny: [removedId], + slots: { memory: removedId }, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.warnings).toEqual( + expect.arrayContaining([ + { + path: `plugins.entries.${removedId}`, + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.allow", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.deny", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.slots.memory", + message: + "plugin removed: google-antigravity-auth (stale config entry ignored; remove it from plugins config)", + }, + ]), + ); + } + }); + it("surfaces plugin config diagnostics", async () => { const home = await createCaseHome(); const pluginDir = path.join(home, "bad-plugin"); diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index d7c3cd286a09..138a254411d9 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; +import { + DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS, + resolveSandboxBrowserConfig, + resolveSandboxDockerConfig, +} from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { @@ -53,6 +57,62 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(false); }); + it("rejects container namespace join by default", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("allows container namespace join with explicit dangerous override", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "container:peer", + dangerouslyAllowContainerNamespaceJoin: true, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("uses agent override precedence for dangerous sandbox docker booleans", () => { + for (const key of DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS) { + const inherited = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { [key]: true }, + agentDocker: {}, + }); + expect(inherited[key]).toBe(true); + + const overridden = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { [key]: true }, + agentDocker: { [key]: false }, + }); + expect(overridden[key]).toBe(false); + + const sharedScope = resolveSandboxDockerConfig({ + scope: "shared", + globalDocker: { [key]: true }, + agentDocker: { [key]: false }, + }); + expect(sharedScope[key]).toBe(true); + } + }); + it("rejects seccomp unconfined via Zod schema validation", () => { const res = validateConfigObject({ agents: { @@ -219,4 +279,37 @@ describe("sandbox browser binds config", () => { }); expect(res.ok).toBe(false); }); + + it("rejects container namespace join in sandbox.browser config by default", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + browser: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("allows container namespace join in sandbox.browser config with explicit dangerous override", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + dangerouslyAllowContainerNamespaceJoin: true, + }, + browser: { + network: "container:peer", + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index ff42403f8682..c183b34fa8e1 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -63,6 +63,18 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts channels.whatsapp.enabled", () => { + const res = validateConfigObject({ + channels: { + whatsapp: { + enabled: true, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + it("rejects unsafe iMessage remoteHost", () => { const res = validateConfigObject({ channels: { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 0d281c365667..7c652e6c3196 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,7 +2,12 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; import { resolveAgentModelPrimaryValue } from "./model-input.js"; -import { resolveTalkApiKey } from "./talk.js"; +import { + DEFAULT_TALK_PROVIDER, + normalizeTalkConfig, + resolveActiveTalkProviderConfig, + resolveTalkApiKey, +} from "./talk.js"; import type { OpenClawConfig } from "./types.js"; import type { ModelDefinitionConfig } from "./types.models.js"; @@ -163,23 +168,48 @@ export function applySessionDefaults( } export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig { + const normalized = normalizeTalkConfig(config); const resolved = resolveTalkApiKey(); if (!resolved) { - return config; + return normalized; } - const existing = config.talk?.apiKey?.trim(); - if (existing) { - return config; + + const talk = normalized.talk; + const active = resolveActiveTalkProviderConfig(talk); + if (active.provider && active.provider !== DEFAULT_TALK_PROVIDER) { + return normalized; + } + + const existingProviderApiKey = + typeof active.config?.apiKey === "string" ? active.config.apiKey.trim() : ""; + const existingLegacyApiKey = typeof talk?.apiKey === "string" ? talk.apiKey.trim() : ""; + if (existingProviderApiKey || existingLegacyApiKey) { + return normalized; } + + const providerId = active.provider ?? DEFAULT_TALK_PROVIDER; + const providers = { ...talk?.providers }; + const providerConfig = { ...providers[providerId], apiKey: resolved }; + providers[providerId] = providerConfig; + + const nextTalk = { + ...talk, + provider: talk?.provider ?? providerId, + providers, + // Keep legacy shape populated during compatibility rollout. + apiKey: resolved, + }; + return { - ...config, - talk: { - ...config.talk, - apiKey: resolved, - }, + ...normalized, + talk: nextTalk, }; } +export function applyTalkConfigNormalization(config: OpenClawConfig): OpenClawConfig { + return normalizeTalkConfig(config); +} + export function applyModelDefaults(cfg: OpenClawConfig): OpenClawConfig { let mutated = false; let nextCfg = cfg; diff --git a/src/config/group-policy.test.ts b/src/config/group-policy.test.ts index 8151f36363b5..a3ca8ad5327e 100644 --- a/src/config/group-policy.test.ts +++ b/src/config/group-policy.test.ts @@ -89,6 +89,46 @@ describe("resolveChannelGroupPolicy", () => { expect(policy.allowlistEnabled).toBe(true); expect(policy.allowed).toBe(false); }); + + it("allows groups when groupPolicy=allowlist with hasGroupAllowFrom but no groups", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: true, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(true); + }); + + it("still fails closed when groupPolicy=allowlist without groups or groupAllowFrom", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: false, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(false); + }); }); describe("resolveToolsBySender", () => { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index fe8b1542a129..fdb028f9f7c9 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -328,6 +328,8 @@ export function resolveChannelGroupPolicy(params: { groupId?: string | null; accountId?: string | null; groupIdCaseInsensitive?: boolean; + /** When true, sender-level filtering (groupAllowFrom) is configured upstream. */ + hasGroupAllowFrom?: boolean; }): ChannelGroupPolicy { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); @@ -340,8 +342,14 @@ export function resolveChannelGroupPolicy(params: { : undefined; const defaultConfig = groups?.["*"]; const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + // When groupPolicy is "allowlist" with groupAllowFrom but no explicit groups, + // allow the group through — sender-level filtering handles access control. + const senderFilterBypass = + groupPolicy === "allowlist" && !hasGroups && Boolean(params.hasGroupAllowFrom); const allowed = - groupPolicy === "disabled" ? false : !allowlistEnabled || allowAll || Boolean(groupConfig); + groupPolicy === "disabled" + ? false + : !allowlistEnabled || allowAll || Boolean(groupConfig) || senderFilterBypass; return { allowlistEnabled, allowed, diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts new file mode 100644 index 000000000000..ab56e27a659f --- /dev/null +++ b/src/config/io.eacces.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; + +function makeEaccesFs(configPath: string) { + const eaccesErr = Object.assign(new Error(`EACCES: permission denied, open '${configPath}'`), { + code: "EACCES", + }); + return { + existsSync: (p: string) => p === configPath, + readFileSync: (p: string): string => { + if (p === configPath) { + throw eaccesErr; + } + throw new Error(`unexpected readFileSync: ${p}`); + }, + promises: { + readFile: () => Promise.reject(eaccesErr), + mkdir: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + appendFile: () => Promise.resolve(), + }, + } as unknown as typeof import("node:fs"); +} + +describe("config io EACCES handling", () => { + it("returns a helpful error message when config file is not readable (EACCES)", async () => { + const configPath = "/data/.openclaw/openclaw.json"; + const errors: string[] = []; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { + error: (msg: unknown) => errors.push(String(msg)), + warn: () => {}, + }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toHaveLength(1); + expect(snapshot.issues[0].message).toContain("EACCES"); + expect(snapshot.issues[0].message).toContain("chown"); + expect(snapshot.issues[0].message).toContain(configPath); + // Should also emit to the logger + expect(errors.some((e) => e.includes("chown"))).toBe(true); + }); + + it("includes configPath in the chown hint for the correct remediation command", async () => { + const configPath = "/home/myuser/.openclaw/openclaw.json"; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { error: () => {}, warn: () => {} }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.issues[0].message).toContain(configPath); + expect(snapshot.issues[0].message).toContain("container"); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index bff292048fbd..c74992c49382 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -24,6 +24,7 @@ import { applyMessageDefaults, applyModelDefaults, applySessionDefaults, + applyTalkConfigNormalization, applyTalkApiKey, } from "./defaults.js"; import { restoreEnvVarRefs } from "./env-preserve.js"; @@ -72,6 +73,9 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENCLAW_GATEWAY_PASSWORD", ]; +const OPEN_DM_POLICY_ALLOW_FROM_RE = + /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; + const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl"; const loggedInvalidConfigs = new Set(); @@ -137,6 +141,27 @@ function hashConfigRaw(raw: string | null): string { .digest("hex"); } +function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string { + const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE); + const policyPath = match?.groups?.policyPath?.trim(); + const allowPath = match?.groups?.allowPath?.trim(); + if (!policyPath || !allowPath) { + return `Config validation failed: ${pathLabel}: ${issueMessage}`; + } + + return [ + `Config validation failed: ${pathLabel}`, + "", + `Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`, + "", + "Fix with:", + ` openclaw config set ${allowPath} '["*"]'`, + "", + "Or switch policy:", + ` openclaw config set ${policyPath} "pairing"`, + ].join("\n"); +} + function isNumericPathSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } @@ -696,11 +721,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { deps.logger.warn(`Config warnings:\\n${details}`); } warnIfConfigFromFuture(validated.config, deps.logger); - const cfg = applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + const cfg = applyTalkConfigNormalization( + applyModelDefaults( + applyCompactionDefaults( + applyContextPruningDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), ), ), @@ -785,10 +812,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!exists) { const hash = hashConfigRaw(null); const config = applyTalkApiKey( - applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + applyTalkConfigNormalization( + applyModelDefaults( + applyCompactionDefaults( + applyContextPruningDefaults( + applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), + ), ), ), ), @@ -909,9 +938,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { warnIfConfigFromFuture(validated.config, deps.logger); const snapshotConfig = normalizeConfigPaths( applyTalkApiKey( - applyModelDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + applyTalkConfigNormalization( + applyModelDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), ), ), ), @@ -936,6 +967,25 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { envSnapshotForRestore: readResolution.envSnapshotForRestore, }; } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + let message: string; + if (nodeErr?.code === "EACCES") { + // Permission denied — common in Docker/container deployments where the + // config file is owned by root but the gateway runs as a non-root user. + const uid = process.getuid?.(); + const uidHint = typeof uid === "number" ? String(uid) : "$(id -u)"; + message = [ + `read failed: ${String(err)}`, + ``, + `Config file is not readable by the current process. If running in a container`, + `or 1-click deployment, fix ownership with:`, + ` chown ${uidHint} "${configPath}"`, + `Then restart the gateway.`, + ].join("\n"); + deps.logger.error(message); + } else { + message = `read failed: ${String(err)}`; + } return { snapshot: { path: configPath, @@ -946,7 +996,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: false, config: {}, hash: hashConfigRaw(null), - issues: [{ path: "", message: `read failed: ${String(err)}` }], + issues: [{ path: "", message }], warnings: [], legacyIssues: [], }, @@ -1000,7 +1050,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : ""; - throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`); + const issueMessage = issue?.message ?? "invalid"; + throw new Error(formatConfigValidationFailure(pathLabel, issueMessage)); } if (validated.warnings.length > 0) { const details = validated.warnings diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index d8ac2bbc2805..18474914681c 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./home-env.test-harness.js"; import { createConfigIO } from "./io.js"; +import type { OpenClawConfig } from "./types.js"; describe("config io write", () => { const silentLogger = { @@ -125,6 +126,32 @@ describe("config io write", () => { }); }); + it('shows actionable guidance for dmPolicy="open" without wildcard allowFrom', async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const invalidConfig: OpenClawConfig = { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: [], + }, + }, + } satisfies OpenClawConfig; + + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + "openclaw config set channels.telegram.allowFrom '[\"*\"]'", + ); + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + 'openclaw config set channels.telegram.dmPolicy "pairing"', + ); + }); + }); + it("honors explicit unset paths when schema defaults would otherwise reappear", async () => { await withTempHome("openclaw-config-io-", async (home) => { const { configPath, io, snapshot } = await writeConfigAndCreateIo({ diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 7f5779a18189..1c289b17fdea 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,6 +1,25 @@ import { describe, expect, it } from "vitest"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; +/** Helper to build a minimal PluginManifestRegistry for testing. */ +function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): PluginManifestRegistry { + return { + plugins: plugins.map((p) => ({ + id: p.id, + channels: p.channels, + providers: [], + skills: [], + origin: "config" as const, + rootDir: `/fake/${p.id}`, + source: `/fake/${p.id}/index.js`, + manifestPath: `/fake/${p.id}/openclaw.plugin.json`, + })), + diagnostics: [], + }; +} + describe("applyPluginAutoEnable", () => { it("auto-enables built-in channels and appends to existing allowlist", () => { const result = applyPluginAutoEnable({ @@ -48,6 +67,23 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + it("keeps auto-enabled WhatsApp config schema-valid", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + env: {}, + }); + + expect(result.config.channels?.whatsapp?.enabled).toBe(true); + const validated = validateConfigObject(result.config); + expect(validated.ok).toBe(true); + }); + it("respects explicit disable", () => { const result = applyPluginAutoEnable({ config: { @@ -118,6 +154,65 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + describe("third-party channel plugins (pluginId ≠ channelId)", () => { + it("uses the plugin manifest id, not the channel id, for plugins.entries", () => { + // Reproduces: https://github.com/openclaw/openclaw/issues/25261 + // Plugin "apn-channel" declares channels: ["apn"]. Doctor must write + // plugins.entries["apn-channel"], not plugins.entries["apn"]. + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["apn"]).toBeUndefined(); + expect(result.changes.join("\n")).toContain("apn configured, enabled automatically."); + }); + + it("does not double-enable when plugin is already enabled under its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: true } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.changes).toEqual([]); + }); + + it("respects explicit disable of the plugin by its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: false } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false); + expect(result.changes).toEqual([]); + }); + + it("falls back to channel key as plugin id when no installed manifest declares the channel", () => { + // Without a matching manifest entry, behavior is unchanged (backward compat). + const result = applyPluginAutoEnable({ + config: { + channels: { "unknown-chan": { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([]), + }); + + expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true); + }); + }); + describe("preferOver channel prioritization", () => { it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => { const result = applyPluginAutoEnable({ diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 63657e3ea214..554e96843bcb 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -8,6 +8,10 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; import { hasAnyWhatsAppAuth } from "../web/accounts.js"; import type { OpenClawConfig } from "./config.js"; @@ -45,7 +49,7 @@ function recordHasKeys(value: unknown): boolean { return isRecord(value) && Object.keys(value).length > 0; } -function accountsHaveKeys(value: unknown, keys: string[]): boolean { +function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean { if (!isRecord(value)) { return false; } @@ -71,108 +75,95 @@ function resolveChannelConfig( return isRecord(entry) ? entry : null; } -function isTelegramConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.TELEGRAM_BOT_TOKEN)) { - return true; - } - const entry = resolveChannelConfig(cfg, "telegram"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.botToken) || hasNonEmptyString(entry.tokenFile)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["botToken", "tokenFile"])) { - return true; +type StructuredChannelConfigSpec = { + envAny?: readonly string[]; + envAll?: readonly string[]; + stringKeys?: readonly string[]; + numberKeys?: readonly string[]; + accountStringKeys?: readonly string[]; +}; + +const STRUCTURED_CHANNEL_CONFIG_SPECS: Record = { + telegram: { + envAny: ["TELEGRAM_BOT_TOKEN"], + stringKeys: ["botToken", "tokenFile"], + accountStringKeys: ["botToken", "tokenFile"], + }, + discord: { + envAny: ["DISCORD_BOT_TOKEN"], + stringKeys: ["token"], + accountStringKeys: ["token"], + }, + irc: { + envAll: ["IRC_HOST", "IRC_NICK"], + stringKeys: ["host", "nick"], + accountStringKeys: ["host", "nick"], + }, + slack: { + envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], + stringKeys: ["botToken", "appToken", "userToken"], + accountStringKeys: ["botToken", "appToken", "userToken"], + }, + signal: { + stringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + numberKeys: ["httpPort"], + accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + }, + imessage: { + stringKeys: ["cliPath"], + }, +}; + +function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (hasNonEmptyString(env[key])) { + return true; + } } - return recordHasKeys(entry); + return false; } -function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.DISCORD_BOT_TOKEN)) { - return true; - } - const entry = resolveChannelConfig(cfg, "discord"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.token)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["token"])) { - return true; +function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (!hasNonEmptyString(env[key])) { + return false; + } } - return recordHasKeys(entry); + return keys.length > 0; } -function isIrcConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasNonEmptyString(env.IRC_HOST) && hasNonEmptyString(env.IRC_NICK)) { - return true; - } - const entry = resolveChannelConfig(cfg, "irc"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.host) || hasNonEmptyString(entry.nick)) { - return true; - } - if (accountsHaveKeys(entry.accounts, ["host", "nick"])) { - return true; +function hasAnyNumberKeys(entry: Record, keys: readonly string[]): boolean { + for (const key of keys) { + if (typeof entry[key] === "number") { + return true; + } } - return recordHasKeys(entry); + return false; } -function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if ( - hasNonEmptyString(env.SLACK_BOT_TOKEN) || - hasNonEmptyString(env.SLACK_APP_TOKEN) || - hasNonEmptyString(env.SLACK_USER_TOKEN) - ) { - return true; - } - const entry = resolveChannelConfig(cfg, "slack"); - if (!entry) { - return false; - } - if ( - hasNonEmptyString(entry.botToken) || - hasNonEmptyString(entry.appToken) || - hasNonEmptyString(entry.userToken) - ) { +function isStructuredChannelConfigured( + cfg: OpenClawConfig, + channelId: string, + env: NodeJS.ProcessEnv, + spec: StructuredChannelConfigSpec, +): boolean { + if (spec.envAny && envHasAnyKeys(env, spec.envAny)) { return true; } - if (accountsHaveKeys(entry.accounts, ["botToken", "appToken", "userToken"])) { + if (spec.envAll && envHasAllKeys(env, spec.envAll)) { return true; } - return recordHasKeys(entry); -} - -function isSignalConfigured(cfg: OpenClawConfig): boolean { - const entry = resolveChannelConfig(cfg, "signal"); + const entry = resolveChannelConfig(cfg, channelId); if (!entry) { return false; } - if ( - hasNonEmptyString(entry.account) || - hasNonEmptyString(entry.httpUrl) || - hasNonEmptyString(entry.httpHost) || - typeof entry.httpPort === "number" || - hasNonEmptyString(entry.cliPath) - ) { + if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) { return true; } - if (accountsHaveKeys(entry.accounts, ["account", "httpUrl", "httpHost", "cliPath"])) { + if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) { return true; } - return recordHasKeys(entry); -} - -function isIMessageConfigured(cfg: OpenClawConfig): boolean { - const entry = resolveChannelConfig(cfg, "imessage"); - if (!entry) { - return false; - } - if (hasNonEmptyString(entry.cliPath)) { + if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) { return true; } return recordHasKeys(entry); @@ -199,24 +190,14 @@ export function isChannelConfigured( channelId: string, env: NodeJS.ProcessEnv = process.env, ): boolean { - switch (channelId) { - case "whatsapp": - return isWhatsAppConfigured(cfg); - case "telegram": - return isTelegramConfigured(cfg, env); - case "discord": - return isDiscordConfigured(cfg, env); - case "irc": - return isIrcConfigured(cfg, env); - case "slack": - return isSlackConfigured(cfg, env); - case "signal": - return isSignalConfigured(cfg); - case "imessage": - return isIMessageConfigured(cfg); - default: - return isGenericChannelConfigured(cfg, channelId); + if (channelId === "whatsapp") { + return isWhatsAppConfigured(cfg); } + const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId]; + if (spec) { + return isStructuredChannelConfigured(cfg, channelId, env, spec); + } + return isGenericChannelConfigured(cfg, channelId); } function collectModelRefs(cfg: OpenClawConfig): string[] { @@ -309,32 +290,62 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean return false; } -function resolveConfiguredPlugins( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv, -): PluginEnableChange[] { - const changes: PluginEnableChange[] = []; - const channelIds = new Set(CHANNEL_PLUGIN_IDS); - const configuredChannels = cfg.channels as Record | undefined; - if (configuredChannels && typeof configuredChannels === "object") { - for (const key of Object.keys(configuredChannels)) { - if (key === "defaults" || key === "modelByChannel") { - continue; +function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { + const map = new Map(); + for (const record of registry.plugins) { + for (const channelId of record.channels) { + if (channelId && !map.has(channelId)) { + map.set(channelId, record.id); } - channelIds.add(normalizeChatChannelId(key) ?? key); } } - for (const channelId of channelIds) { - if (!channelId) { + return map; +} + +function resolvePluginIdForChannel( + channelId: string, + channelToPluginId: ReadonlyMap, +): string { + // Third-party plugins can expose a channel id that differs from their + // manifest id; plugins.entries must always be keyed by manifest id. + const builtInId = normalizeChatChannelId(channelId); + if (builtInId) { + return builtInId; + } + return channelToPluginId.get(channelId) ?? channelId; +} + +function collectCandidateChannelIds(cfg: OpenClawConfig): string[] { + const channelIds = new Set(CHANNEL_PLUGIN_IDS); + const configuredChannels = cfg.channels as Record | undefined; + if (!configuredChannels || typeof configuredChannels !== "object") { + return Array.from(channelIds); + } + for (const key of Object.keys(configuredChannels)) { + if (key === "defaults" || key === "modelByChannel") { continue; } + const normalizedBuiltIn = normalizeChatChannelId(key); + channelIds.add(normalizedBuiltIn ?? key); + } + return Array.from(channelIds); +} + +function resolveConfiguredPlugins( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, + registry: PluginManifestRegistry, +): PluginEnableChange[] { + const changes: PluginEnableChange[] = []; + // Build reverse map: channel ID → plugin ID from installed plugin manifests. + const channelToPluginId = buildChannelToPluginIdMap(registry); + for (const channelId of collectCandidateChannelIds(cfg)) { + const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId); if (isChannelConfigured(cfg, channelId, env)) { - changes.push({ - pluginId: channelId, - reason: `${channelId} configured`, - }); + changes.push({ pluginId, reason: `${channelId} configured` }); } } + for (const mapping of PROVIDER_PLUGIN_IDS) { if (isProviderConfigured(cfg, mapping.providerId)) { changes.push({ @@ -450,9 +461,14 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { export function applyPluginAutoEnable(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; + /** Pre-loaded manifest registry. When omitted, the registry is loaded from + * the installed plugins on disk. Pass an explicit registry in tests to + * avoid filesystem access and control what plugins are "installed". */ + manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const configured = resolveConfiguredPlugins(params.config, env); + const registry = params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config }); + const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 387f351947bd..6634b115dbf0 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -133,6 +133,26 @@ export const FIELD_HELP: Record = { "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "talk.provider": 'Active Talk provider id (for example "elevenlabs").', + "talk.providers": + "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", + "talk.providers.*.voiceId": "Provider default voice ID for Talk mode.", + "talk.providers.*.voiceAliases": "Optional provider voice alias map for Talk directives.", + "talk.providers.*.modelId": "Provider default model ID for Talk mode.", + "talk.providers.*.outputFormat": "Provider default output format for Talk mode.", + "talk.providers.*.apiKey": "Provider API key for Talk mode.", + "talk.voiceId": + "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", + "talk.voiceAliases": + 'Use this legacy ElevenLabs voice alias map (for example {"Clawd":"EXAVITQu4vr4xnSDxMaL"}) only during migration. Prefer talk.providers.elevenlabs.voiceAliases.', + "talk.modelId": + "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", + "talk.outputFormat": + "Use this legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128) only during migration. Prefer talk.providers.elevenlabs.outputFormat.", + "talk.apiKey": + "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", + "talk.interruptOnSpeech": + "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", "agents.list.*.skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].skills": @@ -273,24 +293,16 @@ export const FIELD_HELP: Record = { "canvasHost.liveReload": "Enables automatic live-reload behavior for canvas assets during development workflows. Keep disabled in production-like environments where deterministic output is preferred.", talk: "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", - "talk.voiceId": - "Primary voice identifier used by talk mode when synthesizing spoken responses. Use a stable voice for consistent persona and switch only when experience goals change.", - "talk.voiceAliases": - "Alias map for human-friendly voice shortcuts to concrete voice IDs in talk workflows. Use aliases to simplify operator switching without exposing long provider-native IDs.", - "talk.modelId": - "Model override used for talk pipeline generation when voice workflows require different model behavior. Use this when speech output needs a specialized low-latency or style-tuned model.", - "talk.outputFormat": - "Audio output format for synthesized talk responses, depending on provider support and client playback expectations. Use formats compatible with your playback channel to avoid decode failures.", - "talk.interruptOnSpeech": - "When true, interrupts current speech playback on new speech/input events for more conversational turn-taking. Keep enabled for interactive voice UX and disable for uninterrupted long-form playback.", - "talk.apiKey": - "Optional talk-provider API key override used specifically for speech synthesis requests. Use env-backed secrets and set this only when talk traffic must use separate credentials.", "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "agents.defaults.sandbox.browser.network": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "agents.list[].sandbox.browser.network": "Per-agent override for sandbox browser Docker network.", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "agents.defaults.sandbox.browser.cdpSourceRange": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "agents.list[].sandbox.browser.cdpSourceRange": @@ -1368,6 +1380,10 @@ export const FIELD_HELP: Record = { "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "channels.discord.voice.autoJoin": "Voice channels to auto-join on startup (list of guildId/channelId entries).", + "channels.discord.voice.daveEncryption": + "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", + "channels.discord.voice.decryptionFailureTolerance": + "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "channels.discord.voice.tts": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "channels.discord.intents.presence": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index de25f7e4e028..a9dc9244a146 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -417,6 +417,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Sandbox Docker Allow Container Namespace Join", commands: "Commands", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", @@ -612,6 +614,13 @@ export const FIELD_LABELS: Record = { "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", "messages.inbound.byChannel": "Inbound Debounce by Channel (ms)", "messages.tts": "Message Text-to-Speech", + "talk.provider": "Talk Active Provider", + "talk.providers": "Talk Provider Settings", + "talk.providers.*.voiceId": "Talk Provider Voice ID", + "talk.providers.*.voiceAliases": "Talk Provider Voice Aliases", + "talk.providers.*.modelId": "Talk Provider Model ID", + "talk.providers.*.outputFormat": "Talk Provider Output Format", + "talk.providers.*.apiKey": "Talk Provider API Key", "talk.apiKey": "Talk API Key", channels: "Channels", "channels.defaults": "Channel Defaults", @@ -680,6 +689,8 @@ export const FIELD_LABELS: Record = { "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.voice.enabled": "Discord Voice Enabled", "channels.discord.voice.autoJoin": "Discord Voice Auto-Join", + "channels.discord.voice.daveEncryption": "Discord Voice DAVE Encryption", + "channels.discord.voice.decryptionFailureTolerance": "Discord Voice Decrypt Failure Tolerance", "channels.discord.voice.tts": "Discord Voice Text-to-Speech", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", "channels.discord.pluralkit.token": "Discord PluralKit Token", @@ -718,6 +729,8 @@ export const FIELD_LABELS: Record = { "Agent Heartbeat Suppress Tool Error Warnings", "agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network", "agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Agent Sandbox Docker Allow Container Namespace Join", "discovery.mdns.mode": "mDNS Discovery Mode", plugins: "Plugins", "plugins.enabled": "Enable Plugins", diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 1bcbac5711c7..10ac5a13b453 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { clearSessionStoreCacheForTest, loadSessionStore, + mergeSessionEntry, resolveAndPersistSessionFile, updateSessionStore, } from "../sessions.js"; @@ -215,6 +216,42 @@ describe("session store lock (Promise chain mutex)", () => { const store = loadSessionStore(storePath); expect(store[key]?.modelOverride).toBe("recovered"); }); + + it("clears stale runtime provider when model is patched without provider", () => { + const merged = mergeSessionEntry( + { + sessionId: "sess-runtime", + updatedAt: 100, + modelProvider: "anthropic", + model: "claude-opus-4-6", + }, + { + model: "gpt-5.2", + }, + ); + expect(merged.model).toBe("gpt-5.2"); + expect(merged.modelProvider).toBeUndefined(); + }); + + it("normalizes orphan modelProvider fields at store write boundary", async () => { + const key = "agent:main:orphan-provider"; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-orphan", + updatedAt: 100, + modelProvider: "anthropic", + }, + }); + + await updateSessionStore(storePath, async (store) => { + const entry = store[key]; + entry.updatedAt = Date.now(); + }); + + const store = loadSessionStore(storePath); + expect(store[key]?.modelProvider).toBeUndefined(); + expect(store[key]?.model).toBeUndefined(); + }); }); describe("appendAssistantMessageToSessionTranscript", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 210ebc99963a..d721cf4ad3ed 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -22,7 +22,11 @@ import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; import { deriveSessionMetaPatch } from "./metadata.js"; -import { mergeSessionEntry, type SessionEntry } from "./types.js"; +import { + mergeSessionEntry, + normalizeSessionRuntimeModelFields, + type SessionEntry, +} from "./types.js"; const log = createSubsystemLogger("sessions/store"); @@ -157,7 +161,7 @@ function normalizeSessionStore(store: Record): void { if (!entry) { continue; } - const normalized = normalizeSessionEntryDelivery(entry); + const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)); if (normalized !== entry) { store[key] = normalized; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 25091cd065ea..e00772677429 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -114,6 +114,65 @@ export type SessionEntry = { systemPromptReport?: SessionSystemPromptReport; }; +function normalizeRuntimeField(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry { + const normalizedModel = normalizeRuntimeField(entry.model); + const normalizedProvider = normalizeRuntimeField(entry.modelProvider); + let next = entry; + + if (!normalizedModel) { + if (entry.model !== undefined || entry.modelProvider !== undefined) { + next = { ...next }; + delete next.model; + delete next.modelProvider; + } + return next; + } + + if (entry.model !== normalizedModel) { + if (next === entry) { + next = { ...next }; + } + next.model = normalizedModel; + } + + if (!normalizedProvider) { + if (entry.modelProvider !== undefined) { + if (next === entry) { + next = { ...next }; + } + delete next.modelProvider; + } + return next; + } + + if (entry.modelProvider !== normalizedProvider) { + if (next === entry) { + next = { ...next }; + } + next.modelProvider = normalizedProvider; + } + return next; +} + +export function setSessionRuntimeModel( + entry: SessionEntry, + runtime: { provider: string; model: string }, +): boolean { + const provider = runtime.provider.trim(); + const model = runtime.model.trim(); + if (!provider || !model) { + return false; + } + entry.modelProvider = provider; + entry.model = model; + return true; +} + export function mergeSessionEntry( existing: SessionEntry | undefined, patch: Partial, @@ -121,9 +180,20 @@ export function mergeSessionEntry( const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now()); if (!existing) { - return { ...patch, sessionId, updatedAt }; + return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt }); + } + const next = { ...existing, ...patch, sessionId, updatedAt }; + + // Guard against stale provider carry-over when callers patch runtime model + // without also patching runtime provider. + if (Object.hasOwn(patch, "model") && !Object.hasOwn(patch, "modelProvider")) { + const patchedModel = normalizeRuntimeField(patch.model); + const existingModel = normalizeRuntimeField(existing.model); + if (patchedModel && patchedModel !== existingModel) { + delete next.modelProvider; + } } - return { ...existing, ...patch, sessionId, updatedAt }; + return normalizeSessionRuntimeModelFields(next); } export function resolveFreshSessionTotalTokens( diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts new file mode 100644 index 000000000000..a61af099bf31 --- /dev/null +++ b/src/config/talk.normalize.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; +import { normalizeTalkSection } from "./talk.js"; + +async function withTempConfig( + config: unknown, + run: (configPath: string) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-talk-")); + const configPath = path.join(dir, "openclaw.json"); + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + try { + await run(configPath); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +async function withEnv( + updates: Record, + run: () => Promise, +): Promise { + const previous = new Map(); + for (const [key, value] of Object.entries(updates)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + await run(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("talk normalization", () => { + it("maps legacy ElevenLabs fields into provider/providers", () => { + const normalized = normalizeTalkSection({ + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + }); + + expect(normalized).toEqual({ + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + }, + }, + voiceId: "voice-123", + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + }); + }); + + it("uses new provider/providers shape directly when present", () => { + const normalized = normalizeTalkSection({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + custom: true, + }, + }, + voiceId: "legacy-voice", + interruptOnSpeech: true, + }); + + expect(normalized).toEqual({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + custom: true, + }, + }, + voiceId: "legacy-voice", + interruptOnSpeech: true, + }); + }); + + it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => { + await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withTempConfig( + { + talk: { + voiceId: "voice-123", + }, + }, + async (configPath) => { + const io = createConfigIO({ configPath }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.config.talk?.provider).toBe("elevenlabs"); + expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); + expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe("env-eleven-key"); + expect(snapshot.config.talk?.apiKey).toBe("env-eleven-key"); + }, + ); + }); + }); + + it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => { + await withEnv({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withTempConfig( + { + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + }, + }, + }, + }, + async (configPath) => { + const io = createConfigIO({ configPath }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.config.talk?.provider).toBe("acme"); + expect(snapshot.config.talk?.providers?.acme?.voiceId).toBe("acme-voice"); + expect(snapshot.config.talk?.providers?.acme?.apiKey).toBeUndefined(); + expect(snapshot.config.talk?.apiKey).toBeUndefined(); + }, + ); + }); + }); +}); diff --git a/src/config/talk.ts b/src/config/talk.ts index f7856dc67965..e8de2e398019 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { TalkConfig, TalkProviderConfig } from "./types.gateway.js"; +import type { OpenClawConfig } from "./types.js"; type TalkApiKeyDeps = { fs?: typeof fs; @@ -8,6 +10,266 @@ type TalkApiKeyDeps = { path?: typeof path; }; +export const DEFAULT_TALK_PROVIDER = "elevenlabs"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeVoiceAliases(value: unknown): Record | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const aliases: Record = {}; + for (const [alias, rawId] of Object.entries(value)) { + if (typeof rawId !== "string") { + continue; + } + aliases[alias] = rawId; + } + return Object.keys(aliases).length > 0 ? aliases : undefined; +} + +function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const provider: TalkProviderConfig = {}; + for (const [key, raw] of Object.entries(value)) { + if (raw === undefined) { + continue; + } + if (key === "voiceAliases") { + const aliases = normalizeVoiceAliases(raw); + if (aliases) { + provider.voiceAliases = aliases; + } + continue; + } + if (key === "voiceId" || key === "modelId" || key === "outputFormat" || key === "apiKey") { + const normalized = normalizeString(raw); + if (normalized) { + provider[key] = normalized; + } + continue; + } + provider[key] = raw; + } + + return Object.keys(provider).length > 0 ? provider : undefined; +} + +function normalizeTalkProviders(value: unknown): Record | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const providers: Record = {}; + for (const [rawProviderId, providerConfig] of Object.entries(value)) { + const providerId = normalizeString(rawProviderId); + if (!providerId) { + continue; + } + const normalizedProvider = normalizeTalkProviderConfig(providerConfig); + if (!normalizedProvider) { + continue; + } + providers[providerId] = normalizedProvider; + } + return Object.keys(providers).length > 0 ? providers : undefined; +} + +function normalizedLegacyTalkFields(source: Record): Partial { + const legacy: Partial = {}; + const voiceId = normalizeString(source.voiceId); + if (voiceId) { + legacy.voiceId = voiceId; + } + const voiceAliases = normalizeVoiceAliases(source.voiceAliases); + if (voiceAliases) { + legacy.voiceAliases = voiceAliases; + } + const modelId = normalizeString(source.modelId); + if (modelId) { + legacy.modelId = modelId; + } + const outputFormat = normalizeString(source.outputFormat); + if (outputFormat) { + legacy.outputFormat = outputFormat; + } + const apiKey = normalizeString(source.apiKey); + if (apiKey) { + legacy.apiKey = apiKey; + } + return legacy; +} + +function legacyProviderConfigFromTalk( + source: Record, +): TalkProviderConfig | undefined { + return normalizeTalkProviderConfig({ + voiceId: source.voiceId, + voiceAliases: source.voiceAliases, + modelId: source.modelId, + outputFormat: source.outputFormat, + apiKey: source.apiKey, + }); +} + +function activeProviderFromTalk(talk: TalkConfig): string | undefined { + const provider = normalizeString(talk.provider); + if (provider) { + return provider; + } + const providerIds = talk.providers ? Object.keys(talk.providers) : []; + return providerIds.length === 1 ? providerIds[0] : undefined; +} + +function legacyTalkFieldsFromProviderConfig( + config: TalkProviderConfig | undefined, +): Partial { + if (!config) { + return {}; + } + const legacy: Partial = {}; + if (typeof config.voiceId === "string") { + legacy.voiceId = config.voiceId; + } + if ( + config.voiceAliases && + typeof config.voiceAliases === "object" && + !Array.isArray(config.voiceAliases) + ) { + const aliases = normalizeVoiceAliases(config.voiceAliases); + if (aliases) { + legacy.voiceAliases = aliases; + } + } + if (typeof config.modelId === "string") { + legacy.modelId = config.modelId; + } + if (typeof config.outputFormat === "string") { + legacy.outputFormat = config.outputFormat; + } + if (typeof config.apiKey === "string") { + legacy.apiKey = config.apiKey; + } + return legacy; +} + +export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const source = value as Record; + const hasNormalizedShape = typeof source.provider === "string" || isPlainObject(source.providers); + const normalized: TalkConfig = {}; + const legacy = normalizedLegacyTalkFields(source); + if (Object.keys(legacy).length > 0) { + Object.assign(normalized, legacy); + } + if (typeof source.interruptOnSpeech === "boolean") { + normalized.interruptOnSpeech = source.interruptOnSpeech; + } + + if (hasNormalizedShape) { + const providers = normalizeTalkProviders(source.providers); + const provider = normalizeString(source.provider); + if (providers) { + normalized.providers = providers; + } + if (provider) { + normalized.provider = provider; + } else if (providers) { + const ids = Object.keys(providers); + if (ids.length === 1) { + normalized.provider = ids[0]; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; + } + + const legacyProviderConfig = legacyProviderConfigFromTalk(source); + if (legacyProviderConfig) { + normalized.provider = DEFAULT_TALK_PROVIDER; + normalized.providers = { [DEFAULT_TALK_PROVIDER]: legacyProviderConfig }; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig { + if (!config.talk) { + return config; + } + const normalizedTalk = normalizeTalkSection(config.talk); + if (!normalizedTalk) { + return config; + } + return { + ...config, + talk: normalizedTalk, + }; +} + +export function resolveActiveTalkProviderConfig(talk: TalkConfig | undefined): { + provider?: string; + config?: TalkProviderConfig; +} { + const normalizedTalk = normalizeTalkSection(talk); + if (!normalizedTalk) { + return {}; + } + const provider = activeProviderFromTalk(normalizedTalk); + if (!provider) { + return {}; + } + return { + provider, + config: normalizedTalk.providers?.[provider], + }; +} + +export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const normalized = normalizeTalkSection(value as TalkConfig); + if (!normalized) { + return undefined; + } + + const payload: TalkConfig = {}; + if (typeof normalized.interruptOnSpeech === "boolean") { + payload.interruptOnSpeech = normalized.interruptOnSpeech; + } + if (normalized.providers && Object.keys(normalized.providers).length > 0) { + payload.providers = normalized.providers; + } + if (typeof normalized.provider === "string") { + payload.provider = normalized.provider; + } + + const activeProvider = activeProviderFromTalk(normalized); + const providerConfig = activeProvider ? normalized.providers?.[activeProvider] : undefined; + const providerCompatibilityLegacy = legacyTalkFieldsFromProviderConfig(providerConfig); + const compatibilityLegacy = + Object.keys(providerCompatibilityLegacy).length > 0 + ? providerCompatibilityLegacy + : normalizedLegacyTalkFields(normalized as unknown as Record); + Object.assign(payload, compatibilityLegacy); + + return Object.keys(payload).length > 0 ? payload : undefined; +} + export function readTalkApiKeyFromProfile(deps: TalkApiKeyDeps = {}): string | null { const fsImpl = deps.fs ?? fs; const osImpl = deps.os ?? os; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 818df3db3804..bc0cb6334306 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -247,6 +247,8 @@ export type AgentDefaultsConfig = { model?: AgentModelConfig; /** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */ thinking?: string; + /** Default run timeout in seconds for spawned sub-agents (0 = no timeout). */ + runTimeoutSeconds?: number; /** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */ announceTimeoutMs?: number; }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 0d795c94bb4e..1b43ddeb48b3 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -107,6 +107,10 @@ export type DiscordVoiceConfig = { enabled?: boolean; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; + /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ + daveEncryption?: boolean; + /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ + decryptionFailureTolerance?: number; /** Optional TTS overrides for Discord voice output. */ tts?: TtsConfig; }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 5a18da096786..5e644db40eb4 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -46,19 +46,38 @@ export type CanvasHostConfig = { liveReload?: boolean; }; -export type TalkConfig = { - /** Default ElevenLabs voice ID for Talk mode. */ +export type TalkProviderConfig = { + /** Default voice ID for the provider's Talk mode implementation. */ voiceId?: string; - /** Optional voice name -> ElevenLabs voice ID map. */ + /** Optional voice name -> provider voice ID map. */ voiceAliases?: Record; - /** Default ElevenLabs model ID for Talk mode. */ + /** Default provider model ID for Talk mode. */ modelId?: string; - /** Default ElevenLabs output format (e.g. mp3_44100_128). */ + /** Default provider output format (for example pcm_44100). */ outputFormat?: string; - /** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */ + /** Provider API key (optional; provider-specific env fallback may apply). */ apiKey?: string; + /** Provider-specific extensions. */ + [key: string]: unknown; +}; + +export type TalkConfig = { + /** Active Talk TTS provider (for example "elevenlabs"). */ + provider?: string; + /** Provider-specific Talk config keyed by provider id. */ + providers?: Record; /** Stop speaking when user starts talking (default: true). */ interruptOnSpeech?: boolean; + + /** + * Legacy ElevenLabs compatibility fields. + * Kept during rollout while older clients migrate to provider/providers. + */ + voiceId?: string; + voiceAliases?: Record; + modelId?: string; + outputFormat?: string; + apiKey?: string; }; export type GatewayControlUiConfig = { diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 0d7ecfc8a970..b4d5e6e20270 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -52,6 +52,11 @@ export type SandboxDockerSettings = { * (workspace + agent workspace roots). */ dangerouslyAllowExternalBindSources?: boolean; + /** + * Dangerous override: allow Docker `network: "container:"` namespace joins. + * Default behavior blocks container namespace joins to preserve sandbox isolation. + */ + dangerouslyAllowContainerNamespaceJoin?: boolean; }; export type SandboxBrowserSettings = { diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 6fa99ea7b845..395ce3b06b2a 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -36,6 +36,8 @@ export type WhatsAppAckReactionConfig = { }; type WhatsAppSharedConfig = { + /** Whether the WhatsApp channel is enabled. */ + enabled?: boolean; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; /** Same-phone setup (bot uses your personal WhatsApp number). */ diff --git a/src/config/validation.ts b/src/config/validation.ts index f2ee18674855..746f89ef0e4a 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -22,6 +22,8 @@ import { findLegacyConfigIssues } from "./legacy.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; +const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); + function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean { const workspaceRoot = path.resolve(workspaceDir); const resolved = path.resolve(workspaceRoot, value); @@ -313,6 +315,19 @@ function validateConfigObjectWithPluginsBase( } const { registry, knownIds, normalizedPlugins } = ensureRegistry(); + const pushMissingPluginIssue = (path: string, pluginId: string) => { + if (LEGACY_REMOVED_PLUGIN_IDS.has(pluginId)) { + warnings.push({ + path, + message: `plugin removed: ${pluginId} (stale config entry ignored; remove it from plugins config)`, + }); + return; + } + issues.push({ + path, + message: `plugin not found: ${pluginId}`, + }); + }; const pluginsConfig = config.plugins; @@ -320,10 +335,7 @@ function validateConfigObjectWithPluginsBase( if (entries && isRecord(entries)) { for (const pluginId of Object.keys(entries)) { if (!knownIds.has(pluginId)) { - issues.push({ - path: `plugins.entries.${pluginId}`, - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue(`plugins.entries.${pluginId}`, pluginId); } } } @@ -334,10 +346,7 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - issues.push({ - path: "plugins.allow", - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue("plugins.allow", pluginId); } } @@ -347,19 +356,13 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - issues.push({ - path: "plugins.deny", - message: `plugin not found: ${pluginId}`, - }); + pushMissingPluginIssue("plugins.deny", pluginId); } } const memorySlot = normalizedPlugins.slots.memory; if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) { - issues.push({ - path: "plugins.slots.memory", - message: `plugin not found: ${memorySlot}`, - }); + pushMissingPluginIssue("plugins.slots.memory", memorySlot); } let selectedMemoryPluginId: string | null = null; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a02f4c789d2d..de5fceca0d45 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,6 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), + runTimeoutSeconds: z.number().int().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict() diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 70af26e698d4..685842d66020 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { AgentModelSchema } from "./zod-schema.agent-model.js"; import { @@ -126,6 +127,7 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), dangerouslyAllowReservedContainerTargets: z.boolean().optional(), dangerouslyAllowExternalBindSources: z.boolean().optional(), + dangerouslyAllowContainerNamespaceJoin: z.boolean().optional(), }) .strict() .superRefine((data, ctx) => { @@ -153,7 +155,11 @@ export const SandboxDockerSchema = z } } } - if (data.network?.trim().toLowerCase() === "host") { + const blockedNetworkReason = getBlockedNetworkModeReason({ + network: data.network, + allowContainerNamespaceJoin: data.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedNetworkReason === "host") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -161,6 +167,15 @@ export const SandboxDockerSchema = z 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', }); } + if (blockedNetworkReason === "container_namespace_join") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "container:*" is blocked by default. ' + + "Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + }); + } if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -476,6 +491,21 @@ export const AgentSandboxSchema = z prune: SandboxPruneSchema, }) .strict() + .superRefine((data, ctx) => { + const blockedBrowserNetworkReason = getBlockedNetworkModeReason({ + network: data.browser?.network, + allowContainerNamespaceJoin: data.docker?.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedBrowserNetworkReason === "container_namespace_join") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["browser", "network"], + message: + 'Sandbox security: browser network mode "container:*" is blocked by default. ' + + "Set sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.", + }); + } + }) .optional(); const CommonToolPolicyFields = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index bccbb5bdd35f..806eb8f89ce3 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -315,6 +315,8 @@ const DiscordVoiceSchema = z .object({ enabled: z.boolean().optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), + daveEncryption: z.boolean().optional(), + decryptionFailureTolerance: z.number().int().min(0).optional(), tts: TtsConfigSchema.optional(), }) .strict() diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 92c6daeffc3e..4387ed1abb59 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -32,6 +32,7 @@ const WhatsAppAckReactionSchema = z .optional(); const WhatsAppSharedSchema = z.object({ + enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index bc77227de28b..57ae259a9f9b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -129,7 +129,21 @@ export const OpenClawSchema = z meta: z .object({ lastTouchedVersion: z.string().optional(), - lastTouchedAt: z.string().optional(), + // Accept any string unchanged (backwards-compatible) and coerce numeric Unix + // timestamps to ISO strings (agent file edits may write Date.now()). + lastTouchedAt: z + .union([ + z.string(), + z.number().transform((n, ctx) => { + const d = new Date(n); + if (Number.isNaN(d.getTime())) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid timestamp" }); + return z.NEVER; + } + return d.toISOString(); + }), + ]) + .optional(), }) .strict() .optional(), @@ -426,6 +440,21 @@ export const OpenClawSchema = z .optional(), talk: z .object({ + provider: z.string().optional(), + providers: z + .record( + z.string(), + z + .object({ + voiceId: z.string().optional(), + voiceAliases: z.record(z.string(), z.string()).optional(), + modelId: z.string().optional(), + outputFormat: z.string().optional(), + apiKey: z.string().optional().register(sensitive), + }) + .catchall(z.unknown()), + ) + .optional(), voiceId: z.string().optional(), voiceAliases: z.record(z.string(), z.string()).optional(), modelId: z.string().optional(), diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index abb27177a54f..353d92e1b858 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -27,9 +27,9 @@ function makeDeps(): CliDeps { }; } -function mockEmbeddedTexts(texts: string[]) { +function mockEmbeddedPayloads(payloads: Array<{ text?: string; isError?: boolean }>) { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: texts.map((text) => ({ text })), + payloads, meta: { durationMs: 5, agentMeta: { sessionId: "s", provider: "p", model: "m" }, @@ -37,6 +37,10 @@ function mockEmbeddedTexts(texts: string[]) { }); } +function mockEmbeddedTexts(texts: string[]) { + mockEmbeddedPayloads(texts.map((text) => ({ text }))); +} + function mockEmbeddedOk() { mockEmbeddedTexts(["ok"]); } @@ -174,6 +178,25 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("returns error when embedded run payload is marked as error", async () => { + await withTempHome(async (home) => { + mockEmbeddedPayloads([ + { + text: "⚠️ 🛠️ Exec failed: /bin/bash: line 1: python: command not found", + isError: true, + }, + ]); + const { res } = await runCronTurn(home, { + jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, + mockTexts: null, + }); + + expect(res.status).toBe("error"); + expect(res.error).toContain("command not found"); + expect(res.summary).toContain("Exec failed"); + }); + }); + it("passes resolved agentDir to runEmbeddedPiAgent", async () => { await withTempHome(async (home) => { const { res } = await runCronTurn(home, { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index da8ea6ed060f..8000b88481b5 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -32,7 +32,11 @@ import { } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js"; +import { + resolveSessionTranscriptPath, + setSessionRuntimeModel, + updateSessionStore, +} from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; @@ -489,8 +493,10 @@ export async function runCronIsolatedAgentTurn(params: { const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; - cronSession.sessionEntry.modelProvider = providerUsed; - cronSession.sessionEntry.model = modelUsed; + setSessionRuntimeModel(cronSession.sessionEntry, { + provider: providerUsed, + model: modelUsed, + }); cronSession.sessionEntry.contextTokens = contextTokens; if (isCliProvider(providerUsed, cfgWithAgentDefaults)) { const cliSessionId = runResult.meta?.agentMeta?.sessionId?.trim(); @@ -551,6 +557,25 @@ export async function runCronIsolatedAgentTurn(params: { (deliveryPayload?.mediaUrls?.length ?? 0) > 0 || Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); + const hasErrorPayload = payloads.some((payload) => payload?.isError === true); + const lastErrorPayloadText = [...payloads] + .toReversed() + .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) + ?.text?.trim(); + const embeddedRunError = hasErrorPayload + ? (lastErrorPayloadText ?? "cron isolated run returned an error payload") + : undefined; + const resolveRunOutcome = (params?: { delivered?: boolean }) => + withRunSession({ + status: hasErrorPayload ? "error" : "ok", + ...(hasErrorPayload + ? { error: embeddedRunError ?? "cron isolated run returned an error payload" } + : {}), + summary, + outputText, + delivered: params?.delivered, + ...telemetry, + }); // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); @@ -594,11 +619,14 @@ export async function runCronIsolatedAgentTurn(params: { withRunSession, }); if (deliveryResult.result) { - return deliveryResult.result; + if (!hasErrorPayload || deliveryResult.result.status !== "ok") { + return deliveryResult.result; + } + return resolveRunOutcome({ delivered: deliveryResult.result.delivered }); } const delivered = deliveryResult.delivered; summary = deliveryResult.summary; outputText = deliveryResult.outputText; - return withRunSession({ status: "ok", summary, outputText, delivered, ...telemetry }); + return resolveRunOutcome({ delivered }); } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 7d91e6b32ca5..7460cc0632fb 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -200,6 +200,18 @@ export async function installSystemdService({ const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); + + // Preserve user customizations: back up existing unit file before overwriting. + let backedUp = false; + try { + await fs.access(unitPath); + const backupPath = `${unitPath}.bak`; + await fs.copyFile(unitPath, backupPath); + backedUp = true; + } catch { + // File does not exist yet — nothing to back up. + } + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const unit = buildSystemdUnit({ description: serviceDescription, @@ -227,8 +239,24 @@ export async function installSystemdService({ } // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). - stdout.write("\n"); - stdout.write(`${formatLine("Installed systemd service", unitPath)}\n`); + writeFormattedLines( + stdout, + [ + { + label: "Installed systemd service", + value: unitPath, + }, + ...(backedUp + ? [ + { + label: "Previous unit backed up to", + value: `${unitPath}.bak`, + }, + ] + : []), + ], + { leadingBlankLine: true }, + ); return { unitPath }; } diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index c4d317803111..e39adf58165a 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -23,6 +23,7 @@ import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto- import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -727,6 +728,57 @@ function formatModalSubmissionText( return lines.join("\n"); } +function resolveComponentCommandAuthorized(params: { + ctx: AgentComponentContext; + interactionCtx: ComponentInteractionContext; + channelConfig: ReturnType; + guildInfo: ReturnType; + allowNameMatching: boolean; +}): boolean { + const { ctx, interactionCtx, channelConfig, guildInfo } = params; + if (interactionCtx.isDirectMessage) { + return true; + } + + const ownerAllowList = normalizeDiscordAllowList(ctx.allowFrom, ["discord:", "user:", "pk:"]); + const ownerOk = ownerAllowList + ? resolveDiscordAllowListMatch({ + allowList: ownerAllowList, + candidate: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }).allowed + : false; + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: interactionCtx.memberRoleIds, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerOk }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }); +} + async function dispatchDiscordComponentEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -780,12 +832,20 @@ async function dispatchDiscordComponentEvent(params: { parentSlug: channelCtx.parentSlug, scope: channelCtx.isThread ? "thread" : "channel", }); + const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig); const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined; const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ channelConfig, guildInfo, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + allowNameMatching, + }); + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, }); const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId }); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); @@ -830,7 +890,7 @@ async function dispatchDiscordComponentEvent(params: { Provider: "discord" as const, Surface: "discord" as const, WasMentioned: true, - CommandAuthorized: true, + CommandAuthorized: commandAuthorized, CommandSource: "text" as const, MessageSid: interaction.rawData.id, Timestamp: timestamp, diff --git a/src/discord/monitor/gateway-plugin.ts b/src/discord/monitor/gateway-plugin.ts index 74e1aad8630c..c86b6259c5e8 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/src/discord/monitor/gateway-plugin.ts @@ -1,5 +1,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; import type { DiscordAccountConfig } from "../../config/types.js"; import { danger } from "../../globals.js"; @@ -42,7 +44,8 @@ export function createDiscordGatewayPlugin(params: { } try { - const agent = new HttpsProxyAgent(proxy); + const wsAgent = new HttpsProxyAgent(proxy); + const fetchAgent = new ProxyAgent(proxy); params.runtime.log?.("discord: gateway proxy enabled"); @@ -51,8 +54,28 @@ export function createDiscordGatewayPlugin(params: { super(options); } - createWebSocket(url: string) { - return new WebSocket(url, { agent }); + override async registerClient(client: Parameters[0]) { + if (!this.gatewayInfo) { + try { + const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", { + headers: { + Authorization: `Bot ${client.options.token}`, + }, + dispatcher: fetchAgent, + } as Record); + this.gatewayInfo = (await response.json()) as APIGatewayBotInfo; + } catch (error) { + throw new Error( + `Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + return super.registerClient(client); + } + + override createWebSocket(url: string) { + return new WebSocket(url, { agent: wsAgent }); } } diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index e321c8ef86f1..88871b006831 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -733,5 +733,6 @@ export async function preflightDiscordMessage( canDetectMention, historyEntry, threadBindings: params.threadBindings, + discordRestFetch: params.discordRestFetch, }; } diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index 86a32dbf7e81..91eff1ce2646 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -84,6 +84,7 @@ export type DiscordMessagePreflightContext = { historyEntry?: HistoryEntry; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; }; export type DiscordMessagePreflightParams = { @@ -106,6 +107,7 @@ export type DiscordMessagePreflightParams = { ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"]; groupPolicy: DiscordMessagePreflightContext["groupPolicy"]; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; data: DiscordMessageEvent; client: Client; }; diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index f3d2c7bcf157..a7333794cbb2 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -31,7 +31,14 @@ const deliverDiscordReply = deliveryMocks.deliverDiscordReply; const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream; type DispatchInboundParams = { dispatcher: { - sendFinalReply: (payload: { text?: string }) => boolean | Promise; + sendBlockReply: (payload: { + text?: string; + isReasoning?: boolean; + }) => boolean | Promise; + sendFinalReply: (payload: { + text?: string; + isReasoning?: boolean; + }) => boolean | Promise; }; replyOptions?: { onReasoningStream?: () => Promise | void; @@ -75,7 +82,10 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ (opts: { deliver: (payload: unknown, info: { kind: string }) => Promise | void }) => ({ dispatcher: { sendToolResult: vi.fn(() => true), - sendBlockReply: vi.fn(() => true), + sendBlockReply: vi.fn((payload: unknown) => { + void opts.deliver(payload as never, { kind: "block" }); + return true; + }), sendFinalReply: vi.fn((payload: unknown) => { void opts.deliver(payload as never, { kind: "final" }); return true; @@ -247,6 +257,35 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.stallHard); expect(emojis).toContain(DEFAULT_EMOJIS.done); }); + + it("applies status reaction emoji/timing overrides from config", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReasoningStream?.(); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { + ackReaction: "👀", + statusReactions: { + emojis: { queued: "🟦", thinking: "🧪", done: "🏁" }, + timing: { debounceMs: 0 }, + }, + }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const emojis = ( + sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> + ).map((call) => call[2]); + expect(emojis).toContain("🟦"); + expect(emojis).toContain("🏁"); + }); }); describe("processDiscordMessage session routing", () => { @@ -423,6 +462,52 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("suppresses reasoning payload delivery to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendBlockReply({ text: "thinking...", isReasoning: true }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + + it("suppresses reasoning-tagged final payload delivery to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ + text: "Reasoning:\nthis should stay internal", + isReasoning: true, + }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).not.toHaveBeenCalled(); + expect(editMessageDiscord).not.toHaveBeenCalled(); + }); + + it("delivers non-reasoning block payloads to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendBlockReply({ text: "hello from block stream" }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + }); + it("streams block previews using draft chunking", async () => { const draftStream = createMockDraftStream(); createDiscordDraftStream.mockReturnValueOnce(draftStream); @@ -458,4 +543,49 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1); }); + + it("strips reasoning tags from partial stream updates", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Let me think about this\nThe answer is 42", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const updates = draftStream.update.mock.calls.map((call) => call[0]); + for (const text of updates) { + expect(text).not.toContain(""); + } + }); + + it("skips pure-reasoning partial updates without updating draft", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Reasoning:\nThe user asked about X so I need to consider Y", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(draftStream.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 2d5b4058f6ef..00124709c740 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -30,6 +30,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; @@ -100,10 +101,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) threadBindings, route, commandAuthorized, + discordRestFetch, } = ctx; - const mediaList = await resolveMediaList(message, mediaMaxBytes); - const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes); + const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch); + const forwardedMediaList = await resolveForwardedMediaList( + message, + mediaMaxBytes, + discordRestFetch, + ); mediaList.push(...forwardedMediaList); const text = messageText; if (!text) { @@ -146,6 +152,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) enabled: statusReactionsEnabled, adapter: discordAdapter, initialEmoji: ackReaction, + emojis: cfg.messages?.statusReactions?.emojis, + timing: cfg.messages?.statusReactions?.timing, onError: (err) => { logAckFailure({ log: logVerbose, @@ -485,7 +493,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (!draftStream || !text) { return; } - if (text === lastPartialText) { + // Strip reasoning/thinking tags that may leak through the stream. + const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + // Skip pure-reasoning messages (e.g. "Reasoning:\n…") that contain no answer text. + if (!cleaned || cleaned.startsWith("Reasoning:\n")) { + return; + } + if (cleaned === lastPartialText) { return; } hasStreamedMessage = true; @@ -493,30 +507,30 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Keep the longer preview to avoid visible punctuation flicker. if ( lastPartialText && - lastPartialText.startsWith(text) && - text.length < lastPartialText.length + lastPartialText.startsWith(cleaned) && + cleaned.length < lastPartialText.length ) { return; } - lastPartialText = text; - draftStream.update(text); + lastPartialText = cleaned; + draftStream.update(cleaned); return; } - let delta = text; - if (text.startsWith(lastPartialText)) { - delta = text.slice(lastPartialText.length); + let delta = cleaned; + if (cleaned.startsWith(lastPartialText)) { + delta = cleaned.slice(lastPartialText.length); } else { // Streaming buffer reset (or non-monotonic stream). Start fresh. draftChunker?.reset(); draftText = ""; } - lastPartialText = text; + lastPartialText = cleaned; if (!delta) { return; } if (!draftChunker) { - draftText = text; + draftText = cleaned; draftStream.update(draftText); return; } @@ -555,8 +569,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload: ReplyPayload, info) => { const isFinal = info.kind === "final"; + if (payload.isReasoning) { + // Reasoning/thinking payloads should not be delivered to Discord. + return; + } if (draftStream && isFinal) { await flushDraft(); const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 4c671ce01e25..de8976ce5d23 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -93,6 +93,7 @@ describe("resolveForwardedMediaList", () => { url: attachment.url, filePathHint: attachment.filename, maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -105,6 +106,38 @@ describe("resolveForwardedMediaList", () => { ]); }); + it("forwards fetchImpl to forwarded attachment downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const attachment = { + id: "att-proxy", + url: "https://cdn.discordapp.com/attachments/1/proxy.png", + filename: "proxy.png", + content_type: "image/png", + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("image"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/proxy.png", + contentType: "image/png", + }); + + await resolveForwardedMediaList( + asMessage({ + rawData: { + message_snapshots: [{ message: { attachments: [attachment] } }], + }, + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); + it("downloads forwarded stickers", async () => { const sticker = { id: "sticker-1", @@ -134,6 +167,7 @@ describe("resolveForwardedMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-1.png", filePathHint: "wave.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -201,6 +235,7 @@ describe("resolveMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-2.png", filePathHint: "hello.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -212,6 +247,35 @@ describe("resolveMediaList", () => { }, ]); }); + + it("forwards fetchImpl to sticker downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const sticker = { + id: "sticker-proxy", + name: "proxy-sticker", + format_type: StickerFormatType.PNG, + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/sticker-proxy.png", + contentType: "image/png", + }); + + await resolveMediaList( + asMessage({ + stickers: [sticker], + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); }); describe("resolveDiscordMessageText", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 05aeab5dc768..3c523d277eff 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -2,7 +2,7 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; import { logVerbose } from "../../globals.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; +import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; export type DiscordMediaInfo = { @@ -161,6 +161,7 @@ export function hasDiscordMessageStickers(message: Message): boolean { export async function resolveMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const out: DiscordMediaInfo[] = []; await appendResolvedMediaFromAttachments({ @@ -168,12 +169,14 @@ export async function resolveMediaList( maxBytes, out, errorPrefix: "discord: failed to download attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: resolveDiscordMessageStickers(message), maxBytes, out, errorPrefix: "discord: failed to download sticker", + fetchImpl, }); return out; } @@ -181,6 +184,7 @@ export async function resolveMediaList( export async function resolveForwardedMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const snapshots = resolveDiscordMessageSnapshots(message); if (snapshots.length === 0) { @@ -193,12 +197,14 @@ export async function resolveForwardedMediaList( maxBytes, out, errorPrefix: "discord: failed to download forwarded attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], maxBytes, out, errorPrefix: "discord: failed to download forwarded sticker", + fetchImpl, }); } return out; @@ -209,6 +215,7 @@ async function appendResolvedMediaFromAttachments(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const attachments = params.attachments; if (!attachments || attachments.length === 0) { @@ -220,6 +227,7 @@ async function appendResolvedMediaFromAttachments(params: { url: attachment.url, filePathHint: attachment.filename ?? attachment.url, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, @@ -296,6 +304,7 @@ async function appendResolvedMediaFromStickers(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const stickers = params.stickers; if (!stickers || stickers.length === 0) { @@ -310,6 +319,7 @@ async function appendResolvedMediaFromStickers(params: { url: candidate.url, filePathHint: candidate.fileName, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts index 0ef048408bb1..29365fb784b0 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/src/discord/monitor/model-picker.test.ts @@ -117,6 +117,28 @@ describe("Discord model picker custom_id", () => { }); }); + it("parses compact custom_id aliases", () => { + const parsed = parseDiscordModelPickerData({ + c: "models", + a: "submit", + v: "models", + u: "42", + p: "openai", + g: "3", + mi: "2", + }); + + expect(parsed).toEqual({ + command: "models", + action: "submit", + view: "models", + userId: "42", + provider: "openai", + page: 3, + modelIndex: 2, + }); + }); + it("parses optional submit model index", () => { const parsed = parseDiscordModelPickerData({ cmd: "models", @@ -179,6 +201,21 @@ describe("Discord model picker custom_id", () => { }), ).toThrow(/custom_id exceeds/i); }); + + it("keeps typical submit ids under Discord max length", () => { + const customId = buildDiscordModelPickerCustomId({ + command: "models", + action: "submit", + view: "models", + provider: "azure-openai-responses", + page: 1, + providerPage: 1, + modelIndex: 10, + userId: "12345678901234567890", + }); + + expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS); + }); }); describe("provider paging", () => { @@ -325,7 +362,7 @@ describe("Discord model picker rendering", () => { return parsed?.action === "provider"; }); expect(providerButtons).toHaveLength(Object.keys(entries).length); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); @@ -352,7 +389,7 @@ describe("Discord model picker rendering", () => { expect(rows.length).toBeGreaterThan(0); const allButtons = rows.flatMap((row) => row.components ?? []); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts index ad3654ae81b2..5c686face275 100644 --- a/src/discord/monitor/model-picker.ts +++ b/src/discord/monitor/model-picker.ts @@ -577,11 +577,11 @@ export function buildDiscordModelPickerCustomId(params: { : undefined; const parts = [ - `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:cmd=${encodeCustomIdValue(params.command)}`, - `act=${encodeCustomIdValue(params.action)}`, - `view=${encodeCustomIdValue(params.view)}`, + `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`, + `a=${encodeCustomIdValue(params.action)}`, + `v=${encodeCustomIdValue(params.view)}`, `u=${encodeCustomIdValue(userId)}`, - `pg=${String(page)}`, + `g=${String(page)}`, ]; if (normalizedProvider) { parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`); @@ -635,12 +635,12 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi return null; } - const command = decodeCustomIdValue(coerceString(data.cmd)); - const action = decodeCustomIdValue(coerceString(data.act)); - const view = decodeCustomIdValue(coerceString(data.view)); + const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd)); + const action = decodeCustomIdValue(coerceString(data.a ?? data.act)); + const view = decodeCustomIdValue(coerceString(data.v ?? data.view)); const userId = decodeCustomIdValue(coerceString(data.u)); const providerRaw = decodeCustomIdValue(coerceString(data.p)); - const page = parseRawPage(data.pg); + const page = parseRawPage(data.g ?? data.pg); const providerPage = parseRawPositiveInt(data.pp); const modelIndex = parseRawPositiveInt(data.mi); const recentSlot = parseRawPositiveInt(data.rs); diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index 18fdce2e7868..afa9bbd93a74 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -391,6 +391,70 @@ describe("discord component interactions", () => { expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull(); }); + it("does not mark guild modal events as command-authorized for non-allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-1", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(false); + }); + + it("marks guild modal events as command-authorized for allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-2", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(true); + }); + it("keeps reusable modal entries active after submission", async () => { const { acknowledge } = await runModalSubmission({ reusable: true }); diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index c703c8568987..4d43469e2e41 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -2,14 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { GatewayIntents, + baseRegisterClientSpy, GatewayPlugin, HttpsProxyAgent, getLastAgent, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent, webSocketSpy, + wsProxyAgentSpy, } = vi.hoisted(() => { - const proxyAgentSpy = vi.fn(); + const wsProxyAgentSpy = vi.fn(); + const undiciProxyAgentSpy = vi.fn(); + const restProxyAgentSpy = vi.fn(); + const undiciFetchMock = vi.fn(); + const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); const GatewayIntents = { @@ -23,7 +31,17 @@ const { GuildMembers: 1 << 7, } as const; - class GatewayPlugin {} + class GatewayPlugin { + options: unknown; + gatewayInfo: unknown; + constructor(options?: unknown, gatewayInfo?: unknown) { + this.options = options; + this.gatewayInfo = gatewayInfo; + } + async registerClient(client: unknown) { + baseRegisterClientSpy(client); + } + } class HttpsProxyAgent { static lastCreated: HttpsProxyAgent | undefined; @@ -34,20 +52,24 @@ const { } this.proxyUrl = proxyUrl; HttpsProxyAgent.lastCreated = this; - proxyAgentSpy(proxyUrl); + wsProxyAgentSpy(proxyUrl); } } return { + baseRegisterClientSpy, GatewayIntents, GatewayPlugin, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent: () => { HttpsProxyAgent.lastCreated = undefined; }, webSocketSpy, + wsProxyAgentSpy, }; }); @@ -61,6 +83,18 @@ vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); +vi.mock("undici", () => ({ + ProxyAgent: class { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + restProxyAgentSpy(proxyUrl); + } + }, + fetch: undiciFetchMock, +})); + vi.mock("ws", () => ({ default: class MockWebSocket { constructor(url: string, options?: { agent?: unknown }) { @@ -87,7 +121,11 @@ describe("createDiscordGatewayPlugin", () => { } beforeEach(() => { - proxyAgentSpy.mockClear(); + baseRegisterClientSpy.mockClear(); + restProxyAgentSpy.mockClear(); + undiciFetchMock.mockClear(); + undiciProxyAgentSpy.mockClear(); + wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); resetLastAgent(); }); @@ -106,7 +144,7 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", expect.objectContaining({ agent: getLastAgent() }), @@ -127,4 +165,33 @@ describe("createDiscordGatewayPlugin", () => { expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); + + it("uses proxy fetch for gateway metadata lookup before registering", async () => { + const runtime = createRuntime(); + undiciFetchMock.mockResolvedValue({ + json: async () => ({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: { proxy: "http://proxy.test:8080" }, + runtime, + }); + + await ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }); + + expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/gateway/bot", + expect.objectContaining({ + headers: { Authorization: "Bot token-123" }, + dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + }), + ); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 5d6056b28f0e..618db385f0f5 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -574,6 +574,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { allowFrom, guildEntries, threadBindings, + discordRestFetch, }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts new file mode 100644 index 000000000000..6d2d169002c3 --- /dev/null +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -0,0 +1,111 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; +import { resolveDiscordThreadParentInfo } from "./threading.js"; + +describe("resolveDiscordThreadParentInfo", () => { + beforeEach(() => { + __resetDiscordChannelInfoCacheForTest(); + }); + + it("falls back to fetched thread parentId when parentId is missing in payload", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: "parent-1", + }; + } + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { + fetchChannel, + } as unknown as import("@buape/carbon").Client; + + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); + + it("does not fetch thread info when parentId is already present", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: "parent-1", + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); + + it("returns empty parent info when fallback thread lookup has no parentId", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: undefined, + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(result).toEqual({}); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 4efc83d0c74e..877329c29952 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -131,8 +131,12 @@ export async function resolveDiscordThreadParentInfo(params: { channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): Promise { const { threadChannel, channelInfo, client } = params; - const parentId = + let parentId = threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined; + if (!parentId && threadChannel.id) { + const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); + parentId = threadInfo?.parentId ?? undefined; + } if (!parentId) { return {}; } diff --git a/src/discord/voice/manager.test.ts b/src/discord/voice/manager.test.ts new file mode 100644 index 000000000000..ab13304b5e37 --- /dev/null +++ b/src/discord/voice/manager.test.ts @@ -0,0 +1,274 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + createConnectionMock, + joinVoiceChannelMock, + entersStateMock, + createAudioPlayerMock, + resolveAgentRouteMock, +} = vi.hoisted(() => { + type EventHandler = (...args: unknown[]) => unknown; + type MockConnection = { + destroy: ReturnType; + subscribe: ReturnType; + on: ReturnType; + off: ReturnType; + receiver: { + speaking: { + on: ReturnType; + off: ReturnType; + }; + subscribe: ReturnType; + }; + handlers: Map; + }; + + const createConnectionMock = (): MockConnection => { + const handlers = new Map(); + const connection: MockConnection = { + destroy: vi.fn(), + subscribe: vi.fn(), + on: vi.fn((event: string, handler: EventHandler) => { + handlers.set(event, handler); + }), + off: vi.fn(), + receiver: { + speaking: { + on: vi.fn(), + off: vi.fn(), + }, + subscribe: vi.fn(() => ({ + on: vi.fn(), + [Symbol.asyncIterator]: async function* () {}, + })), + }, + handlers, + }; + return connection; + }; + + return { + createConnectionMock, + joinVoiceChannelMock: vi.fn(() => createConnectionMock()), + entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => { + return undefined; + }), + createAudioPlayerMock: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + stop: vi.fn(), + play: vi.fn(), + state: { status: "idle" }, + })), + resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), + }; +}); + +vi.mock("@discordjs/voice", () => ({ + AudioPlayerStatus: { Playing: "playing", Idle: "idle" }, + EndBehaviorType: { AfterSilence: "AfterSilence" }, + VoiceConnectionStatus: { + Ready: "ready", + Disconnected: "disconnected", + Destroyed: "destroyed", + Signalling: "signalling", + Connecting: "connecting", + }, + createAudioPlayer: createAudioPlayerMock, + createAudioResource: vi.fn(), + entersState: entersStateMock, + joinVoiceChannel: joinVoiceChannelMock, +})); + +vi.mock("../../routing/resolve-route.js", () => ({ + resolveAgentRoute: resolveAgentRouteMock, +})); + +let managerModule: typeof import("./manager.js"); + +function createClient() { + return { + fetchChannel: vi.fn(async (channelId: string) => ({ + id: channelId, + guildId: "g1", + type: ChannelType.GuildVoice, + })), + getPlugin: vi.fn(() => ({ + getGatewayAdapterCreator: vi.fn(() => vi.fn()), + })), + fetchMember: vi.fn(), + fetchUser: vi.fn(), + }; +} + +function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +describe("DiscordVoiceManager", () => { + beforeAll(async () => { + managerModule = await import("./manager.js"); + }); + + beforeEach(() => { + joinVoiceChannelMock.mockReset(); + joinVoiceChannelMock.mockImplementation(() => createConnectionMock()); + entersStateMock.mockReset(); + entersStateMock.mockResolvedValue(undefined); + createAudioPlayerMock.mockClear(); + resolveAgentRouteMock.mockClear(); + }); + + it("keeps the new session when an old disconnected handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + entersStateMock.mockImplementation(async (target: unknown, status?: string) => { + if (target === oldConnection && (status === "signalling" || status === "connecting")) { + throw new Error("old disconnected"); + } + return undefined; + }); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDisconnected = oldConnection.handlers.get("disconnected"); + expect(oldDisconnected).toBeTypeOf("function"); + await oldDisconnected?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("keeps the new session when an old destroyed handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDestroyed = oldConnection.handlers.get("destroyed"); + expect(oldDestroyed).toBeTypeOf("function"); + oldDestroyed?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("removes voice listeners on leave", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.leave({ guildId: "g1" }); + + const player = createAudioPlayerMock.mock.results[0]?.value; + expect(connection.receiver.speaking.off).toHaveBeenCalledWith("start", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("disconnected", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("destroyed", expect.any(Function)); + expect(player.off).toHaveBeenCalledWith("error", expect.any(Function)); + }); + + it("passes DAVE options to joinVoiceChannel", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: { + voice: { + daveEncryption: false, + decryptionFailureTolerance: 8, + }, + }, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + expect(joinVoiceChannelMock).toHaveBeenCalledWith( + expect.objectContaining({ + daveEncryption: false, + decryptionFailureTolerance: 8, + }), + ); + }); + + it("attempts rejoin after repeated decrypt failures", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1"); + expect(entry).toBeDefined(); + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + ( + manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } + ).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/discord/voice/manager.ts b/src/discord/voice/manager.ts index 7333b3f89d74..c246b280fb44 100644 --- a/src/discord/voice/manager.ts +++ b/src/discord/voice/manager.ts @@ -45,6 +45,9 @@ const MIN_SEGMENT_SECONDS = 0.35; const SILENCE_DURATION_MS = 1_000; const PLAYBACK_READY_TIMEOUT_MS = 15_000; const SPEAKING_READY_TIMEOUT_MS = 60_000; +const DECRYPT_FAILURE_WINDOW_MS = 30_000; +const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3; +const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/; const logger = createSubsystemLogger("discord/voice"); @@ -69,6 +72,9 @@ type VoiceSessionEntry = { playbackQueue: Promise; processingQueue: Promise; activeSpeakers: Set; + decryptFailureCount: number; + lastDecryptFailureAt: number; + decryptRecoveryInFlight: boolean; stop: () => void; }; @@ -377,12 +383,21 @@ export class DiscordVoiceManager { } const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); + const daveEncryption = this.params.discordConfig.voice?.daveEncryption; + const decryptionFailureTolerance = this.params.discordConfig.voice?.decryptionFailureTolerance; + logVoiceVerbose( + `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${ + decryptionFailureTolerance ?? "default" + }`, + ); const connection = joinVoiceChannel({ channelId, guildId, adapterCreator, selfDeaf: false, selfMute: false, + daveEncryption, + decryptionFailureTolerance, }); try { @@ -412,6 +427,17 @@ export class DiscordVoiceManager { const player = createAudioPlayer(); connection.subscribe(player); + let speakingHandler: ((userId: string) => void) | undefined; + let disconnectedHandler: (() => Promise) | undefined; + let destroyedHandler: (() => void) | undefined; + let playerErrorHandler: ((err: Error) => void) | undefined; + const clearSessionIfCurrent = () => { + const active = this.sessions.get(guildId); + if (active?.connection === connection) { + this.sessions.delete(guildId); + } + }; + const entry: VoiceSessionEntry = { guildId, channelId, @@ -422,37 +448,55 @@ export class DiscordVoiceManager { playbackQueue: Promise.resolve(), processingQueue: Promise.resolve(), activeSpeakers: new Set(), + decryptFailureCount: 0, + lastDecryptFailureAt: 0, + decryptRecoveryInFlight: false, stop: () => { + if (speakingHandler) { + connection.receiver.speaking.off("start", speakingHandler); + } + if (disconnectedHandler) { + connection.off(VoiceConnectionStatus.Disconnected, disconnectedHandler); + } + if (destroyedHandler) { + connection.off(VoiceConnectionStatus.Destroyed, destroyedHandler); + } + if (playerErrorHandler) { + player.off("error", playerErrorHandler); + } player.stop(); connection.destroy(); }, }; - const speakingHandler = (userId: string) => { + speakingHandler = (userId: string) => { void this.handleSpeakingStart(entry, userId).catch((err) => { logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`); }); }; - connection.receiver.speaking.on("start", speakingHandler); - connection.on(VoiceConnectionStatus.Disconnected, async () => { + disconnectedHandler = async () => { try { await Promise.race([ entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000), ]); } catch { - this.sessions.delete(guildId); + clearSessionIfCurrent(); connection.destroy(); } - }); - connection.on(VoiceConnectionStatus.Destroyed, () => { - this.sessions.delete(guildId); - }); - - player.on("error", (err: unknown) => { + }; + destroyedHandler = () => { + clearSessionIfCurrent(); + }; + playerErrorHandler = (err: Error) => { logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`); - }); + }; + + connection.receiver.speaking.on("start", speakingHandler); + connection.on(VoiceConnectionStatus.Disconnected, disconnectedHandler); + connection.on(VoiceConnectionStatus.Destroyed, destroyedHandler); + player.on("error", playerErrorHandler); this.sessions.set(guildId, entry); return { @@ -525,8 +569,8 @@ export class DiscordVoiceManager { duration: SILENCE_DURATION_MS, }, }); - stream.on("error", (err: unknown) => { - logger.warn(`discord voice: receive error: ${formatErrorMessage(err)}`); + stream.on("error", (err) => { + this.handleReceiveError(entry, err); }); try { @@ -537,6 +581,7 @@ export class DiscordVoiceManager { ); return; } + this.resetDecryptFailureState(entry); const { path: wavPath, durationSeconds } = await writeWavFile(pcm); if (durationSeconds < MIN_SEGMENT_SECONDS) { logVoiceVerbose( @@ -654,6 +699,64 @@ export class DiscordVoiceManager { }); } + private handleReceiveError(entry: VoiceSessionEntry, err: unknown) { + const message = formatErrorMessage(err); + logger.warn(`discord voice: receive error: ${message}`); + if (!DECRYPT_FAILURE_PATTERN.test(message)) { + return; + } + const now = Date.now(); + if (now - entry.lastDecryptFailureAt > DECRYPT_FAILURE_WINDOW_MS) { + entry.decryptFailureCount = 0; + } + entry.lastDecryptFailureAt = now; + entry.decryptFailureCount += 1; + if (entry.decryptFailureCount === 1) { + logger.warn( + "discord voice: DAVE decrypt failures detected; voice receive may be unstable (upstream: discordjs/discord.js#11419)", + ); + } + if ( + entry.decryptFailureCount < DECRYPT_FAILURE_RECONNECT_THRESHOLD || + entry.decryptRecoveryInFlight + ) { + return; + } + entry.decryptRecoveryInFlight = true; + this.resetDecryptFailureState(entry); + void this.recoverFromDecryptFailures(entry) + .catch((recoverErr) => + logger.warn(`discord voice: decrypt recovery failed: ${formatErrorMessage(recoverErr)}`), + ) + .finally(() => { + entry.decryptRecoveryInFlight = false; + }); + } + + private resetDecryptFailureState(entry: VoiceSessionEntry) { + entry.decryptFailureCount = 0; + entry.lastDecryptFailureAt = 0; + } + + private async recoverFromDecryptFailures(entry: VoiceSessionEntry) { + const active = this.sessions.get(entry.guildId); + if (!active || active.connection !== entry.connection) { + return; + } + logger.warn( + `discord voice: repeated decrypt failures; attempting rejoin for guild ${entry.guildId} channel ${entry.channelId}`, + ); + const leaveResult = await this.leave({ guildId: entry.guildId }); + if (!leaveResult.ok) { + logger.warn(`discord voice: decrypt recovery leave failed: ${leaveResult.message}`); + return; + } + const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId }); + if (!result.ok) { + logger.warn(`discord voice: rejoin after decrypt failures failed: ${result.message}`); + } + } + private async resolveSpeakerLabel(guildId: string, userId: string): Promise { try { const member = await this.params.client.fetchMember(guildId, userId); diff --git a/src/gateway/agent-prompt.test.ts b/src/gateway/agent-prompt.test.ts index 80fc92e48199..75800696614c 100644 --- a/src/gateway/agent-prompt.test.ts +++ b/src/gateway/agent-prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; import { buildAgentMessageFromConversationEntries } from "./agent-prompt.js"; describe("gateway agent prompt", () => { @@ -15,6 +16,24 @@ describe("gateway agent prompt", () => { ).toBe("hi"); }); + it("extracts text from content-array body when there is no history", () => { + expect( + buildAgentMessageFromConversationEntries([ + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "hi" }, + { type: "image", data: "base64-image", mimeType: "image/png" }, + { type: "text", text: "there" }, + ] as unknown as string, + }, + }, + ]), + ).toBe("hi there"); + }); + it("uses history context when there is history", () => { const entries = [ { role: "assistant", entry: { sender: "Assistant", body: "prev" } }, @@ -45,4 +64,34 @@ describe("gateway agent prompt", () => { expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); }); + + it("normalizes content-array bodies in history and current message", () => { + const entries = [ + { + role: "assistant", + entry: { + sender: "Assistant", + body: [{ type: "text", text: "prev" }] as unknown as string, + }, + }, + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "next" }, + { type: "text", text: "step" }, + ] as unknown as string, + }, + }, + ] as const; + + const expected = buildHistoryContextFromEntries({ + entries: entries.map((e) => e.entry), + currentMessage: "User: next step", + formatEntry: (e) => `${e.sender}: ${extractTextFromChatContent(e.body) ?? ""}`, + }); + + expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); + }); }); diff --git a/src/gateway/agent-prompt.ts b/src/gateway/agent-prompt.ts index 58e12bacd02e..5904726b927c 100644 --- a/src/gateway/agent-prompt.ts +++ b/src/gateway/agent-prompt.ts @@ -1,10 +1,23 @@ import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; export type ConversationEntry = { role: "user" | "assistant" | "tool"; entry: HistoryEntry; }; +/** + * Coerce body to string. Handles cases where body is a content array + * (e.g. [{type:"text", text:"hello"}]) that would serialize as + * [object Object] if used directly in a template literal. + */ +function safeBody(body: unknown): string { + if (typeof body === "string") { + return body; + } + return extractTextFromChatContent(body) ?? ""; +} + export function buildAgentMessageFromConversationEntries(entries: ConversationEntry[]): string { if (entries.length === 0) { return ""; @@ -31,10 +44,10 @@ export function buildAgentMessageFromConversationEntries(entries: ConversationEn const historyEntries = entries.slice(0, currentIndex).map((e) => e.entry); if (historyEntries.length === 0) { - return currentEntry.body; + return safeBody(currentEntry.body); } - const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; + const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${safeBody(entry.body)}`; return buildHistoryContextFromEntries({ entries: [...historyEntries, currentEntry], currentMessage: formatEntry(currentEntry), diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 9829f45c9996..f3aff5ebfe5b 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { abortChatRunById, + isChatStopCommandText, type ChatAbortOps, type ChatAbortControllerEntry, } from "./chat-abort.js"; @@ -42,6 +43,24 @@ function createOps(params: { }; } +describe("isChatStopCommandText", () => { + it("matches slash and standalone multilingual stop forms", () => { + expect(isChatStopCommandText(" /STOP!!! ")).toBe(true); + expect(isChatStopCommandText("stop please")).toBe(true); + expect(isChatStopCommandText("do not do that")).toBe(true); + expect(isChatStopCommandText("停止")).toBe(true); + expect(isChatStopCommandText("やめて")).toBe(true); + expect(isChatStopCommandText("توقف")).toBe(true); + expect(isChatStopCommandText("остановись")).toBe(true); + expect(isChatStopCommandText("halt")).toBe(true); + expect(isChatStopCommandText("stopp")).toBe(true); + expect(isChatStopCommandText("pare")).toBe(true); + expect(isChatStopCommandText("/status")).toBe(false); + expect(isChatStopCommandText("please do not do that")).toBe(false); + expect(isChatStopCommandText("keep going")).toBe(false); + }); +}); + describe("abortChatRunById", () => { it("broadcasts aborted payload with partial message when buffered text exists", () => { const runId = "run-1"; diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 0d544324133f..0210f9223f77 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -1,4 +1,4 @@ -import { isAbortTrigger } from "../auto-reply/reply/abort.js"; +import { isAbortRequestText } from "../auto-reply/reply/abort.js"; export type ChatAbortControllerEntry = { controller: AbortController; @@ -9,11 +9,7 @@ export type ChatAbortControllerEntry = { }; export function isChatStopCommandText(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed); + return isAbortRequestText(text); } export function resolveChatRunExpiresAtMs(params: { diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 8a2828da9702..5e582d42a03a 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -7,6 +7,7 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000; export type ExecApprovalRequestPayload = { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; @@ -153,6 +154,21 @@ export class ExecApprovalManager { return entry?.record ?? null; } + consumeAllowOnce(recordId: string): boolean { + const entry = this.pending.get(recordId); + if (!entry) { + return false; + } + const record = entry.record; + if (record.decision !== "allow-once") { + return false; + } + // One-time approvals must be consumed atomically so the same runId + // cannot be replayed during the resolved-entry grace window. + record.decision = undefined; + return true; + } + /** * Wait for decision on an already-registered approval. * Returns the decision promise if the ID is pending, null otherwise. diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 0c53168a0d28..dde79c886144 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -56,10 +56,79 @@ function parseFilter(raw?: string): Set | null { return ids.length ? new Set(ids) : null; } +function toInt(value: string | undefined, fallback: number): number { + const trimmed = value?.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function capByProviderSpread( + items: T[], + maxItems: number, + providerOf: (item: T) => string, +): T[] { + if (maxItems <= 0 || items.length <= maxItems) { + return items; + } + const providerOrder: string[] = []; + const grouped = new Map(); + for (const item of items) { + const provider = providerOf(item); + const bucket = grouped.get(provider); + if (bucket) { + bucket.push(item); + continue; + } + providerOrder.push(provider); + grouped.set(provider, [item]); + } + + const selected: T[] = []; + while (selected.length < maxItems && grouped.size > 0) { + for (const provider of providerOrder) { + const bucket = grouped.get(provider); + if (!bucket || bucket.length === 0) { + continue; + } + const item = bucket.shift(); + if (item) { + selected.push(item); + } + if (bucket.length === 0) { + grouped.delete(provider); + } + if (selected.length >= maxItems) { + break; + } + } + } + return selected; +} + function logProgress(message: string): void { console.log(`[live] ${message}`); } +function formatFailurePreview( + failures: Array<{ model: string; error: string }>, + maxItems: number, +): string { + const limit = Math.max(1, maxItems); + const lines = failures.slice(0, limit).map((failure, index) => { + const normalized = failure.error.replace(/\s+/g, " ").trim(); + const clipped = normalized.length > 320 ? `${normalized.slice(0, 317)}...` : normalized; + return `${index + 1}. ${failure.model}: ${clipped}`; + }); + const remaining = failures.length - limit; + if (remaining > 0) { + lines.push(`... and ${remaining} more`); + } + return lines.join("\n"); +} + function assertNoReasoningTags(params: { text: string; model: string; @@ -128,6 +197,16 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); } +function isProviderUnavailableErrorMessage(raw: string): boolean { + const msg = raw.toLowerCase(); + return ( + msg.includes("no allowed providers are available") || + msg.includes("provider unavailable") || + msg.includes("upstream provider unavailable") || + msg.includes("upstream error from google") + ); +} + function isInstructionsRequiredError(error: string): boolean { return /instructions are required/i.test(error); } @@ -962,6 +1041,11 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (anthropic empty response)`); break; } + if (isProviderUnavailableErrorMessage(message)) { + skippedCount += 1; + logProgress(`${progressLabel}: skip (provider unavailable)`); + break; + } // OpenAI Codex refresh tokens can become single-use; skip instead of failing all live tests. if (model.provider === "openai-codex" && isRefreshTokenReused(message)) { logProgress(`${progressLabel}: skip (codex refresh token reused)`); @@ -1010,11 +1094,10 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { } if (failures.length > 0) { - const preview = failures - .slice(0, 20) - .map((f) => `- ${f.model}: ${f.error}`) - .join("\n"); - throw new Error(`gateway live model failures (${failures.length}):\n${preview}`); + const preview = formatFailurePreview(failures, 20); + throw new Error( + `gateway live model failures (${failures.length}, showing ${Math.min(failures.length, 20)}):\n${preview}`, + ); } if (skippedCount === total) { logProgress(`[${params.label}] skipped all models (missing profiles)`); @@ -1062,6 +1145,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { const useModern = !rawModels || rawModels === "modern" || rawModels === "all"; const useExplicit = Boolean(rawModels) && !useModern; const filter = useExplicit ? parseFilter(rawModels) : null; + const maxModels = toInt(process.env.OPENCLAW_LIVE_GATEWAY_MAX_MODELS, 0); const wanted = filter ? all.filter((m) => filter.has(`${m.provider}/${m.id}`)) : all.filter((m) => isModernModelRef({ provider: m.provider, id: m.id })); @@ -1092,21 +1176,31 @@ describeLive("gateway live (dev agent, profile keys)", () => { logProgress("[all-models] no API keys found; skipping"); return; } + const selectedCandidates = capByProviderSpread( + candidates, + maxModels > 0 ? maxModels : candidates.length, + (model) => model.provider, + ); logProgress(`[all-models] selection=${useExplicit ? "explicit" : "modern"}`); - const imageCandidates = candidates.filter((m) => m.input?.includes("image")); + if (selectedCandidates.length < candidates.length) { + logProgress( + `[all-models] capped to ${selectedCandidates.length}/${candidates.length} via OPENCLAW_LIVE_GATEWAY_MAX_MODELS=${maxModels}`, + ); + } + const imageCandidates = selectedCandidates.filter((m) => m.input?.includes("image")); if (imageCandidates.length === 0) { logProgress("[all-models] no image-capable models selected; image probe will be skipped"); } await runGatewayModelSuite({ label: "all-models", cfg, - candidates, + candidates: selectedCandidates, extraToolProbes: true, extraImageProbes: true, thinkingLevel: THINKING_LEVEL, }); - const minimaxCandidates = candidates.filter((model) => model.provider === "minimax"); + const minimaxCandidates = selectedCandidates.filter((model) => model.provider === "minimax"); if (minimaxCandidates.length === 0) { logProgress("[minimax] no candidates with keys; skipping dual endpoint probes"); return; diff --git a/src/gateway/node-invoke-sanitize.ts b/src/gateway/node-invoke-sanitize.ts index c794405ddea5..651399dce08b 100644 --- a/src/gateway/node-invoke-sanitize.ts +++ b/src/gateway/node-invoke-sanitize.ts @@ -3,6 +3,7 @@ import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-a import type { GatewayClient } from "./server-methods/types.js"; export function sanitizeNodeInvokeParamsForForwarding(opts: { + nodeId: string; command: string; rawParams: unknown; client: GatewayClient | null; @@ -12,6 +13,7 @@ export function sanitizeNodeInvokeParamsForForwarding(opts: { | { ok: false; message: string; details?: Record } { if (opts.command === "system.run") { return sanitizeSystemRunParamsForForwarding({ + nodeId: opts.nodeId, rawParams: opts.rawParams, client: opts.client, execApprovalManager: opts.execApprovalManager, diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index a5a7c3d9f0df..196b5947f451 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { ExecApprovalRecord } from "./exec-approval-manager.js"; +import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; describe("sanitizeSystemRunParamsForForwarding", () => { @@ -18,6 +18,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { id: "approval-1", request: { host: "node", + nodeId: "node-1", command, cwd: null, agentId: null, @@ -35,8 +36,17 @@ describe("sanitizeSystemRunParamsForForwarding", () => { } function manager(record: ReturnType) { + let consumed = false; return { getSnapshot: () => record, + consumeAllowOnce: () => { + if (consumed || record.decision !== "allow-once") { + return false; + } + consumed = true; + record.decision = undefined; + return true; + }, }; } @@ -61,6 +71,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo")), nowMs: now, @@ -82,6 +93,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE&&whoami")), nowMs: now, @@ -97,6 +109,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE")), nowMs: now, @@ -117,6 +130,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager( makeRecord('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo SAFE"'), @@ -125,4 +139,101 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + test("consumes allow-once approvals and blocks same runId replay", async () => { + const approvalManager = new ExecApprovalManager(); + const runId = "approval-replay-1"; + const record = approvalManager.create( + { + host: "node", + nodeId: "node-1", + command: "echo SAFE", + cwd: null, + agentId: null, + sessionKey: null, + }, + 60_000, + runId, + ); + record.requestedByConnId = "conn-1"; + record.requestedByDeviceId = "dev-1"; + record.requestedByClientId = "cli-1"; + + const decisionPromise = approvalManager.register(record, 60_000); + approvalManager.resolve(runId, "allow-once", "operator"); + await expect(decisionPromise).resolves.toBe("allow-once"); + + const params = { + command: ["echo", "SAFE"], + rawCommand: "echo SAFE", + runId, + approved: true, + approvalDecision: "allow-once", + }; + + const first = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expectAllowOnceForwardingResult(first); + + const second = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expect(second.ok).toBe(false); + if (second.ok) { + throw new Error("unreachable"); + } + expect(second.details?.code).toBe("APPROVAL_REQUIRED"); + }); + + test("rejects approval ids that do not bind a nodeId", () => { + const record = makeRecord("echo SAFE"); + record.request.nodeId = null; + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(record), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("missing node binding"); + expect(result.details?.code).toBe("APPROVAL_NODE_BINDING_MISSING"); + }); + + test("rejects approval ids replayed against a different nodeId", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-2", + client, + execApprovalManager: manager(makeRecord("echo SAFE")), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("not valid for this node"); + expect(result.details?.code).toBe("APPROVAL_NODE_MISMATCH"); + }); }); diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 5684f4221f51..d5600adf032a 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -17,6 +17,7 @@ type SystemRunParamsLike = { type ApprovalLookup = { getSnapshot: (recordId: string) => ExecApprovalRecord | null; + consumeAllowOnce?: (recordId: string) => boolean; }; type ApprovalClient = { @@ -114,6 +115,7 @@ function pickSystemRunParams(raw: Record): Record 0 ? p.id.trim() : null; + const host = typeof p.host === "string" ? p.host.trim() : ""; + const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; + if (host === "node" && !nodeId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"), + ); + return; + } if (explicitId && manager.getSnapshot(explicitId)) { respond( false, @@ -68,7 +79,8 @@ export function createExecApprovalHandlers( const request = { command: p.command, cwd: p.cwd ?? null, - host: p.host ?? null, + nodeId: host === "node" ? nodeId : null, + host: host || null, security: p.security ?? null, ask: p.ask ?? null, agentId: p.agentId ?? null, diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 4f076abd59c8..f02210331556 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -698,6 +698,7 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } const forwardedParams = sanitizeNodeInvokeParamsForForwarding({ + nodeId, command, rawParams: p.params, client, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 60349d9c0e4f..b19a6d8c6082 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -248,6 +248,7 @@ describe("exec approval handlers", () => { const defaultExecApprovalRequestParams = { command: "echo ok", cwd: "/tmp", + nodeId: "node-1", host: "node", timeoutMs: 2000, } as const; @@ -323,6 +324,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", }; expect(validateExecApprovalRequestParams(params)).toBe(true); @@ -332,6 +334,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: "/usr/bin/echo", }; @@ -342,6 +345,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: undefined, }; @@ -352,6 +356,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: null, }; @@ -359,6 +364,25 @@ describe("exec approval handlers", () => { }); }); + it("rejects host=node approval requests without nodeId", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + nodeId: undefined, + }, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "nodeId is required for host=node", + }), + ); + }); + it("broadcasts request + resolve", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 8813ad065f6c..357d1f4e5639 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -387,6 +387,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { reasoningLevel: entry?.reasoningLevel, responseUsage: entry?.responseUsage, model: entry?.model, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 760f4cc93106..693f34475379 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -1,5 +1,6 @@ import { readConfigFileSnapshot } from "../../config/config.js"; import { redactConfigObject } from "../../config/redact-snapshot.js"; +import { buildTalkConfigResponse } from "../../config/talk.js"; import { ErrorCodes, errorShape, @@ -17,46 +18,6 @@ function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null): return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE); } -function normalizeTalkConfigSection(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const source = value as Record; - const talk: Record = {}; - if (typeof source.voiceId === "string") { - talk.voiceId = source.voiceId; - } - if ( - source.voiceAliases && - typeof source.voiceAliases === "object" && - !Array.isArray(source.voiceAliases) - ) { - const aliases: Record = {}; - for (const [alias, id] of Object.entries(source.voiceAliases as Record)) { - if (typeof id !== "string") { - continue; - } - aliases[alias] = id; - } - if (Object.keys(aliases).length > 0) { - talk.voiceAliases = aliases; - } - } - if (typeof source.modelId === "string") { - talk.modelId = source.modelId; - } - if (typeof source.outputFormat === "string") { - talk.outputFormat = source.outputFormat; - } - if (typeof source.apiKey === "string") { - talk.apiKey = source.apiKey; - } - if (typeof source.interruptOnSpeech === "boolean") { - talk.interruptOnSpeech = source.interruptOnSpeech; - } - return Object.keys(talk).length > 0 ? talk : undefined; -} - export const talkHandlers: GatewayRequestHandlers = { "talk.config": async ({ params, respond, client }) => { if (!validateTalkConfigParams(params)) { @@ -87,7 +48,7 @@ export const talkHandlers: GatewayRequestHandlers = { const talkSource = includeSecrets ? snapshot.config.talk : redactConfigObject(snapshot.config.talk); - const talk = normalizeTalkConfigSection(talkSource); + const talk = buildTalkConfigResponse(talkSource); if (talk) { configPayload.talk = talk; } diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index e536193accde..454657d188d2 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -76,13 +76,22 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) { sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); + // Slack uses replyToId (thread_ts) for threading, not threadId. + // The reply path does this mapping but deliverOutboundPayloads does not, + // so we must convert here to ensure post-restart notifications land in + // the originating Slack thread. See #17716. + const isSlack = channel === "slack"; + const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined; + const resolvedThreadId = isSlack ? undefined : threadId; + try { await deliverOutboundPayloads({ cfg, channel, to: resolved.to, accountId: origin?.accountId, - threadId, + replyToId, + threadId: resolvedThreadId, payloads: [{ text: message }], agentId: resolveSessionAgentId({ sessionKey, config: cfg }), bestEffort: true, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index b1dda9a05cae..837a17cd3bda 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -328,7 +328,7 @@ describe("gateway server models + voicewake", () => { ); }); - test("models.list falls back to full catalog when allowlist has no catalog match", async () => { + test("models.list includes synthetic entries for allowlist models absent from catalog", async () => { await withModelsConfig( { agents: { @@ -345,7 +345,13 @@ describe("gateway server models + voicewake", () => { const res = await listModels(); expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual(expectedSortedCatalog()); + expect(res.payload?.models).toEqual([ + { + id: "not-in-catalog", + name: "not-in-catalog", + provider: "openai", + }, + ]); }, ); }); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 9a78453a199a..7cc84b5b8d8a 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { deriveDeviceIdFromPublicKey, + type DeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; @@ -23,6 +24,22 @@ installGatewayTestHooks({ scope: "suite" }); const NODE_CONNECT_TIMEOUT_MS = 3_000; const CONNECT_REQ_TIMEOUT_MS = 2_000; +function createDeviceIdentity(): DeviceIdentity { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); + const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); + if (!deviceId) { + throw new Error("failed to create test device identity"); + } + return { + deviceId, + publicKeyPem, + privateKeyPem, + }; +} + async function expectNoForwardedInvoke(hasInvoke: () => boolean): Promise { // Yield a couple of macrotasks so any accidental async forwarding would fire. await new Promise((resolve) => setImmediate(resolve)); @@ -42,11 +59,26 @@ async function getConnectedNodeId(ws: WebSocket): Promise { return nodeId; } -async function requestAllowOnceApproval(ws: WebSocket, command: string): Promise { +async function getConnectedNodeIds(ws: WebSocket): Promise { + const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>( + ws, + "node.list", + {}, + ); + expect(nodes.ok).toBe(true); + return (nodes.payload?.nodes ?? []).filter((n) => n.connected).map((n) => n.nodeId); +} + +async function requestAllowOnceApproval( + ws: WebSocket, + command: string, + nodeId: string, +): Promise { const approvalId = crypto.randomUUID(); const requestP = rpcReq(ws, "exec.approval.request", { id: approvalId, command, + nodeId, cwd: null, host: "node", timeoutMs: 30_000, @@ -161,7 +193,10 @@ describe("node.invoke approval bypass", () => { }); }; - const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => { + const connectLinuxNode = async ( + onInvoke: (payload: unknown) => void, + deviceIdentity?: DeviceIdentity, + ) => { let readyResolve: (() => void) | null = null; const ready = new Promise((resolve) => { readyResolve = resolve; @@ -180,6 +215,7 @@ describe("node.invoke approval bypass", () => { mode: GATEWAY_CLIENT_MODES.NODE, scopes: [], commands: ["system.run"], + deviceIdentity, onHelloOk: () => readyResolve?.(), onEvent: (evt) => { if (evt.event !== "node.invoke.request") { @@ -295,7 +331,7 @@ describe("node.invoke approval bypass", () => { try { const nodeId = await getConnectedNodeId(wsApprover); - const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); // Separate caller connection simulates per-call clients. const invoke = await rpcReq(wsCaller, "node.invoke", { nodeId, @@ -316,7 +352,7 @@ describe("node.invoke approval bypass", () => { expect(lastInvokeParams?.["approvalDecision"]).toBe("allow-once"); expect(lastInvokeParams?.["injected"]).toBeUndefined(); - const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); const invokeCountBeforeReplay = invokeCount; const replay = await rpcReq(wsOtherDevice, "node.invoke", { nodeId, @@ -340,4 +376,63 @@ describe("node.invoke approval bypass", () => { node.stop(); } }); + + test("blocks cross-node replay on same device", async () => { + const invokeCounts = new Map(); + const onInvoke = (payload: unknown) => { + const obj = payload as { nodeId?: unknown }; + const nodeId = typeof obj?.nodeId === "string" ? obj.nodeId : ""; + if (!nodeId) { + return; + } + invokeCounts.set(nodeId, (invokeCounts.get(nodeId) ?? 0) + 1); + }; + const nodeA = await connectLinuxNode(onInvoke, createDeviceIdentity()); + const nodeB = await connectLinuxNode(onInvoke, createDeviceIdentity()); + + const wsApprover = await connectOperator(["operator.write", "operator.approvals"]); + const wsCaller = await connectOperator(["operator.write"]); + + try { + await expect + .poll(async () => (await getConnectedNodeIds(wsApprover)).length, { + timeout: 3_000, + interval: 50, + }) + .toBeGreaterThanOrEqual(2); + const connectedNodeIds = await getConnectedNodeIds(wsApprover); + const approvedNodeId = connectedNodeIds[0] ?? ""; + const replayNodeId = connectedNodeIds.find((id) => id !== approvedNodeId) ?? ""; + expect(approvedNodeId).toBeTruthy(); + expect(replayNodeId).toBeTruthy(); + + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", approvedNodeId); + const beforeReplayApprovedNode = invokeCounts.get(approvedNodeId) ?? 0; + const beforeReplayOtherNode = invokeCounts.get(replayNodeId) ?? 0; + const replay = await rpcReq(wsCaller, "node.invoke", { + nodeId: replayNodeId, + command: "system.run", + params: { + command: ["echo", "hi"], + rawCommand: "echo hi", + runId: approvalId, + approved: true, + approvalDecision: "allow-once", + }, + idempotencyKey: crypto.randomUUID(), + }); + expect(replay.ok).toBe(false); + expect(replay.error?.message ?? "").toContain("not valid for this node"); + await expectNoForwardedInvoke( + () => + (invokeCounts.get(approvedNodeId) ?? 0) > beforeReplayApprovedNode || + (invokeCounts.get(replayNodeId) ?? 0) > beforeReplayOtherNode, + ); + } finally { + wsApprover.close(); + wsCaller.close(); + nodeA.stop(); + nodeB.stop(); + } + }); }); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index f932e1e2a358..25568d4803e4 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -142,6 +142,12 @@ describe("gateway plugin HTTP auth boundary", () => { run: async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-root" })); + return true; + } if (pathname === "/api/channels/nostr/default/profile") { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -179,6 +185,16 @@ describe("gateway plugin HTTP auth boundary", () => { expect(unauthenticated.getBody()).toContain("Unauthorized"); expect(handlePluginRequest).not.toHaveBeenCalled(); + const unauthenticatedRoot = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels" }), + unauthenticatedRoot.res, + ); + expect(unauthenticatedRoot.res.statusCode).toBe(401); + expect(unauthenticatedRoot.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + const authenticated = createResponse(); await dispatchRequest( server, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 0ffa73c92703..b05cf2220ede 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -202,6 +202,8 @@ describe("gateway server sessions", () => { main: { sessionId: "sess-main", updatedAt: recent, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", inputTokens: 10, outputTokens: 20, thinkingLevel: "low", @@ -456,11 +458,13 @@ describe("gateway server sessions", () => { const reset = await rpcReq<{ ok: true; key: string; - entry: { sessionId: string }; + entry: { sessionId: string; modelProvider?: string; model?: string }; }>(ws, "sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); + expect(reset.payload?.entry.modelProvider).toBe("anthropic"); + expect(reset.payload?.entry.model).toBe("claude-sonnet-4-6"); const filesAfterReset = await fs.readdir(dir); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 856e54ecebda..107d8a832635 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -79,12 +79,24 @@ describe("gateway talk.config", () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); - const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( - ws, - "talk.config", - {}, - ); + const res = await rpcReq<{ + config?: { + talk?: { + provider?: string; + providers?: { + elevenlabs?: { voiceId?: string; apiKey?: string }; + }; + apiKey?: string; + voiceId?: string; + }; + }; + }>(ws, "talk.config", {}); expect(res.ok).toBe(true); + expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe( + "__OPENCLAW_REDACTED__", + ); expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); }); @@ -113,4 +125,38 @@ describe("gateway talk.config", () => { expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc"); }); }); + + it("prefers normalized provider payload over conflicting legacy talk keys", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-normalized", + }, + }, + voiceId: "voice-legacy", + }, + }); + + await withServer(async (ws) => { + await connectOperator(ws, ["operator.read"]); + const res = await rpcReq<{ + config?: { + talk?: { + provider?: string; + providers?: { + elevenlabs?: { voiceId?: string }; + }; + voiceId?: string; + }; + }; + }>(ws, "talk.config", {}); + expect(res.ok).toBe(true); + expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized"); + expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized"); + }); + }); }); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index d5e98dfd533d..cb7977722889 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -133,9 +133,13 @@ export async function resolveConnectAuthState(params: { // primary auth flow (or deferred for device-token candidates). rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, })); + // Trusted-proxy auth is semantically shared: the proxy vouches for identity, + // no per-device credential needed. Include it so operator connections + // can skip device identity via roleCanSkipDeviceIdentity(). const sharedAuthOk = - sharedAuthResult?.ok === true && - (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); + (sharedAuthResult?.ok === true && + (sharedAuthResult.method === "token" || sharedAuthResult.method === "password")) || + (authResult.ok && authResult.method === "trusted-proxy"); return { authResult, diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index e0b691fecdcb..320f90537cee 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -49,6 +49,7 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -68,6 +69,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -82,6 +84,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -101,6 +104,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -114,6 +118,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -127,6 +132,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: false, authOk: false, hasSharedAuth: true, @@ -140,15 +146,31 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, isLocalClient: false, }).kind, ).toBe("reject-device-required"); + + // Trusted-proxy authenticated Control UI should bypass device-identity gating. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: true, + controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("allow"); }); - test("pairing bypass requires control-ui bypass + shared auth", () => { + test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, @@ -159,8 +181,9 @@ describe("ws connect policy", () => { controlUiConfig: undefined, deviceRaw: null, }); - expect(shouldSkipControlUiPairing(bypass, true)).toBe(true); - expect(shouldSkipControlUiPairing(bypass, false)).toBe(false); - expect(shouldSkipControlUiPairing(strict, true)).toBe(false); + expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true); + expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true); }); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index b52cb066411b..70dbea075058 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -35,7 +35,11 @@ export function resolveControlUiAuthPolicy(params: { export function shouldSkipControlUiPairing( policy: ControlUiAuthPolicy, sharedAuthOk: boolean, + trustedProxyAuthOk = false, ): boolean { + if (trustedProxyAuthOk) { + return true; + } return policy.allowBypass && sharedAuthOk; } @@ -50,6 +54,7 @@ export function evaluateMissingDeviceIdentity(params: { role: GatewayRole; isControlUi: boolean; controlUiAuthPolicy: ControlUiAuthPolicy; + trustedProxyAuthOk?: boolean; sharedAuthOk: boolean; authOk: boolean; hasSharedAuth: boolean; @@ -58,6 +63,9 @@ export function evaluateMissingDeviceIdentity(params: { if (params.hasDeviceIdentity) { return { kind: "allow" }; } + if (params.isControlUi && params.trustedProxyAuthOk) { + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1798b71afb42..191278275ee8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -427,11 +427,17 @@ export function attachGatewayWsMessageHandler(params: { if (!device) { clearUnboundScopes(); } + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const decision = evaluateMissingDeviceIdentity({ hasDeviceIdentity: Boolean(device), role, isControlUi, controlUiAuthPolicy, + trustedProxyAuthOk, sharedAuthOk, authOk, hasSharedAuth, @@ -563,8 +569,13 @@ export function attachGatewayWsMessageHandler(params: { // In that case, don't force device pairing on first connect. const skipPairingForOperatorSharedAuth = role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const skipPairing = - shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) || + shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) || skipPairingForOperatorSharedAuth; if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 5ad550eb0ed2..b86e3be142e3 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -13,6 +13,7 @@ import { parseGroupKey, pruneLegacyStoreKeys, resolveGatewaySessionStoreTarget, + resolveSessionModelIdentityRef, resolveSessionModelRef, resolveSessionStoreKey, } from "./session-utils.js"; @@ -339,6 +340,159 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); }); + + test("falls back to resolved provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "google-gemini-cli", + model: "claude-sonnet-4-6", + }); + }); + + test("preserves provider from slash-prefixed model when modelProvider is missing", () => { + // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") + // parseModelRef should extract it correctly even without modelProvider set. + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); +}); + +describe("resolveSessionModelIdentityRef", () => { + test("does not inherit default provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("infers provider from configured model allowlist when unambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("keeps provider unknown when configured models are ambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("preserves provider from slash-prefixed runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "vercel-ai-gateway", + model: "anthropic/claude-sonnet-4-6", + }); + }); }); describe("deriveSessionTitle", () => { @@ -529,6 +683,99 @@ describe("listSessionsFromStore search", () => { expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); }); + test("does not guess provider for legacy runtime model without modelProvider", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBeUndefined(); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers provider for legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("anthropic"); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "anthropic/claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway"); + expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6"); + }); + test("exposes unknown totals when freshness is stale or missing", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 73dbd9c71be9..14165ab28754 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { + inferUniqueProviderFromConfiguredModels, parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, @@ -692,6 +693,39 @@ export function resolveSessionModelRef( return { provider, model }; } +export function resolveSessionModelIdentityRef( + cfg: OpenClawConfig, + entry?: + | SessionEntry + | Pick, + agentId?: string, +): { provider?: string; model: string } { + const runtimeModel = entry?.model?.trim(); + const runtimeProvider = entry?.modelProvider?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + const inferredProvider = inferUniqueProviderFromConfiguredModels({ + cfg, + model: runtimeModel, + }); + if (inferredProvider) { + return { provider: inferredProvider, model: runtimeModel }; + } + if (runtimeModel.includes("/")) { + const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER); + if (parsedRuntime) { + return { provider: parsedRuntime.provider, model: parsedRuntime.model }; + } + return { model: runtimeModel }; + } + return { model: runtimeModel }; + } + const resolved = resolveSessionModelRef(cfg, entry, agentId); + return { provider: resolved.provider, model: resolved.model }; +} + export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; @@ -782,8 +816,8 @@ export function listSessionsFromStore(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); const parsedAgent = parseAgentSessionKey(key); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); - const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); - const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER; + const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId); + const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; return { key, diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 3d8c575cf66f..6bf20d326411 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -87,6 +87,38 @@ describe("gateway sessions patch", () => { expect(res.entry.thinkingLevel).toBeUndefined(); }); + test("persists reasoningLevel=off (does not clear)", async () => { + const store: Record = {}; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: "off" }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBe("off"); + }); + + test("clears reasoningLevel when patch sets null", async () => { + const store: Record = { + "agent:main:main": { reasoningLevel: "stream" } as SessionEntry, + }; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: null }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBeUndefined(); + }); + test("persists elevatedLevel=off (does not clear)", async () => { const store: Record = {}; const res = await applySessionsPatchToStore({ @@ -211,6 +243,38 @@ describe("gateway sessions patch", () => { expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); }); + test("accepts explicit allowlisted refs absent from bundled catalog", async () => { + const store: Record = {}; + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("anthropic"); + expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); + }); + test("sets spawnDepth for subagent sessions", async () => { const store: Record = {}; const res = await applySessionsPatchToStore({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 99e83a3bea00..d55cf2cf1a41 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -186,11 +186,9 @@ export async function applySessionsPatchToStore(params: { if (!normalized) { return invalid('invalid reasoningLevel (use "on"|"off"|"stream")'); } - if (normalized === "off") { - delete next.reasoningLevel; - } else { - next.reasoningLevel = normalized; - } + // Persist "off" explicitly so that resolveDefaultReasoningLevel() + // does not re-enable reasoning for capable models (#24406). + next.reasoningLevel = normalized; } } diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts new file mode 100644 index 000000000000..509df14497f7 --- /dev/null +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -0,0 +1,142 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; + +let cfg: Record = {}; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => cfg, +})); + +vi.mock("../config/sessions.js", () => ({ + resolveMainSessionKey: () => "agent:main:main", +})); + +vi.mock("./auth.js", () => ({ + authorizeHttpGatewayConnect: async () => ({ ok: true }), +})); + +vi.mock("../logger.js", () => ({ + logWarn: () => {}, +})); + +vi.mock("../plugins/config-state.js", () => ({ + isTestDefaultMemorySlotDisabled: () => false, +})); + +vi.mock("../plugins/tools.js", () => ({ + getPluginToolMeta: () => undefined, +})); + +vi.mock("../agents/openclaw-tools.js", () => { + const tools = [ + { + name: "cron", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "cron" }), + }, + { + name: "gateway", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "gateway" }), + }, + ]; + return { + createOpenClawTools: () => tools, + }; +}); + +const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js"); + +let port = 0; +let server: ReturnType | undefined; + +beforeAll(async () => { + server = createServer((req, res) => { + void handleToolsInvokeHttpRequest(req, res, { + auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false }, + }).then((handled) => { + if (handled) { + return; + } + res.statusCode = 404; + res.end("not found"); + }); + }); + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(0, "127.0.0.1", () => { + const address = server?.address() as AddressInfo | null; + port = address?.port ?? 0; + resolve(); + }); + }); +}); + +afterAll(async () => { + if (!server) { + return; + } + await new Promise((resolve) => server?.close(() => resolve())); + server = undefined; +}); + +beforeEach(() => { + cfg = {}; +}); + +async function invoke(tool: string) { + return await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${TEST_GATEWAY_TOKEN}`, + }, + body: JSON.stringify({ tool, action: "status", args: {}, sessionKey: "main" }), + }); +} + +describe("tools invoke HTTP denylist", () => { + it("blocks cron and gateway by default", async () => { + const gatewayRes = await invoke("gateway"); + const cronRes = await invoke("cron"); + + expect(gatewayRes.status).toBe(404); + expect(cronRes.status).toBe(404); + }); + + it("allows cron only when explicitly enabled in gateway.tools.allow", async () => { + cfg = { + gateway: { + tools: { + allow: ["cron"], + }, + }, + }; + + const cronRes = await invoke("cron"); + + expect(cronRes.status).toBe(200); + }); + + it("keeps cron available under coding profile without exposing gateway", async () => { + cfg = { + tools: { + profile: "coding", + }, + gateway: { + tools: { + allow: ["cron"], + }, + }, + }; + + const cronRes = await invoke("cron"); + const gatewayRes = await invoke("gateway"); + + expect(cronRes.status).toBe(200); + expect(gatewayRes.status).toBe(404); + }); +}); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 3a2ec73607b5..f87f00593a0a 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; let cfg: Record = {}; +let lastCreateOpenClawToolsContext: Record | undefined; // Perf: keep this suite pure unit. Mock heavyweight config/session modules. vi.mock("../config/config.js", () => ({ @@ -78,7 +79,13 @@ vi.mock("../agents/openclaw-tools.js", () => { { name: "sessions_spawn", parameters: { type: "object", properties: {} }, - execute: async () => ({ ok: true }), + execute: async () => ({ + ok: true, + route: { + agentTo: lastCreateOpenClawToolsContext?.agentTo, + agentThreadId: lastCreateOpenClawToolsContext?.agentThreadId, + }, + }), }, { name: "sessions_send", @@ -119,7 +126,10 @@ vi.mock("../agents/openclaw-tools.js", () => { ]; return { - createOpenClawTools: () => tools, + createOpenClawTools: (ctx: Record) => { + lastCreateOpenClawToolsContext = ctx; + return tools; + }, }; }); @@ -176,6 +186,7 @@ beforeEach(() => { delete process.env.OPENCLAW_GATEWAY_PASSWORD; pluginHttpHandlers = []; cfg = {}; + lastCreateOpenClawToolsContext = undefined; }); const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN; @@ -365,6 +376,35 @@ describe("POST /tools/invoke", () => { expect(body.error.type).toBe("not_found"); }); + it("propagates message target/thread headers into tools context for sessions_spawn", async () => { + cfg = { + ...cfg, + agents: { + list: [{ id: "main", default: true, tools: { allow: ["sessions_spawn"] } }], + }, + gateway: { tools: { allow: ["sessions_spawn"] } }, + }; + + const res = await invokeTool({ + port: sharedPort, + headers: { + ...gatewayAuthHeaders(), + "x-openclaw-message-to": "channel:24514", + "x-openclaw-thread-id": "thread-24514", + }, + tool: "sessions_spawn", + sessionKey: "main", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.result?.route).toEqual({ + agentTo: "channel:24514", + agentThreadId: "thread-24514", + }); + }); + it("denies sessions_send via HTTP gateway", async () => { cfg = { ...cfg, diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0be53d5fc4e6..caf71c56c3ca 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -213,6 +213,8 @@ export async function handleToolsInvokeHttpRequest( getHeader(req, "x-openclaw-message-channel") ?? "", ); const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined; + const agentTo = getHeader(req, "x-openclaw-message-to")?.trim() || undefined; + const agentThreadId = getHeader(req, "x-openclaw-thread-id")?.trim() || undefined; const { agentId, @@ -248,6 +250,8 @@ export async function handleToolsInvokeHttpRequest( agentSessionKey: sessionKey, agentChannel: messageChannel ?? undefined, agentAccountId: accountId, + agentTo, + agentThreadId, config: cfg, pluginToolAllowlist: collectExplicitAllowlist([ profilePolicy, diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts index 866c808dbf08..d2018e20b43b 100644 --- a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts +++ b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts @@ -92,7 +92,10 @@ describe("bootstrap-extra-files hook", () => { const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); await handler(event); - - expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md", "TOOLS.md"]); + expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual([ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + ]); }); }); diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index f104cc4a7b89..eb355fc3289b 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -9,7 +9,10 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir, + resolveAgentEffectiveModelPrimary, } from "../agents/agent-scope.js"; +import { DEFAULT_PROVIDER, DEFAULT_MODEL } from "../agents/defaults.js"; +import { parseModelRef } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -41,6 +44,12 @@ ${params.sessionContent.slice(0, 2000)} Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`; + // Resolve model from agent config instead of using hardcoded defaults + const modelRef = resolveAgentEffectiveModelPrimary(params.cfg, agentId); + const parsed = modelRef ? parseModelRef(modelRef, DEFAULT_PROVIDER) : null; + const provider = parsed?.provider ?? DEFAULT_PROVIDER; + const model = parsed?.model ?? DEFAULT_MODEL; + const result = await runEmbeddedPiAgent({ sessionId: `slug-generator-${Date.now()}`, sessionKey: "temp:slug-generator", @@ -50,6 +59,8 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", agentDir, config: params.cfg, prompt, + provider, + model, timeoutMs: 15_000, // 15 second timeout runId: `slug-gen-${Date.now()}`, }); diff --git a/src/imessage/monitor/deliver.test.ts b/src/imessage/monitor/deliver.test.ts index 4c771b5fe57d..9db03d6ace54 100644 --- a/src/imessage/monitor/deliver.test.ts +++ b/src/imessage/monitor/deliver.test.ts @@ -123,4 +123,30 @@ describe("deliverReplies", () => { }), ); }); + + it("records outbound text and message ids in sent-message cache", async () => { + const remember = vi.fn(); + chunkTextWithModeMock.mockImplementation((text: string) => text.split("|")); + + await deliverReplies({ + replies: [{ text: "first|second" }], + target: "chat_id:30", + client, + accountId: "acct-3", + runtime, + maxBytes: 2048, + textLimit: 4000, + sentMessageCache: { remember }, + }); + + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { text: "first|second" }); + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { + text: "first", + messageId: "imsg-1", + }); + expect(remember).toHaveBeenCalledWith("acct-3:chat_id:30", { + text: "second", + messageId: "imsg-1", + }); + }); }); diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 84bd8994c131..71825be8d0ba 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -6,10 +6,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; - -type SentMessageCache = { - remember: (scope: string, text: string) => void; -}; +import type { SentMessageCache } from "./echo-cache.js"; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -19,7 +16,7 @@ export async function deliverReplies(params: { runtime: RuntimeEnv; maxBytes: number; textLimit: number; - sentMessageCache?: SentMessageCache; + sentMessageCache?: Pick; }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; @@ -39,31 +36,32 @@ export async function deliverReplies(params: { continue; } if (mediaList.length === 0) { - sentMessageCache?.remember(scope, text); + sentMessageCache?.remember(scope, { text }); for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { - await sendMessageIMessage(target, chunk, { + const sent = await sendMessageIMessage(target, chunk, { maxBytes, client, accountId, replyToId: payload.replyToId, }); - sentMessageCache?.remember(scope, chunk); + sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); } } else { let first = true; for (const url of mediaList) { const caption = first ? text : ""; first = false; - await sendMessageIMessage(target, caption, { + const sent = await sendMessageIMessage(target, caption, { mediaUrl: url, maxBytes, client, accountId, replyToId: payload.replyToId, }); - if (caption) { - sentMessageCache?.remember(scope, caption); - } + sentMessageCache?.remember(scope, { + text: caption || undefined, + messageId: sent.messageId, + }); } } runtime.log?.(`imessage: delivered reply to ${target}`); diff --git a/src/imessage/monitor/echo-cache.ts b/src/imessage/monitor/echo-cache.ts new file mode 100644 index 000000000000..c68ff04b9702 --- /dev/null +++ b/src/imessage/monitor/echo-cache.ts @@ -0,0 +1,85 @@ +export type SentMessageLookup = { + text?: string; + messageId?: string; +}; + +export type SentMessageCache = { + remember: (scope: string, lookup: SentMessageLookup) => void; + has: (scope: string, lookup: SentMessageLookup) => boolean; +}; + +const SENT_MESSAGE_TEXT_TTL_MS = 5000; +const SENT_MESSAGE_ID_TTL_MS = 60_000; + +function normalizeEchoTextKey(text: string | undefined): string | null { + if (!text) { + return null; + } + const normalized = text.replace(/\r\n?/g, "\n").trim(); + return normalized ? normalized : null; +} + +function normalizeEchoMessageIdKey(messageId: string | undefined): string | null { + if (!messageId) { + return null; + } + const normalized = messageId.trim(); + if (!normalized || normalized === "ok" || normalized === "unknown") { + return null; + } + return normalized; +} + +class DefaultSentMessageCache implements SentMessageCache { + private textCache = new Map(); + private messageIdCache = new Map(); + + remember(scope: string, lookup: SentMessageLookup): void { + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + this.textCache.set(`${scope}:${textKey}`, Date.now()); + } + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now()); + } + this.cleanup(); + } + + has(scope: string, lookup: SentMessageLookup): boolean { + this.cleanup(); + const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId); + if (messageIdKey) { + const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`); + if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) { + return true; + } + } + const textKey = normalizeEchoTextKey(lookup.text); + if (textKey) { + const textTimestamp = this.textCache.get(`${scope}:${textKey}`); + if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) { + return true; + } + } + return false; + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, timestamp] of this.textCache.entries()) { + if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) { + this.textCache.delete(key); + } + } + for (const [key, timestamp] of this.messageIdCache.entries()) { + if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) { + this.messageIdCache.delete(key); + } + } + } +} + +export function createSentMessageCache(): SentMessageCache { + return new DefaultSentMessageCache(); +} diff --git a/src/imessage/monitor/inbound-processing.test.ts b/src/imessage/monitor/inbound-processing.test.ts new file mode 100644 index 000000000000..d63c41633184 --- /dev/null +++ b/src/imessage/monitor/inbound-processing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + describeIMessageEchoDropLog, + resolveIMessageInboundDecision, +} from "./inbound-processing.js"; + +describe("resolveIMessageInboundDecision echo detection", () => { + const cfg = {} as OpenClawConfig; + + it("drops inbound messages when outbound message id matches echo cache", () => { + const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { + return lookup.messageId === "42"; + }); + + const decision = resolveIMessageInboundDecision({ + cfg, + accountId: "default", + message: { + id: 42, + sender: "+15555550123", + text: "Reasoning:\n_step_", + is_from_me: false, + is_group: false, + }, + opts: undefined, + messageText: "Reasoning:\n_step_", + bodyText: "Reasoning:\n_step_", + allowFrom: [], + groupAllowFrom: [], + groupPolicy: "open", + dmPolicy: "open", + storeAllowFrom: [], + historyLimit: 0, + groupHistories: new Map(), + echoCache: { has: echoHas }, + logVerbose: undefined, + }); + + expect(decision).toEqual({ kind: "drop", reason: "echo" }); + expect(echoHas).toHaveBeenCalledWith( + "default:imessage:+15555550123", + expect.objectContaining({ + text: "Reasoning:\n_step_", + messageId: "42", + }), + ); + }); +}); + +describe("describeIMessageEchoDropLog", () => { + it("includes message id when available", () => { + expect( + describeIMessageEchoDropLog({ + messageText: "Reasoning:\n_step_", + messageId: "abc-123", + }), + ).toContain("id=abc-123"); + }); +}); diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index 5f4757bf5423..cf51e958b31f 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -95,7 +95,7 @@ export function resolveIMessageInboundDecision(params: { storeAllowFrom: string[]; historyLimit: number; groupHistories: Map; - echoCache?: { has: (scope: string, text: string) => boolean }; + echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean }; logVerbose?: (msg: string) => void; }): IMessageInboundDecision { const senderRaw = params.message.sender ?? ""; @@ -224,15 +224,23 @@ export function resolveIMessageInboundDecision(params: { // Echo detection: check if the received message matches a recently sent message (within 5 seconds). // Scope by conversation so same text in different chats is not conflated. - if (params.echoCache && messageText) { + const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined; + if (params.echoCache && (messageText || inboundMessageId)) { const echoScope = buildIMessageEchoScope({ accountId: params.accountId, isGroup, chatId, sender, }); - if (params.echoCache.has(echoScope, messageText)) { - params.logVerbose?.(describeIMessageEchoDropLog({ messageText })); + if ( + params.echoCache.has(echoScope, { + text: messageText || undefined, + messageId: inboundMessageId, + }) + ) { + params.logVerbose?.( + describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }), + ); return { kind: "drop", reason: "echo" }; } } @@ -479,6 +487,11 @@ export function buildIMessageEchoScope(params: { return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`; } -export function describeIMessageEchoDropLog(params: { messageText: string }): string { - return `imessage: skipping echo message (matches recently sent text within 5s): "${truncateUtf16Safe(params.messageText, 50)}"`; +export function describeIMessageEchoDropLog(params: { + messageText: string; + messageId?: string; +}): string { + const preview = truncateUtf16Safe(params.messageText, 50); + const messageIdPart = params.messageId ? ` id=${params.messageId}` : ""; + return `imessage: skipping echo message${messageIdPart}: "${preview}"`; } diff --git a/src/imessage/monitor/monitor-provider.echo-cache.test.ts b/src/imessage/monitor/monitor-provider.echo-cache.test.ts new file mode 100644 index 000000000000..e67667c02285 --- /dev/null +++ b/src/imessage/monitor/monitor-provider.echo-cache.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSentMessageCache } from "./echo-cache.js"; + +describe("iMessage sent-message echo cache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("matches recent text within the same scope", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " }); + + expect(cache.has("acct:imessage:+1555", { text: "Reasoning:\n_step_" })).toBe(true); + expect(cache.has("acct:imessage:+1666", { text: "Reasoning:\n_step_" })).toBe(false); + }); + + it("matches by outbound message id and ignores placeholder ids", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { messageId: "abc-123" }); + cache.remember("acct:imessage:+1555", { messageId: "ok" }); + + expect(cache.has("acct:imessage:+1555", { messageId: "abc-123" })).toBe(true); + expect(cache.has("acct:imessage:+1555", { messageId: "ok" })).toBe(false); + }); + + it("keeps message-id lookups longer than text fallback", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T00:00:00Z")); + const cache = createSentMessageCache(); + + cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" }); + vi.advanceTimersByTime(6000); + + expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false); + expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true); + }); +}); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 703935954b15..3bfdc691163b 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -44,6 +44,7 @@ import { probeIMessage } from "../probe.js"; import { sendMessageIMessage } from "../send.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; import { deliverReplies } from "./deliver.js"; +import { createSentMessageCache } from "./echo-cache.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, @@ -80,51 +81,6 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise(); - private readonly ttlMs = 5000; // 5 seconds - - remember(scope: string, text: string): void { - if (!text?.trim()) { - return; - } - const key = `${scope}:${text.trim()}`; - this.cache.set(key, Date.now()); - this.cleanup(); - } - - has(scope: string, text: string): boolean { - if (!text?.trim()) { - return false; - } - const key = `${scope}:${text.trim()}`; - const timestamp = this.cache.get(key); - if (!timestamp) { - return false; - } - const age = Date.now() - timestamp; - if (age > this.ttlMs) { - this.cache.delete(key); - return false; - } - return true; - } - - private cleanup(): void { - const now = Date.now(); - for (const [text, timestamp] of this.cache.entries()) { - if (now - timestamp > this.ttlMs) { - this.cache.delete(text); - } - } - } -} - export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -140,7 +96,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P DEFAULT_GROUP_HISTORY_LIMIT, ); const groupHistories = new Map(); - const sentMessageCache = new SentMessageCache(); + const sentMessageCache = createSentMessageCache(); const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); const groupAllowFrom = normalizeAllowList( diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 46617f07d7dc..7af7489baf21 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -168,6 +168,9 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } + if (request.request.nodeId) { + lines.push(`Node: ${request.request.nodeId}`); + } if (request.request.host) { lines.push(`Host: ${request.request.host}`); } diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index ab43ff17ec5a..640ea8706d63 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -153,6 +153,60 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).not.toContain("/usr/bin/nice"); }); + it("unwraps busybox/toybox shell applets and persists inner executables", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + makeExecutable(dir, "toybox"); + const whoami = makeExecutable(dir, "whoami"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sh -lc whoami`, + argv: [busybox, "sh", "-lc", "whoami"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env, + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain(busybox); + }); + + it("fails closed for unsupported busybox/toybox applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sed -n 1p`, + argv: [busybox, "sed", "-n", "1p"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([]); + }); + it("fails closed for unresolved dispatch wrappers", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ @@ -171,6 +225,52 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).toEqual([]); }); + it("prevents allow-always bypass for busybox shell applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const safeBins = resolveSafeBins(undefined); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + + const first = evaluateShellAllowlist({ + command: `${busybox} sh -c 'echo warmup-ok'`, + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([echo]); + + const second = evaluateShellAllowlist({ + command: `${busybox} sh -c 'id > marker'`, + allowlist: [{ pattern: echo }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + expect( + requiresExecApproval({ + ask: "on-miss", + security: "allowlist", + analysisOk: second.analysisOk, + allowlistSatisfied: second.allowlistSatisfied, + }), + ).toBe(true); + }); + it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 6d48347e4031..687ce3039ba2 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { DEFAULT_SAFE_BINS, analyzeShellCommand, @@ -21,6 +22,7 @@ import { extractShellWrapperInlineCommand, isDispatchWrapperExecutable, isShellWrapperExecutable, + unwrapKnownShellMultiplexerInvocation, unwrapKnownDispatchWrapperInvocation, } from "./exec-wrapper-resolution.js"; @@ -103,6 +105,71 @@ export type ExecAllowlistEvaluation = { }; export type ExecSegmentSatisfiedBy = "allowlist" | "safeBins" | "skills" | null; +export type SkillBinTrustEntry = { + name: string; + resolvedPath: string; +}; + +function normalizeSkillBinName(value: string | undefined): string | null { + const trimmed = value?.trim().toLowerCase(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function normalizeSkillBinResolvedPath(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const resolved = path.resolve(trimmed); + if (process.platform === "win32") { + return resolved.replace(/\\/g, "/").toLowerCase(); + } + return resolved; +} + +function buildSkillBinTrustIndex( + entries: readonly SkillBinTrustEntry[] | undefined, +): Map> { + const trustByName = new Map>(); + if (!entries || entries.length === 0) { + return trustByName; + } + for (const entry of entries) { + const name = normalizeSkillBinName(entry.name); + const resolvedPath = normalizeSkillBinResolvedPath(entry.resolvedPath); + if (!name || !resolvedPath) { + continue; + } + const paths = trustByName.get(name) ?? new Set(); + paths.add(resolvedPath); + trustByName.set(name, paths); + } + return trustByName; +} + +function isSkillAutoAllowedSegment(params: { + segment: ExecCommandSegment; + allowSkills: boolean; + skillBinTrust: ReadonlyMap>; +}): boolean { + if (!params.allowSkills) { + return false; + } + const resolution = params.segment.resolution; + if (!resolution?.resolvedPath) { + return false; + } + const rawExecutable = resolution.rawExecutable?.trim() ?? ""; + if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) { + return false; + } + const executableName = normalizeSkillBinName(resolution.executableName); + const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath); + if (!executableName || !resolvedPath) { + return false; + } + return Boolean(params.skillBinTrust.get(executableName)?.has(resolvedPath)); +} function evaluateSegments( segments: ExecCommandSegment[], @@ -113,7 +180,7 @@ function evaluateSegments( cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }, ): { @@ -122,7 +189,8 @@ function evaluateSegments( segmentSatisfiedBy: ExecSegmentSatisfiedBy[]; } { const matches: ExecAllowlistEntry[] = []; - const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0; + const skillBinTrust = buildSkillBinTrustIndex(params.skillBins); + const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0; const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = []; const satisfied = segments.every((segment) => { @@ -151,19 +219,11 @@ function evaluateSegments( platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); - const rawExecutable = segment.resolution?.rawExecutable?.trim() ?? ""; - const executableName = segment.resolution?.executableName; - const usesExplicitPath = isPathScopedExecutableToken(rawExecutable); - let skillAllow = false; - if ( - allowSkills && - segment.resolution?.resolvedPath && - rawExecutable.length > 0 && - !usesExplicitPath && - executableName - ) { - skillAllow = Boolean(params.skillBins?.has(executableName)); - } + const skillAllow = isSkillAutoAllowedSegment({ + segment, + allowSkills, + skillBinTrust, + }); const by: ExecSegmentSatisfiedBy = match ? "allowlist" : safe @@ -178,6 +238,13 @@ function evaluateSegments( return { satisfied, matches, segmentSatisfiedBy }; } +function resolveAnalysisSegmentGroups(analysis: ExecCommandAnalysis): ExecCommandSegment[][] { + if (analysis.chains) { + return analysis.chains; + } + return [analysis.segments]; +} + export function evaluateExecAllowlist(params: { analysis: ExecCommandAnalysis; allowlist: ExecAllowlistEntry[]; @@ -186,7 +253,7 @@ export function evaluateExecAllowlist(params: { cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }): ExecAllowlistEvaluation { const allowlistMatches: ExecAllowlistEntry[] = []; @@ -195,44 +262,32 @@ export function evaluateExecAllowlist(params: { return { allowlistSatisfied: false, allowlistMatches, segmentSatisfiedBy }; } - // If the analysis contains chains, evaluate each chain part separately - if (params.analysis.chains) { - for (const chainSegments of params.analysis.chains) { - const result = evaluateSegments(chainSegments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - if (!result.satisfied) { - return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; + const hasChains = Boolean(params.analysis.chains); + for (const group of resolveAnalysisSegmentGroups(params.analysis)) { + const result = evaluateSegments(group, { + allowlist: params.allowlist, + safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, + cwd: params.cwd, + platform: params.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + skillBins: params.skillBins, + autoAllowSkills: params.autoAllowSkills, + }); + if (!result.satisfied) { + if (!hasChains) { + return { + allowlistSatisfied: false, + allowlistMatches: result.matches, + segmentSatisfiedBy: result.segmentSatisfiedBy, + }; } - allowlistMatches.push(...result.matches); - segmentSatisfiedBy.push(...result.segmentSatisfiedBy); + return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; } - return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; + allowlistMatches.push(...result.matches); + segmentSatisfiedBy.push(...result.segmentSatisfiedBy); } - - // No chains, evaluate all segments together - const result = evaluateSegments(params.analysis.segments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - return { - allowlistSatisfied: result.satisfied, - allowlistMatches: result.matches, - segmentSatisfiedBy: result.segmentSatisfiedBy, - }; + return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; } export type ExecAllowlistAnalysis = { @@ -304,6 +359,30 @@ function collectAllowAlwaysPatterns(params: { return; } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + collectAllowAlwaysPatterns({ + segment: { + raw: shellMultiplexerUnwrap.argv.join(" "), + argv: shellMultiplexerUnwrap.argv, + resolution: resolveCommandResolutionFromArgv( + shellMultiplexerUnwrap.argv, + params.cwd, + params.env, + ), + }, + cwd: params.cwd, + env: params.env, + platform: params.platform, + depth: params.depth + 1, + out: params.out, + }); + return; + } + const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd); if (!candidatePath) { return; @@ -373,7 +452,7 @@ export function evaluateShellAllowlist(params: { cwd?: string; env?: NodeJS.ProcessEnv; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; platform?: string | null; }): ExecAllowlistAnalysis { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index cddc8cfbdf6f..39ee8b3f3edb 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -24,6 +24,14 @@ import { type ExecAllowlistEntry, } from "./exec-approvals.js"; +function buildNestedEnvShellCommand(params: { + envExecutable: string; + depth: number; + payload: string; +}): string[] { + return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; +} + describe("exec approvals allowlist matching", () => { const baseResolution = { rawExecutable: "rg", @@ -43,6 +51,22 @@ describe("exec approvals allowlist matching", () => { } }); + it("matches bare * wildcard pattern against any resolved path", () => { + const match = matchAllowlist([{ pattern: "*" }], baseResolution); + expect(match).not.toBeNull(); + expect(match?.pattern).toBe("*"); + }); + + it("matches bare * wildcard against arbitrary executables", () => { + const match = matchAllowlist([{ pattern: "*" }], { + rawExecutable: "python3", + resolvedPath: "/usr/bin/python3", + executableName: "python3", + }); + expect(match).not.toBeNull(); + expect(match?.pattern).toBe("*"); + }); + it("requires a resolved path", () => { const match = matchAllowlist([{ pattern: "bin/rg" }], { rawExecutable: "bin/rg", @@ -283,6 +307,40 @@ describe("exec approvals command resolution", () => { expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); }); + it("fails closed when transparent env wrappers exceed unwrap depth", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const binDir = path.join(dir, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const envPath = path.join(binDir, "env"); + fs.writeFileSync(envPath, "#!/bin/sh\n"); + fs.chmodSync(envPath, 0o755); + + const analysis = analyzeArgvCommand({ + argv: buildNestedEnvShellCommand({ + envExecutable: envPath, + depth: 5, + payload: "echo pwned", + }), + cwd: dir, + env: makePathEnv(binDir), + }); + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: envPath }], + safeBins: normalizeSafeBins([]), + cwd: dir, + }); + + expect(analysis.ok).toBe(true); + expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true); + expect(analysis.segments[0]?.resolution?.blockedWrapper).toBe("env"); + expect(allowlistEval.allowlistSatisfied).toBe(false); + expect(allowlistEval.segmentSatisfiedBy).toEqual([null]); + }); + it("unwraps env wrapper with shell inner executable", () => { const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]); expect(resolution?.rawExecutable).toBe("bash"); @@ -543,6 +601,26 @@ describe("exec approvals shell allowlist (chained commands)", () => { expect(result.analysisOk).toBe(false); expect(result.allowlistSatisfied).toBe(false); }); + + it("satisfies allowlist when bare * wildcard is present", () => { + const dir = makeTempDir(); + const binPath = path.join(dir, "mybin"); + fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); + const env = makePathEnv(dir); + try { + const result = evaluateShellAllowlist({ + command: "mybin --flag", + allowlist: [{ pattern: "*" }], + safeBins: new Set(), + cwd: dir, + env, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("exec approvals allowlist evaluation", () => { @@ -621,7 +699,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -647,7 +725,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/tmp/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -673,13 +751,78 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); expect(result.allowlistSatisfied).toBe(false); expect(result.segmentSatisfiedBy).toEqual([null]); }); + + it("returns empty segment details for chain misses", () => { + const segment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const analysis = { + ok: true, + segments: [segment], + chains: [[segment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/other" }], + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.allowlistSatisfied).toBe(false); + expect(result.allowlistMatches).toEqual([]); + expect(result.segmentSatisfiedBy).toEqual([]); + }); + + it("aggregates segment satisfaction across chains", () => { + const allowlistSegment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const safeBinSegment = { + raw: "jq .foo", + argv: ["jq", ".foo"], + resolution: { + rawExecutable: "jq", + resolvedPath: "/usr/bin/jq", + executableName: "jq", + }, + }; + const analysis = { + ok: true, + segments: [allowlistSegment, safeBinSegment], + chains: [[allowlistSegment], [safeBinSegment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/tool" }], + safeBins: normalizeSafeBins(["jq"]), + cwd: "/tmp", + }); + if (process.platform === "win32") { + expect(result.allowlistSatisfied).toBe(false); + return; + } + expect(result.allowlistSatisfied).toBe(true); + expect(result.allowlistMatches.map((entry) => entry.pattern)).toEqual(["/usr/bin/tool"]); + expect(result.segmentSatisfiedBy).toEqual(["allowlist", "safeBins"]); + }); }); describe("exec approvals policy helpers", () => { diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 4fd3f63470dc..be4264e22ecd 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -16,6 +16,7 @@ export type ExecApprovalRequest = { request: { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 3dceb0fc598d..d102a1030f1e 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -223,7 +223,17 @@ export function matchAllowlist( entries: ExecAllowlistEntry[], resolution: CommandResolution | null, ): ExecAllowlistEntry | null { - if (!entries.length || !resolution?.resolvedPath) { + if (!entries.length) { + return null; + } + // A bare "*" wildcard allows any parsed executable command. + // Check it before the resolvedPath guard so unresolved PATH lookups still + // match (for example platform-specific executables without known extensions). + const bareWild = entries.find((e) => e.pattern?.trim() === "*"); + if (bareWild && resolution) { + return bareWild; + } + if (!resolution?.resolvedPath) { return null; } const resolvedPath = resolution.resolvedPath; diff --git a/src/infra/exec-safe-bin-policy-profiles.ts b/src/infra/exec-safe-bin-policy-profiles.ts new file mode 100644 index 000000000000..b450325d2fec --- /dev/null +++ b/src/infra/exec-safe-bin-policy-profiles.ts @@ -0,0 +1,315 @@ +export type SafeBinProfile = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: ReadonlySet; + deniedFlags?: ReadonlySet; + // Precomputed long-option metadata for GNU abbreviation resolution. + knownLongFlags?: readonly string[]; + knownLongFlagsSet?: ReadonlySet; + longFlagPrefixMap?: ReadonlyMap; +}; + +export type SafeBinProfileFixture = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: readonly string[]; + deniedFlags?: readonly string[]; +}; + +export type SafeBinProfileFixtures = Readonly>; + +const NO_FLAGS: ReadonlySet = new Set(); + +const toFlagSet = (flags?: readonly string[]): ReadonlySet => { + if (!flags || flags.length === 0) { + return NO_FLAGS; + } + return new Set(flags); +}; + +export function collectKnownLongFlags( + allowedValueFlags: ReadonlySet, + deniedFlags: ReadonlySet, +): string[] { + const known = new Set(); + for (const flag of allowedValueFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + for (const flag of deniedFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + return Array.from(known); +} + +export function buildLongFlagPrefixMap( + knownLongFlags: readonly string[], +): ReadonlyMap { + const prefixMap = new Map(); + for (const flag of knownLongFlags) { + if (!flag.startsWith("--") || flag.length <= 2) { + continue; + } + for (let length = 3; length <= flag.length; length += 1) { + const prefix = flag.slice(0, length); + const existing = prefixMap.get(prefix); + if (existing === undefined) { + prefixMap.set(prefix, flag); + continue; + } + if (existing !== flag) { + prefixMap.set(prefix, null); + } + } + } + return prefixMap; +} + +function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { + const allowedValueFlags = toFlagSet(fixture.allowedValueFlags); + const deniedFlags = toFlagSet(fixture.deniedFlags); + const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); + return { + minPositional: fixture.minPositional, + maxPositional: fixture.maxPositional, + allowedValueFlags, + deniedFlags, + knownLongFlags, + knownLongFlagsSet: new Set(knownLongFlags), + longFlagPrefixMap: buildLongFlagPrefixMap(knownLongFlags), + }; +} + +function compileSafeBinProfiles( + fixtures: Record, +): Record { + return Object.fromEntries( + Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), + ) as Record; +} + +export const SAFE_BIN_PROFILE_FIXTURES: Record = { + jq: { + maxPositional: 1, + allowedValueFlags: ["--arg", "--argjson", "--argstr"], + deniedFlags: [ + "--argfile", + "--rawfile", + "--slurpfile", + "--from-file", + "--library-path", + "-L", + "-f", + ], + }, + grep: { + // Keep grep stdin-only: pattern must come from -e/--regexp. + // Allowing one positional is ambiguous because -e consumes the pattern and + // frees the positional slot for a filename. + maxPositional: 0, + allowedValueFlags: [ + "--regexp", + "--max-count", + "--after-context", + "--before-context", + "--context", + "--devices", + "--binary-files", + "--exclude", + "--include", + "--label", + "-e", + "-m", + "-A", + "-B", + "-C", + "-D", + ], + deniedFlags: [ + "--file", + "--exclude-from", + "--dereference-recursive", + "--directories", + "--recursive", + "-f", + "-d", + "-r", + "-R", + ], + }, + cut: { + maxPositional: 0, + allowedValueFlags: [ + "--bytes", + "--characters", + "--fields", + "--delimiter", + "--output-delimiter", + "-b", + "-c", + "-f", + "-d", + ], + }, + sort: { + maxPositional: 0, + allowedValueFlags: [ + "--key", + "--field-separator", + "--buffer-size", + "--parallel", + "--batch-size", + "-k", + "-t", + "-S", + ], + // --compress-program can invoke an external executable and breaks stdin-only guarantees. + // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. + deniedFlags: [ + "--compress-program", + "--files0-from", + "--output", + "--random-source", + "--temporary-directory", + "-T", + "-o", + ], + }, + uniq: { + maxPositional: 0, + allowedValueFlags: [ + "--skip-fields", + "--skip-chars", + "--check-chars", + "--group", + "-f", + "-s", + "-w", + ], + }, + head: { + maxPositional: 0, + allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], + }, + tail: { + maxPositional: 0, + allowedValueFlags: [ + "--lines", + "--bytes", + "--sleep-interval", + "--max-unchanged-stats", + "--pid", + "-n", + "-c", + ], + }, + tr: { + minPositional: 1, + maxPositional: 2, + }, + wc: { + maxPositional: 0, + deniedFlags: ["--files0-from"], + }, +}; + +export const SAFE_BIN_PROFILES: Record = + compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); + +function normalizeSafeBinProfileName(raw: string): string | null { + const name = raw.trim().toLowerCase(); + return name.length > 0 ? name : null; +} + +function normalizeFixtureLimit(raw: number | undefined): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const next = Math.trunc(raw); + return next >= 0 ? next : undefined; +} + +function normalizeFixtureFlags( + flags: readonly string[] | undefined, +): readonly string[] | undefined { + if (!Array.isArray(flags) || flags.length === 0) { + return undefined; + } + const normalized = Array.from( + new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), + ).toSorted((a, b) => a.localeCompare(b)); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { + const minPositional = normalizeFixtureLimit(fixture.minPositional); + const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); + const maxPositional = + minPositional !== undefined && + maxPositionalRaw !== undefined && + maxPositionalRaw < minPositional + ? minPositional + : maxPositionalRaw; + return { + minPositional, + maxPositional, + allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), + deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), + }; +} + +export function normalizeSafeBinProfileFixtures( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalized: Record = {}; + if (!fixtures) { + return normalized; + } + for (const [rawName, fixture] of Object.entries(fixtures)) { + const name = normalizeSafeBinProfileName(rawName); + if (!name) { + continue; + } + normalized[name] = normalizeSafeBinProfileFixture(fixture); + } + return normalized; +} + +export function resolveSafeBinProfiles( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); + if (Object.keys(normalizedFixtures).length === 0) { + return SAFE_BIN_PROFILES; + } + return { + ...SAFE_BIN_PROFILES, + ...compileSafeBinProfiles(normalizedFixtures), + }; +} + +export function resolveSafeBinDeniedFlags( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): Record { + const out: Record = {}; + for (const [name, fixture] of Object.entries(fixtures)) { + const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); + if (denied.length > 0) { + out[name] = denied; + } + } + return out; +} + +export function renderSafeBinDeniedFlagsDocBullets( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): string { + const deniedByBin = resolveSafeBinDeniedFlags(fixtures); + const bins = Object.keys(deniedByBin).toSorted(); + return bins + .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) + .join("\n"); +} diff --git a/src/infra/exec-safe-bin-policy-validator.ts b/src/infra/exec-safe-bin-policy-validator.ts new file mode 100644 index 000000000000..831602852424 --- /dev/null +++ b/src/infra/exec-safe-bin-policy-validator.ts @@ -0,0 +1,206 @@ +import { parseExecArgvToken } from "./exec-approvals-analysis.js"; +import { + buildLongFlagPrefixMap, + collectKnownLongFlags, + type SafeBinProfile, +} from "./exec-safe-bin-policy-profiles.js"; + +function isPathLikeToken(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed === "-") { + return false; + } + if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { + return true; + } + if (trimmed.startsWith("/")) { + return true; + } + return /^[A-Za-z]:[\\/]/.test(trimmed); +} + +function hasGlobToken(value: string): boolean { + // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. + // Note: we still harden execution-time expansion separately. + return /[*?[\]]/.test(value); +} + +const NO_FLAGS: ReadonlySet = new Set(); + +function isSafeLiteralToken(value: string): boolean { + if (!value || value === "-") { + return true; + } + return !hasGlobToken(value) && !isPathLikeToken(value); +} + +function isInvalidValueToken(value: string | undefined): boolean { + return !value || !isSafeLiteralToken(value); +} + +function resolveCanonicalLongFlag(params: { + flag: string; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): string | null { + if (!params.flag.startsWith("--") || params.flag.length <= 2) { + return null; + } + if (params.knownLongFlagsSet.has(params.flag)) { + return params.flag; + } + return params.longFlagPrefixMap.get(params.flag) ?? null; +} + +function consumeLongOptionToken(params: { + args: string[]; + index: number; + flag: string; + inlineValue: string | undefined; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): number { + const canonicalFlag = resolveCanonicalLongFlag({ + flag: params.flag, + knownLongFlagsSet: params.knownLongFlagsSet, + longFlagPrefixMap: params.longFlagPrefixMap, + }); + if (!canonicalFlag) { + return -1; + } + if (params.deniedFlags.has(canonicalFlag)) { + return -1; + } + const expectsValue = params.allowedValueFlags.has(canonicalFlag); + if (params.inlineValue !== undefined) { + if (!expectsValue) { + return -1; + } + return isSafeLiteralToken(params.inlineValue) ? params.index + 1 : -1; + } + if (!expectsValue) { + return params.index + 1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; +} + +function consumeShortOptionClusterToken(params: { + args: string[]; + index: number; + cluster: string; + flags: string[]; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; +}): number { + for (let j = 0; j < params.flags.length; j += 1) { + const flag = params.flags[j]; + if (params.deniedFlags.has(flag)) { + return -1; + } + if (!params.allowedValueFlags.has(flag)) { + continue; + } + const inlineValue = params.cluster.slice(j + 1); + if (inlineValue) { + return isSafeLiteralToken(inlineValue) ? params.index + 1 : -1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; + } + return -1; +} + +function consumePositionalToken(token: string, positional: string[]): boolean { + if (!isSafeLiteralToken(token)) { + return false; + } + positional.push(token); + return true; +} + +function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { + const minPositional = profile.minPositional ?? 0; + if (positional.length < minPositional) { + return false; + } + return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; +} + +export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { + const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; + const deniedFlags = profile.deniedFlags ?? NO_FLAGS; + const knownLongFlags = + profile.knownLongFlags ?? collectKnownLongFlags(allowedValueFlags, deniedFlags); + const knownLongFlagsSet = profile.knownLongFlagsSet ?? new Set(knownLongFlags); + const longFlagPrefixMap = profile.longFlagPrefixMap ?? buildLongFlagPrefixMap(knownLongFlags); + + const positional: string[] = []; + let i = 0; + while (i < args.length) { + const rawToken = args[i] ?? ""; + const token = parseExecArgvToken(rawToken); + + if (token.kind === "empty" || token.kind === "stdin") { + i += 1; + continue; + } + + if (token.kind === "terminator") { + for (let j = i + 1; j < args.length; j += 1) { + const rest = args[j]; + if (!rest || rest === "-") { + continue; + } + if (!consumePositionalToken(rest, positional)) { + return false; + } + } + break; + } + + if (token.kind === "positional") { + if (!consumePositionalToken(token.raw, positional)) { + return false; + } + i += 1; + continue; + } + + if (token.style === "long") { + const nextIndex = consumeLongOptionToken({ + args, + index: i, + flag: token.flag, + inlineValue: token.inlineValue, + allowedValueFlags, + deniedFlags, + knownLongFlagsSet, + longFlagPrefixMap, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + continue; + } + + const nextIndex = consumeShortOptionClusterToken({ + args, + index: i, + cluster: token.cluster, + flags: token.flags, + allowedValueFlags, + deniedFlags, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + } + + return validatePositionalCount(positional, profile); +} diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 886e95ccce02..285b1465e530 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, renderSafeBinDeniedFlagsDocBullets, validateSafeBinArgv, } from "./exec-safe-bin-policy.js"; @@ -76,6 +78,38 @@ describe("exec safe bin policy wc", () => { }); }); +describe("exec safe bin policy long-option metadata", () => { + it("precomputes long-option prefix mappings for compiled profiles", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + expect(sortProfile.knownLongFlagsSet?.has("--compress-program")).toBe(true); + expect(sortProfile.longFlagPrefixMap?.get("--compress-prog")).toBe("--compress-program"); + expect(sortProfile.longFlagPrefixMap?.get("--f")).toBe(null); + }); + + it("preserves behavior when profile metadata is missing and rebuilt at runtime", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const withoutMetadata = { + ...sortProfile, + knownLongFlags: undefined, + knownLongFlagsSet: undefined, + longFlagPrefixMap: undefined, + }; + expect(validateSafeBinArgv(["--compress-prog=sh"], withoutMetadata)).toBe(false); + expect(validateSafeBinArgv(["--totally-unknown=1"], withoutMetadata)).toBe(false); + }); + + it("builds prefix maps from collected long flags", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const flags = collectKnownLongFlags( + sortProfile.allowedValueFlags ?? new Set(), + sortProfile.deniedFlags ?? new Set(), + ); + const prefixMap = buildLongFlagPrefixMap(flags); + expect(prefixMap.get("--compress-pr")).toBe("--compress-program"); + expect(prefixMap.get("--f")).toBe(null); + }); +}); + describe("exec safe bin policy denied-flag matrix", () => { for (const [binName, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { const profile = SAFE_BIN_PROFILES[binName]; diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index d726bb55a105..cd8598098289 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -1,472 +1,15 @@ -import { parseExecArgvToken } from "./exec-approvals-analysis.js"; - -function isPathLikeToken(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (trimmed === "-") { - return false; - } - if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { - return true; - } - if (trimmed.startsWith("/")) { - return true; - } - return /^[A-Za-z]:[\\/]/.test(trimmed); -} - -function hasGlobToken(value: string): boolean { - // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. - // Note: we still harden execution-time expansion separately. - return /[*?[\]]/.test(value); -} - -export type SafeBinProfile = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: ReadonlySet; - deniedFlags?: ReadonlySet; -}; - -export type SafeBinProfileFixture = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: readonly string[]; - deniedFlags?: readonly string[]; -}; - -export type SafeBinProfileFixtures = Readonly>; - -const NO_FLAGS: ReadonlySet = new Set(); - -const toFlagSet = (flags?: readonly string[]): ReadonlySet => { - if (!flags || flags.length === 0) { - return NO_FLAGS; - } - return new Set(flags); -}; - -function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { - return { - minPositional: fixture.minPositional, - maxPositional: fixture.maxPositional, - allowedValueFlags: toFlagSet(fixture.allowedValueFlags), - deniedFlags: toFlagSet(fixture.deniedFlags), - }; -} - -function compileSafeBinProfiles( - fixtures: Record, -): Record { - return Object.fromEntries( - Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), - ) as Record; -} - -export const SAFE_BIN_PROFILE_FIXTURES: Record = { - jq: { - maxPositional: 1, - allowedValueFlags: ["--arg", "--argjson", "--argstr"], - deniedFlags: [ - "--argfile", - "--rawfile", - "--slurpfile", - "--from-file", - "--library-path", - "-L", - "-f", - ], - }, - grep: { - // Keep grep stdin-only: pattern must come from -e/--regexp. - // Allowing one positional is ambiguous because -e consumes the pattern and - // frees the positional slot for a filename. - maxPositional: 0, - allowedValueFlags: [ - "--regexp", - "--max-count", - "--after-context", - "--before-context", - "--context", - "--devices", - "--binary-files", - "--exclude", - "--include", - "--label", - "-e", - "-m", - "-A", - "-B", - "-C", - "-D", - ], - deniedFlags: [ - "--file", - "--exclude-from", - "--dereference-recursive", - "--directories", - "--recursive", - "-f", - "-d", - "-r", - "-R", - ], - }, - cut: { - maxPositional: 0, - allowedValueFlags: [ - "--bytes", - "--characters", - "--fields", - "--delimiter", - "--output-delimiter", - "-b", - "-c", - "-f", - "-d", - ], - }, - sort: { - maxPositional: 0, - allowedValueFlags: [ - "--key", - "--field-separator", - "--buffer-size", - "--parallel", - "--batch-size", - "-k", - "-t", - "-S", - ], - // --compress-program can invoke an external executable and breaks stdin-only guarantees. - // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. - deniedFlags: [ - "--compress-program", - "--files0-from", - "--output", - "--random-source", - "--temporary-directory", - "-T", - "-o", - ], - }, - uniq: { - maxPositional: 0, - allowedValueFlags: [ - "--skip-fields", - "--skip-chars", - "--check-chars", - "--group", - "-f", - "-s", - "-w", - ], - }, - head: { - maxPositional: 0, - allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], - }, - tail: { - maxPositional: 0, - allowedValueFlags: [ - "--lines", - "--bytes", - "--sleep-interval", - "--max-unchanged-stats", - "--pid", - "-n", - "-c", - ], - }, - tr: { - minPositional: 1, - maxPositional: 2, - }, - wc: { - maxPositional: 0, - deniedFlags: ["--files0-from"], - }, -}; - -export const SAFE_BIN_PROFILES: Record = - compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); - -function normalizeSafeBinProfileName(raw: string): string | null { - const name = raw.trim().toLowerCase(); - return name.length > 0 ? name : null; -} - -function normalizeFixtureLimit(raw: number | undefined): number | undefined { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - const next = Math.trunc(raw); - return next >= 0 ? next : undefined; -} - -function normalizeFixtureFlags( - flags: readonly string[] | undefined, -): readonly string[] | undefined { - if (!Array.isArray(flags) || flags.length === 0) { - return undefined; - } - const normalized = Array.from( - new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), - ).toSorted((a, b) => a.localeCompare(b)); - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { - const minPositional = normalizeFixtureLimit(fixture.minPositional); - const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); - const maxPositional = - minPositional !== undefined && - maxPositionalRaw !== undefined && - maxPositionalRaw < minPositional - ? minPositional - : maxPositionalRaw; - return { - minPositional, - maxPositional, - allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), - deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), - }; -} - -export function normalizeSafeBinProfileFixtures( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalized: Record = {}; - if (!fixtures) { - return normalized; - } - for (const [rawName, fixture] of Object.entries(fixtures)) { - const name = normalizeSafeBinProfileName(rawName); - if (!name) { - continue; - } - normalized[name] = normalizeSafeBinProfileFixture(fixture); - } - return normalized; -} - -export function resolveSafeBinProfiles( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); - if (Object.keys(normalizedFixtures).length === 0) { - return SAFE_BIN_PROFILES; - } - return { - ...SAFE_BIN_PROFILES, - ...compileSafeBinProfiles(normalizedFixtures), - }; -} - -export function resolveSafeBinDeniedFlags( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): Record { - const out: Record = {}; - for (const [name, fixture] of Object.entries(fixtures)) { - const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); - if (denied.length > 0) { - out[name] = denied; - } - } - return out; -} - -export function renderSafeBinDeniedFlagsDocBullets( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): string { - const deniedByBin = resolveSafeBinDeniedFlags(fixtures); - const bins = Object.keys(deniedByBin).toSorted(); - return bins - .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) - .join("\n"); -} - -function isSafeLiteralToken(value: string): boolean { - if (!value || value === "-") { - return true; - } - return !hasGlobToken(value) && !isPathLikeToken(value); -} - -function isInvalidValueToken(value: string | undefined): boolean { - return !value || !isSafeLiteralToken(value); -} - -function collectKnownLongFlags( - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): string[] { - const known = new Set(); - for (const flag of allowedValueFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - for (const flag of deniedFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - return Array.from(known); -} - -function resolveCanonicalLongFlag(flag: string, knownLongFlags: string[]): string | null { - if (!flag.startsWith("--") || flag.length <= 2) { - return null; - } - if (knownLongFlags.includes(flag)) { - return flag; - } - const matches = knownLongFlags.filter((candidate) => candidate.startsWith(flag)); - if (matches.length !== 1) { - return null; - } - return matches[0] ?? null; -} - -function consumeLongOptionToken( - args: string[], - index: number, - flag: string, - inlineValue: string | undefined, - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); - const canonicalFlag = resolveCanonicalLongFlag(flag, knownLongFlags); - if (!canonicalFlag) { - return -1; - } - if (deniedFlags.has(canonicalFlag)) { - return -1; - } - const expectsValue = allowedValueFlags.has(canonicalFlag); - if (inlineValue !== undefined) { - if (!expectsValue) { - return -1; - } - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - if (!expectsValue) { - return index + 1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; -} - -function consumeShortOptionClusterToken( - args: string[], - index: number, - _raw: string, - cluster: string, - flags: string[], - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - for (let j = 0; j < flags.length; j += 1) { - const flag = flags[j]; - if (deniedFlags.has(flag)) { - return -1; - } - if (!allowedValueFlags.has(flag)) { - continue; - } - const inlineValue = cluster.slice(j + 1); - if (inlineValue) { - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; - } - return -1; -} - -function consumePositionalToken(token: string, positional: string[]): boolean { - if (!isSafeLiteralToken(token)) { - return false; - } - positional.push(token); - return true; -} - -function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { - const minPositional = profile.minPositional ?? 0; - if (positional.length < minPositional) { - return false; - } - return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; -} - -export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { - const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; - const deniedFlags = profile.deniedFlags ?? NO_FLAGS; - const positional: string[] = []; - let i = 0; - while (i < args.length) { - const rawToken = args[i] ?? ""; - const token = parseExecArgvToken(rawToken); - - if (token.kind === "empty" || token.kind === "stdin") { - i += 1; - continue; - } - - if (token.kind === "terminator") { - for (let j = i + 1; j < args.length; j += 1) { - const rest = args[j]; - if (!rest || rest === "-") { - continue; - } - if (!consumePositionalToken(rest, positional)) { - return false; - } - } - break; - } - - if (token.kind === "positional") { - if (!consumePositionalToken(token.raw, positional)) { - return false; - } - i += 1; - continue; - } - - if (token.style === "long") { - const nextIndex = consumeLongOptionToken( - args, - i, - token.flag, - token.inlineValue, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - continue; - } - - const nextIndex = consumeShortOptionClusterToken( - args, - i, - token.raw, - token.cluster, - token.flags, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - } - - return validatePositionalCount(positional, profile); -} +export { + SAFE_BIN_PROFILE_FIXTURES, + SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, + normalizeSafeBinProfileFixtures, + renderSafeBinDeniedFlagsDocBullets, + resolveSafeBinDeniedFlags, + resolveSafeBinProfiles, + type SafeBinProfile, + type SafeBinProfileFixture, + type SafeBinProfileFixtures, +} from "./exec-safe-bin-policy-profiles.js"; + +export { validateSafeBinArgv } from "./exec-safe-bin-policy-validator.js"; diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index 29f29864be2d..af5510be5f29 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -1,5 +1,7 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isInterpreterLikeSafeBin, listInterpreterLikeSafeBins, @@ -15,6 +17,8 @@ describe("exec safe-bin runtime policy", () => { { bin: "node20", expected: true }, { bin: "ruby3.2", expected: true }, { bin: "bash", expected: true }, + { bin: "busybox", expected: true }, + { bin: "toybox", expected: true }, { bin: "myfilter", expected: false }, { bin: "jq", expected: false }, ]; @@ -87,4 +91,48 @@ describe("exec safe-bin runtime policy", () => { expect(policy.trustedSafeBinDirs.has(path.resolve(customDir))).toBe(true); expect(policy.trustedSafeBinDirs.has(path.resolve(agentDir))).toBe(true); }); + + it("does not trust package-manager bin dirs unless explicitly configured", () => { + const defaultPolicy = resolveExecSafeBinRuntimePolicy({}); + expect(defaultPolicy.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(false); + expect(defaultPolicy.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(false); + + const optedIn = resolveExecSafeBinRuntimePolicy({ + global: { + safeBinTrustedDirs: ["/opt/homebrew/bin", "/usr/local/bin"], + }, + }); + expect(optedIn.trustedSafeBinDirs.has(path.resolve("/opt/homebrew/bin"))).toBe(true); + expect(optedIn.trustedSafeBinDirs.has(path.resolve("/usr/local/bin"))).toBe(true); + }); + + it("emits runtime warning when explicitly trusted dir is writable", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-runtime-")); + try { + await fs.chmod(dir, 0o777); + const onWarning = vi.fn(); + const policy = resolveExecSafeBinRuntimePolicy({ + global: { + safeBinTrustedDirs: [dir], + }, + onWarning, + }); + + expect(policy.writableTrustedSafeBinDirs).toEqual([ + { + dir: path.resolve(dir), + groupWritable: true, + worldWritable: true, + }, + ]); + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining(path.resolve(dir))); + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining("world-writable")); + } finally { + await fs.chmod(dir, 0o755).catch(() => undefined); + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index a6f71d16f918..955d130de11e 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -6,7 +6,12 @@ import { type SafeBinProfileFixture, type SafeBinProfileFixtures, } from "./exec-safe-bin-policy.js"; -import { getTrustedSafeBinDirs, normalizeTrustedSafeBinDirs } from "./exec-safe-bin-trust.js"; +import { + getTrustedSafeBinDirs, + listWritableExplicitTrustedSafeBinDirs, + normalizeTrustedSafeBinDirs, + type WritableTrustedSafeBinDir, +} from "./exec-safe-bin-trust.js"; export type ExecSafeBinConfigScope = { safeBins?: string[] | null; @@ -17,6 +22,7 @@ export type ExecSafeBinConfigScope = { const INTERPRETER_LIKE_SAFE_BINS = new Set([ "ash", "bash", + "busybox", "bun", "cmd", "cmd.exe", @@ -40,6 +46,7 @@ const INTERPRETER_LIKE_SAFE_BINS = new Set([ "python3", "ruby", "sh", + "toybox", "wscript", "zsh", ]); @@ -97,12 +104,14 @@ export function resolveMergedSafeBinProfileFixtures(params: { export function resolveExecSafeBinRuntimePolicy(params: { global?: ExecSafeBinConfigScope | null; local?: ExecSafeBinConfigScope | null; + onWarning?: (message: string) => void; }): { safeBins: Set; safeBinProfiles: Readonly>; trustedSafeBinDirs: ReadonlySet; unprofiledSafeBins: string[]; unprofiledInterpreterSafeBins: string[]; + writableTrustedSafeBinDirs: ReadonlyArray; } { const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins); const safeBinProfiles = resolveSafeBinProfiles( @@ -114,17 +123,35 @@ export function resolveExecSafeBinRuntimePolicy(params: { const unprofiledSafeBins = Array.from(safeBins) .filter((entry) => !safeBinProfiles[entry]) .toSorted(); + const explicitTrustedSafeBinDirs = [ + ...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs), + ...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs), + ]; const trustedSafeBinDirs = getTrustedSafeBinDirs({ - extraDirs: [ - ...normalizeTrustedSafeBinDirs(params.global?.safeBinTrustedDirs), - ...normalizeTrustedSafeBinDirs(params.local?.safeBinTrustedDirs), - ], + extraDirs: explicitTrustedSafeBinDirs, }); + const writableTrustedSafeBinDirs = listWritableExplicitTrustedSafeBinDirs( + explicitTrustedSafeBinDirs, + ); + if (params.onWarning) { + for (const hit of writableTrustedSafeBinDirs) { + const scope = + hit.worldWritable || hit.groupWritable + ? hit.worldWritable + ? "world-writable" + : "group-writable" + : "writable"; + params.onWarning( + `exec: safeBinTrustedDirs includes ${scope} directory '${hit.dir}'; remove trust or tighten permissions (for example chmod 755).`, + ); + } + } return { safeBins, safeBinProfiles, trustedSafeBinDirs, unprofiledSafeBins, unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins), + writableTrustedSafeBinDirs, }; } diff --git a/src/infra/exec-safe-bin-trust.test.ts b/src/infra/exec-safe-bin-trust.test.ts index f653b13ca7ea..c22d062b8939 100644 --- a/src/infra/exec-safe-bin-trust.test.ts +++ b/src/infra/exec-safe-bin-trust.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; @@ -5,9 +7,19 @@ import { buildTrustedSafeBinDirs, getTrustedSafeBinDirs, isTrustedSafeBinPath, + listWritableExplicitTrustedSafeBinDirs, } from "./exec-safe-bin-trust.js"; describe("exec safe bin trust", () => { + it("keeps default trusted dirs limited to immutable system paths", () => { + const dirs = getTrustedSafeBinDirs({ refresh: true }); + + expect(dirs.has(path.resolve("/bin"))).toBe(true); + expect(dirs.has(path.resolve("/usr/bin"))).toBe(true); + expect(dirs.has(path.resolve("/usr/local/bin"))).toBe(false); + expect(dirs.has(path.resolve("/opt/homebrew/bin"))).toBe(false); + }); + it("builds trusted dirs from defaults and explicit extra dirs", () => { const dirs = buildTrustedSafeBinDirs({ baseDirs: ["/usr/bin"], @@ -60,4 +72,25 @@ describe("exec safe bin trust", () => { expect(refreshed.has(path.resolve(injected))).toBe(false); }); }); + + it("flags explicitly trusted dirs that are group/world writable", async () => { + if (process.platform === "win32") { + return; + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-safe-bin-trust-")); + try { + await fs.chmod(dir, 0o777); + const hits = listWritableExplicitTrustedSafeBinDirs([dir]); + expect(hits).toEqual([ + { + dir: path.resolve(dir), + groupWritable: true, + worldWritable: true, + }, + ]); + } finally { + await fs.chmod(dir, 0o755).catch(() => undefined); + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); }); diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index 9edfb16a449c..418a6d49200e 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -1,14 +1,9 @@ +import fs from "node:fs"; import path from "node:path"; -const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [ - "/bin", - "/usr/bin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/local/bin", - "/snap/bin", - "/run/current-system/sw/bin", -]; +// Keep defaults to OS-managed immutable bins only. +// User/package-manager bins must be opted in via tools.exec.safeBinTrustedDirs. +const DEFAULT_SAFE_BIN_TRUSTED_DIRS = ["/bin", "/usr/bin"]; type TrustedSafeBinDirsParams = { baseDirs?: readonly string[]; @@ -25,6 +20,12 @@ type TrustedSafeBinCache = { dirs: Set; }; +export type WritableTrustedSafeBinDir = { + dir: string; + groupWritable: boolean; + worldWritable: boolean; +}; + let trustedSafeBinCache: TrustedSafeBinCache | null = null; function normalizeTrustedDir(value: string): string | null { @@ -94,3 +95,32 @@ export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean const resolvedDir = path.dirname(path.resolve(params.resolvedPath)); return trustedDirs.has(resolvedDir); } + +export function listWritableExplicitTrustedSafeBinDirs( + entries?: readonly string[] | null, +): WritableTrustedSafeBinDir[] { + if (process.platform === "win32") { + return []; + } + const resolved = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries)); + const hits: WritableTrustedSafeBinDir[] = []; + for (const dir of resolved) { + let stat: fs.Stats; + try { + stat = fs.statSync(dir); + } catch { + continue; + } + if (!stat.isDirectory()) { + continue; + } + const mode = stat.mode & 0o777; + const groupWritable = (mode & 0o020) !== 0; + const worldWritable = (mode & 0o002) !== 0; + if (!groupWritable && !worldWritable) { + continue; + } + hits.push({ dir, groupWritable, worldWritable }); + } + return hits; +} diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 58fc18b0015a..1f91c3b4a1fc 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -7,6 +7,7 @@ const WINDOWS_EXE_SUFFIX = ".exe"; const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; +const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; const DISPATCH_WRAPPER_NAMES = [ "chrt", "doas", @@ -42,6 +43,7 @@ export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPAT const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); +const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); const SHELL_WRAPPER_CANONICAL = new Set([ ...POSIX_SHELL_WRAPPER_NAMES, @@ -133,6 +135,39 @@ function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { return null; } +export type ShellMultiplexerUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +export function unwrapKnownShellMultiplexerInvocation( + argv: string[], +): ShellMultiplexerUnwrapResult { + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + const wrapper = normalizeExecutableToken(token0); + if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { + return { kind: "not-wrapper" }; + } + + let appletIndex = 1; + if (argv[appletIndex]?.trim() === "--") { + appletIndex += 1; + } + const applet = argv[appletIndex]?.trim(); + if (!applet || !isShellWrapperExecutable(applet)) { + return { kind: "blocked", wrapper }; + } + + const unwrapped = argv.slice(appletIndex); + if (unwrapped.length === 0) { + return { kind: "blocked", wrapper }; + } + return { kind: "unwrapped", wrapper, argv: unwrapped }; +} + export function isEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); } @@ -413,6 +448,19 @@ function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolea return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper); } +function blockedDispatchWrapperPlan(params: { + argv: string[]; + wrappers: string[]; + blockedWrapper: string; +}): DispatchWrapperExecutionPlan { + return { + argv: params.argv, + wrappers: params.wrappers, + policyBlocked: true, + blockedWrapper: params.blockedWrapper, + }; +} + export function resolveDispatchWrapperExecutionPlan( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, @@ -422,27 +470,35 @@ export function resolveDispatchWrapperExecutionPlan( for (let depth = 0; depth < maxDepth; depth += 1) { const unwrap = unwrapKnownDispatchWrapperInvocation(current); if (unwrap.kind === "blocked") { - return { + return blockedDispatchWrapperPlan({ argv: current, wrappers, - policyBlocked: true, blockedWrapper: unwrap.wrapper, - }; + }); } if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) { break; } wrappers.push(unwrap.wrapper); if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) { - return { + return blockedDispatchWrapperPlan({ argv: current, wrappers, - policyBlocked: true, blockedWrapper: unwrap.wrapper, - }; + }); } current = unwrap.argv; } + if (wrappers.length >= maxDepth) { + const overflow = unwrapKnownDispatchWrapperInvocation(current); + if (overflow.kind === "blocked" || overflow.kind === "unwrapped") { + return blockedDispatchWrapperPlan({ + argv: current, + wrappers, + blockedWrapper: overflow.wrapper, + }); + } + } return { argv: current, wrappers, policyBlocked: false }; } @@ -474,6 +530,18 @@ function hasEnvManipulationBeforeShellWrapperInternal( ); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return false; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return hasEnvManipulationBeforeShellWrapperInternal( + shellMultiplexerUnwrap.argv, + depth + 1, + envManipulationSeen, + ); + } + const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); if (!wrapper) { return false; @@ -577,6 +645,14 @@ function extractShellWrapperCommandInternal( return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); + } + const base0 = normalizeExecutableToken(token0); const wrapper = findShellWrapperSpec(base0); if (!wrapper) { diff --git a/src/infra/file-identity.test.ts b/src/infra/file-identity.test.ts new file mode 100644 index 000000000000..12b3029cda12 --- /dev/null +++ b/src/infra/file-identity.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { sameFileIdentity, type FileIdentityStat } from "./file-identity.js"; + +function stat(dev: number | bigint, ino: number | bigint): FileIdentityStat { + return { dev, ino }; +} + +describe("sameFileIdentity", () => { + it("accepts exact dev+ino match", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 11), "linux")).toBe(true); + }); + + it("rejects inode mismatch", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 12), "linux")).toBe(false); + }); + + it("rejects dev mismatch on non-windows", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "linux")).toBe(false); + }); + + it("accepts win32 dev mismatch when either side is 0", () => { + expect(sameFileIdentity(stat(0, 11), stat(8, 11), "win32")).toBe(true); + expect(sameFileIdentity(stat(7, 11), stat(0, 11), "win32")).toBe(true); + }); + + it("keeps dev strictness on win32 when both dev values are non-zero", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "win32")).toBe(false); + }); + + it("handles bigint stats", () => { + expect(sameFileIdentity(stat(0n, 11n), stat(8n, 11n), "win32")).toBe(true); + }); +}); diff --git a/src/infra/file-identity.ts b/src/infra/file-identity.ts new file mode 100644 index 000000000000..686d6dd086e9 --- /dev/null +++ b/src/infra/file-identity.ts @@ -0,0 +1,25 @@ +export type FileIdentityStat = { + dev: number | bigint; + ino: number | bigint; +}; + +function isZero(value: number | bigint): boolean { + return value === 0 || value === 0n; +} + +export function sameFileIdentity( + left: FileIdentityStat, + right: FileIdentityStat, + platform: NodeJS.Platform = process.platform, +): boolean { + if (left.ino !== right.ino) { + return false; + } + + // On Windows, path-based stat calls can report dev=0 while fd-based stat + // reports a real volume serial; treat either-side dev=0 as "unknown device". + if (left.dev === right.dev) { + return true; + } + return platform === "win32" && (isZero(left.dev) || isZero(right.dev)); +} diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 7b6c648ee702..b42a109df989 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; +import { sameFileIdentity } from "./file-identity.js"; import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; export type SafeOpenErrorCode = @@ -62,13 +63,13 @@ async function openVerifiedLocalFile(filePath: string): Promise if (!stat.isFile()) { throw new SafeOpenError("not-file", "not a file"); } - if (stat.ino !== lstat.ino || stat.dev !== lstat.dev) { + if (!sameFileIdentity(stat, lstat)) { throw new SafeOpenError("path-mismatch", "path changed during read"); } const realPath = await fs.realpath(filePath); const realStat = await fs.stat(realPath); - if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) { + if (!sameFileIdentity(stat, realStat)) { throw new SafeOpenError("path-mismatch", "path mismatch"); } diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts new file mode 100644 index 000000000000..dab2250dd0ec --- /dev/null +++ b/src/infra/heartbeat-events-filter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { buildCronEventPrompt, buildExecEventPrompt } from "./heartbeat-events-filter.js"; + +describe("heartbeat event prompts", () => { + it("builds user-relay cron prompt by default", () => { + const prompt = buildCronEventPrompt(["Cron: rotate logs"]); + expect(prompt).toContain("Please relay this reminder to the user"); + }); + + it("builds internal-only cron prompt when delivery is disabled", () => { + const prompt = buildCronEventPrompt(["Cron: rotate logs"], { deliverToUser: false }); + expect(prompt).toContain("Handle this reminder internally"); + expect(prompt).not.toContain("Please relay this reminder to the user"); + }); + + it("builds internal-only exec prompt when delivery is disabled", () => { + const prompt = buildExecEventPrompt({ deliverToUser: false }); + expect(prompt).toContain("Handle the result internally"); + expect(prompt).not.toContain("Please relay the command output to the user"); + }); +}); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index f5042bb0bdfd..1682c3b308ba 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -3,14 +3,33 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; // Build a dynamic prompt for cron events by embedding the actual event content. // This ensures the model sees the reminder text directly instead of relying on // "shown in the system messages above" which may not be visible in context. -export function buildCronEventPrompt(pendingEvents: string[]): string { +export function buildCronEventPrompt( + pendingEvents: string[], + opts?: { + deliverToUser?: boolean; + }, +): string { + const deliverToUser = opts?.deliverToUser ?? true; const eventText = pendingEvents.join("\n").trim(); if (!eventText) { + if (!deliverToUser) { + return ( + "A scheduled cron event was triggered, but no event content was found. " + + "Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up." + ); + } return ( "A scheduled cron event was triggered, but no event content was found. " + "Reply HEARTBEAT_OK." ); } + if (!deliverToUser) { + return ( + "A scheduled reminder has been triggered. The reminder content is:\n\n" + + eventText + + "\n\nHandle this reminder internally. Do not relay it to the user unless explicitly requested." + ); + } return ( "A scheduled reminder has been triggered. The reminder content is:\n\n" + eventText + @@ -18,6 +37,21 @@ export function buildCronEventPrompt(pendingEvents: string[]): string { ); } +export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string { + const deliverToUser = opts?.deliverToUser ?? true; + if (!deliverToUser) { + return ( + "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "Handle the result internally. Do not relay it to the user unless explicitly requested." + ); + } + return ( + "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + + "If it failed, explain what went wrong." + ); +} + const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase(); // Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events. diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index b835df8863d9..648acf1813cf 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -37,6 +37,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const createConfig = async (params: { tmpDir: string; storePath: string; + target?: "telegram" | "none"; }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { @@ -44,7 +45,7 @@ describe("Ghost reminder bug (issue #13317)", () => { workspace: params.tmpDir, heartbeat: { every: "5m", - target: "telegram", + target: params.target ?? "telegram", }, }, }, @@ -54,7 +55,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const sessionKey = await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "155462274", + lastTo: "-100155462274", }); return { cfg, sessionKey }; @@ -96,6 +97,7 @@ describe("Ghost reminder bug (issue #13317)", () => { replyText: string; reason: string; enqueue: (sessionKey: string) => void; + target?: "telegram" | "none"; }): Promise<{ result: Awaited>; sendTelegram: ReturnType; @@ -105,7 +107,11 @@ describe("Ghost reminder bug (issue #13317)", () => { return withTempHeartbeatSandbox( async ({ tmpDir, storePath }) => { const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText); - const { cfg, sessionKey } = await createConfig({ tmpDir, storePath }); + const { cfg, sessionKey } = await createConfig({ + tmpDir, + storePath, + target: params.target, + }); params.enqueue(sessionKey); const result = await runHeartbeatOnce({ cfg, @@ -192,4 +198,38 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md"); expect(sendTelegram).toHaveBeenCalled(); }); + + it("uses an internal-only cron prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-cron-internal-", + replyText: "Handled internally", + reason: "cron:reminder-job", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("Reminder: Rotate API keys", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("Handle this reminder internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("uses an internal-only exec prompt when delivery target is none", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-exec-internal-", + replyText: "Handled internally", + reason: "exec-event", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("exec finished: deploy succeeded", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("exec-event"); + expect(calledCtx?.Body).toContain("Handle the result internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 926e5292a0d7..d0f4fd19bd7e 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -15,6 +15,9 @@ vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); installHeartbeatRunnerTestRuntime(); describe("runHeartbeatOnce ack handling", () => { + const WHATSAPP_GROUP = "120363140186826074@g.us"; + const TELEGRAM_GROUP = "-1001234567890"; + function createHeartbeatConfig(params: { tmpDir: string; storePath: string; @@ -105,7 +108,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(params.storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "12345", + lastTo: TELEGRAM_GROUP, }); params.replySpy.mockResolvedValue({ text: params.replyText }); @@ -150,7 +153,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(params.storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); return cfg; } @@ -166,7 +169,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" }); @@ -192,7 +195,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -204,7 +207,7 @@ describe("runHeartbeatOnce ack handling", () => { }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "HEARTBEAT_OK", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith(WHATSAPP_GROUP, "HEARTBEAT_OK", expect.any(Object)); }); }); @@ -239,7 +242,7 @@ describe("runHeartbeatOnce ack handling", () => { expect(sendTelegram).toHaveBeenCalledTimes(expectedCalls); if (expectedText) { - expect(sendTelegram).toHaveBeenCalledWith("12345", expectedText, expect.any(Object)); + expect(sendTelegram).toHaveBeenCalledWith(TELEGRAM_GROUP, expectedText, expect.any(Object)); } }); }); @@ -255,7 +258,7 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); const sendWhatsApp = createMessageSendSpy(); @@ -303,7 +306,7 @@ describe("runHeartbeatOnce ack handling", () => { updatedAt: originalUpdatedAt, lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: WHATSAPP_GROUP, }); replySpy.mockImplementationOnce(async () => { @@ -372,11 +375,11 @@ describe("runHeartbeatOnce ack handling", () => { await seedMainSessionStore(storePath, cfg, { lastChannel: "telegram", lastProvider: "telegram", - lastTo: "123456", + lastTo: TELEGRAM_GROUP, }); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); - const sendTelegram = createMessageSendSpy({ chatId: "123456" }); + const sendTelegram = createMessageSendSpy({ chatId: TELEGRAM_GROUP }); await runHeartbeatOnce({ cfg, @@ -385,7 +388,7 @@ describe("runHeartbeatOnce ack handling", () => { expect(sendTelegram).toHaveBeenCalledTimes(1); expect(sendTelegram).toHaveBeenCalledWith( - "123456", + TELEGRAM_GROUP, "Hello from heartbeat", expect.objectContaining({ accountId: params.expectedAccountId, verbose: false }), ); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index d0d34a7bd759..0ec2afcafdd5 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -239,12 +239,12 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, }, { - name: "use last route by default", + name: "target defaults to none when unset", cfg: {}, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" }, + entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "120363401234567890@g.us" }, expected: { - channel: "whatsapp", - to: "+1555", + channel: "none", + reason: "target-none", accountId: undefined, lastChannel: "whatsapp", lastAccountId: undefined, @@ -253,13 +253,15 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "normalize explicit whatsapp target when allowFrom wildcard", cfg: { - agents: { defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" } } }, + agents: { + defaults: { heartbeat: { target: "whatsapp", to: "whatsapp:120363401234567890@G.US" } }, + }, channels: { whatsapp: { allowFrom: ["*"] } }, }, entry: baseEntry, expected: { channel: "whatsapp", - to: "+555123", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -271,7 +273,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { entry: { ...baseEntry, lastChannel: "webchat", lastTo: "web" }, expected: { channel: "none", - reason: "no-target", + reason: "target-none", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -281,7 +283,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { name: "reject explicit whatsapp target outside allowFrom", cfg: { agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } }, - channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us", "+1666"] } }, }, entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1222" }, expected: { @@ -294,7 +296,10 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, { name: "normalize prefixed whatsapp group targets", - cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } }, + cfg: { + agents: { defaults: { heartbeat: { target: "last" } } }, + channels: { whatsapp: { allowFrom: ["120363401234567890@g.us"] } }, + }, entry: { ...baseEntry, lastChannel: "whatsapp", @@ -310,11 +315,11 @@ describe("resolveHeartbeatDeliveryTarget", () => { }, { name: "keep explicit telegram target", - cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } } }, + cfg: { agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } } }, entry: baseEntry, expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: undefined, lastChannel: undefined, lastAccountId: undefined, @@ -355,7 +360,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { accountId: "work", expected: { channel: "telegram", - to: "123", + to: "-100123", accountId: "work", lastChannel: undefined, lastAccountId: undefined, @@ -377,7 +382,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { const cfg: OpenClawConfig = { agents: { defaults: { - heartbeat: { target: "telegram", to: "123", accountId: testCase.accountId }, + heartbeat: { target: "telegram", to: "-100123", accountId: testCase.accountId }, }, }, channels: { telegram: { accounts: { work: { botToken: "token" } } } }, @@ -388,9 +393,9 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("prefers per-agent heartbeat overrides when provided", () => { const cfg: OpenClawConfig = { - agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, + agents: { defaults: { heartbeat: { target: "telegram", to: "-100123" } } }, }; - const heartbeat = { target: "whatsapp", to: "+1555" } as const; + const heartbeat = { target: "whatsapp", to: "120363401234567890@g.us" } as const; expect( resolveHeartbeatDeliveryTarget({ cfg, @@ -399,7 +404,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { }), ).toEqual({ channel: "whatsapp", - to: "+1555", + to: "120363401234567890@g.us", accountId: undefined, lastChannel: "whatsapp", lastAccountId: undefined, @@ -515,7 +520,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -532,7 +537,11 @@ describe("runHeartbeatOnce", () => { }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); } finally { replySpy.mockRestore(); } @@ -569,7 +578,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -584,13 +593,19 @@ describe("runHeartbeatOnce", () => { deps: createHeartbeatDeps(sendWhatsApp), }); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ Body: expect.stringMatching(/Ops check[\s\S]*Current time: /), SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", + OriginatingChannel: "whatsapp", + OriginatingTo: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -640,7 +655,7 @@ describe("runHeartbeatOnce", () => { sessionFile, updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -658,12 +673,16 @@ describe("runHeartbeatOnce", () => { expect(result.status).toBe("ran"); expect(sendWhatsApp).toHaveBeenCalledTimes(1); - expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(sendWhatsApp).toHaveBeenCalledWith( + "120363401234567890@g.us", + "Final alert", + expect.any(Object), + ); expect(replySpy).toHaveBeenCalledWith( expect.objectContaining({ SessionKey: sessionKey, - From: "+1555", - To: "+1555", + From: "120363401234567890@g.us", + To: "120363401234567890@g.us", Provider: "heartbeat", }), expect.objectContaining({ isHeartbeat: true, suppressToolErrorWarnings: false }), @@ -704,8 +723,8 @@ describe("runHeartbeatOnce", () => { { name: "runHeartbeatOnce sessionKey arg", caseDir: "hb-forced-session-override", - peerKind: "direct" as const, - peerId: "+15559990000", + peerKind: "group" as const, + peerId: "120363401234567891@g.us", message: "Forced alert", applyOverride: () => {}, runOptions: ({ sessionKey }: { sessionKey: string }) => ({ sessionKey }), @@ -745,7 +764,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid-main", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, [overrideSessionKey]: { sessionId: `sid-${testCase.peerKind}`, @@ -814,7 +833,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", lastHeartbeatText: "Final alert", lastHeartbeatSentAt: 0, }, @@ -887,7 +906,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -907,7 +926,7 @@ describe("runHeartbeatOnce", () => { for (const [index, text] of testCase.expectedTexts.entries()) { expect(sendWhatsApp, testCase.name).toHaveBeenNthCalledWith( index + 1, - "+1555", + "120363401234567890@g.us", text, expect.any(Object), ); @@ -925,7 +944,7 @@ describe("runHeartbeatOnce", () => { try { const cfg: OpenClawConfig = { agents: { - defaults: { workspace: tmpDir, heartbeat: { every: "5m" } }, + defaults: { workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" } }, list: [{ id: "work", default: true }], }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -944,7 +963,7 @@ describe("runHeartbeatOnce", () => { updatedAt: Date.now(), lastChannel: "whatsapp", lastProvider: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -962,7 +981,7 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenCalledTimes(1); expect(sendWhatsApp).toHaveBeenCalledWith( - "+1555", + "120363401234567890@g.us", "Hello from heartbeat", expect.any(Object), ); @@ -1019,7 +1038,7 @@ describe("runHeartbeatOnce", () => { sessionId: "sid", updatedAt: Date.now(), lastChannel: "whatsapp", - lastTo: "+1555", + lastTo: "120363401234567890@g.us", }, }), ); @@ -1146,4 +1165,110 @@ describe("runHeartbeatOnce", () => { } } }); + + it("uses an internal-only cron prompt when heartbeat delivery target is none", async () => { + const tmpDir = await createCaseDir("hb-cron-target-none"); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "none" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "120363401234567890@g.us", + }, + }), + ); + enqueueSystemEvent("Cron: rotate logs", { + sessionKey, + contextKey: "cron:rotate-logs", + }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "Handled internally" }); + const sendWhatsApp = vi + .fn>() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + try { + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: createHeartbeatDeps(sendWhatsApp), + }); + expect(res.status).toBe("ran"); + expect(sendWhatsApp).toHaveBeenCalledTimes(0); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("Handle this reminder internally"); + expect(calledCtx.Body).not.toContain("Please relay this reminder to the user"); + } finally { + replySpy.mockRestore(); + } + }); + + it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => { + const tmpDir = await createCaseDir("hb-exec-target-none"); + const storePath = path.join(tmpDir, "sessions.json"); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "none" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "120363401234567890@g.us", + }, + }), + ); + enqueueSystemEvent("exec finished: backup completed", { + sessionKey, + contextKey: "exec:backup", + }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "Handled internally" }); + const sendWhatsApp = vi + .fn>() + .mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + try { + const res = await runHeartbeatOnce({ + cfg, + reason: "exec-event", + deps: createHeartbeatDeps(sendWhatsApp), + }); + expect(res.status).toBe("ran"); + expect(sendWhatsApp).toHaveBeenCalledTimes(0); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("exec-event"); + expect(calledCtx.Body).toContain("Handle the result internally"); + expect(calledCtx.Body).not.toContain("Please relay the command output to the user"); + } finally { + replySpy.mockRestore(); + } + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index a34ccfdb7e39..73c2fafb1ae3 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -44,6 +44,7 @@ import { escapeRegExp } from "../utils.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { + buildExecEventPrompt, buildCronEventPrompt, isCronSystemEvent, isExecCompletionEvent, @@ -95,15 +96,7 @@ export type HeartbeatSummary = { ackMaxChars: number; }; -const DEFAULT_HEARTBEAT_TARGET = "last"; - -// Prompt used when an async exec has completed and the result should be relayed to the user. -// This overrides the standard heartbeat prompt to ensure the model responds with the exec result -// instead of just "HEARTBEAT_OK". -const EXEC_EVENT_PROMPT = - "An async command you ran earlier has completed. The result is shown in the system messages above. " + - "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + - "If it failed, explain what went wrong."; +const DEFAULT_HEARTBEAT_TARGET = "none"; export { isCronSystemEvent }; type HeartbeatAgentState = { @@ -560,6 +553,40 @@ async function resolveHeartbeatPreflight(params: { return basePreflight; } +type HeartbeatPromptResolution = { + prompt: string; + hasExecCompletion: boolean; + hasCronEvents: boolean; +}; + +function resolveHeartbeatRunPrompt(params: { + cfg: OpenClawConfig; + heartbeat?: HeartbeatConfig; + preflight: HeartbeatPreflight; + canRelayToUser: boolean; +}): HeartbeatPromptResolution { + const pendingEventEntries = params.preflight.pendingEventEntries; + const pendingEvents = params.preflight.shouldInspectPendingEvents + ? pendingEventEntries.map((event) => event.text) + : []; + const cronEvents = pendingEventEntries + .filter( + (event) => + (params.preflight.isCronEventReason || event.contextKey?.startsWith("cron:")) && + isCronSystemEvent(event.text), + ) + .map((event) => event.text); + const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); + const hasCronEvents = cronEvents.length > 0; + const prompt = hasExecCompletion + ? buildExecEventPrompt({ deliverToUser: params.canRelayToUser }) + : hasCronEvents + ? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser }) + : resolveHeartbeatPrompt(params.cfg, params.heartbeat); + + return { prompt, hasExecCompletion, hasCronEvents }; +} + export async function runHeartbeatOnce(opts: { cfg?: OpenClawConfig; agentId?: string; @@ -608,19 +635,18 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: preflight.skipReason }; } const { entry, sessionKey, storePath } = preflight.session; - const { isCronEventReason, pendingEventEntries } = preflight; const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const heartbeatAccountId = heartbeat?.accountId?.trim(); if (delivery.reason === "unknown-account") { log.warn("heartbeat: unknown accountId", { accountId: delivery.accountId ?? heartbeatAccountId ?? null, - target: heartbeat?.target ?? "last", + target: heartbeat?.target ?? "none", }); } else if (heartbeatAccountId) { log.info("heartbeat: using explicit accountId", { accountId: delivery.accountId ?? heartbeatAccountId, - target: heartbeat?.target ?? "last", + target: heartbeat?.target ?? "none", channel: delivery.channel, }); } @@ -638,31 +664,23 @@ export async function runHeartbeatOnce(opts: { accountId: delivery.accountId, }).responsePrefix; - // Check if this is an exec event or cron event with pending system events. - // If so, use a specialized prompt that instructs the model to relay the result - // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". - const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents; - const pendingEvents = shouldInspectPendingEvents - ? pendingEventEntries.map((event) => event.text) - : []; - const cronEvents = pendingEventEntries - .filter( - (event) => - (isCronEventReason || event.contextKey?.startsWith("cron:")) && - isCronSystemEvent(event.text), - ) - .map((event) => event.text); - const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); - const hasCronEvents = cronEvents.length > 0; - const prompt = hasExecCompletion - ? EXEC_EVENT_PROMPT - : hasCronEvents - ? buildCronEventPrompt(cronEvents) - : resolveHeartbeatPrompt(cfg, heartbeat); + const canRelayToUser = Boolean( + delivery.channel !== "none" && delivery.to && visibility.showAlerts, + ); + const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({ + cfg, + heartbeat, + preflight, + canRelayToUser, + }); const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), From: sender, To: sender, + OriginatingChannel: delivery.channel !== "none" ? delivery.channel : undefined, + OriginatingTo: delivery.to, + AccountId: delivery.accountId, + MessageThreadId: delivery.threadId, Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat", SessionKey: sessionKey, }; diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index de0140d76a25..a03afba325f6 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -44,6 +44,16 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("allows RFC2544 benchmark range IPv4 literal URLs when explicitly opted in", async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); + const result = await fetchWithSsrFGuard({ + url: "http://198.18.0.153/file", + fetchImpl, + policy: { allowRfc2544BenchmarkRange: true }, + }); + expect(result.response.status).toBe(200); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 660b8b6df6b0..28420ea373f1 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -52,11 +52,28 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, + { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); + it("allows RFC2544 benchmark range addresses only when policy explicitly opts in", async () => { + const lookup = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + + await expect(resolvePinnedHostname("api.telegram.org", lookup)).rejects.toThrow( + /private|internal/i, + ); + + const pinned = await resolvePinnedHostnameWithPolicy("api.telegram.org", { + lookupFn: lookup, + policy: { allowRfc2544BenchmarkRange: true }, + }); + expect(pinned.addresses).toContain("198.18.0.153"); + }); + it("falls back for non-matching hostnames", async () => { const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => { const cb = typeof options === "function" ? options : (callback as () => void); @@ -138,6 +155,33 @@ describe("ssrf pinning", () => { expect(lookup).not.toHaveBeenCalled(); }); + it("sorts IPv4 addresses before IPv6 in pinned results", async () => { + const lookup = vi.fn(async () => [ + { address: "2001:db8::1", family: 6 }, + { address: "93.184.216.34", family: 4 }, + { address: "2001:db8::2", family: 6 }, + { address: "93.184.216.35", family: 4 }, + ]) as unknown as LookupFn; + + const pinned = await resolvePinnedHostname("example.com", lookup); + expect(pinned.addresses).toEqual([ + "93.184.216.34", + "93.184.216.35", + "2001:db8::1", + "2001:db8::2", + ]); + }); + + it("uses DNS family metadata for ordering (not address string heuristics)", async () => { + const lookup = vi.fn(async () => [ + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 4 }, + { address: "93.184.216.34", family: 6 }, + ]) as unknown as LookupFn; + + const pinned = await resolvePinnedHostname("example.com", lookup); + expect(pinned.addresses).toEqual(["2606:2800:220:1:248:1893:25c8:1946", "93.184.216.34"]); + }); + it("allows ISATAP embedded private IPv4 when private network is explicitly enabled", async () => { const lookup = vi.fn(async () => [ { address: "2001:db8:1234::5efe:127.0.0.1", family: 6 }, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 5d8fe8f66204..5826669196db 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -124,6 +124,14 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); }); + it("supports opt-in policy to allow RFC2544 benchmark range", () => { + const policy = { allowRfc2544BenchmarkRange: true }; + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("::ffff:198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true); + }); + it("blocks legacy IPv4 literal representations", () => { expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); expect(isBlockedHostnameOrIp("8.8.2056")).toBe(true); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 3a4456e7839f..b84469390c08 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -5,6 +5,7 @@ import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, isCanonicalDottedDecimalIPv4, + type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, isPrivateOrLoopbackIpAddress, @@ -31,6 +32,7 @@ export type LookupFn = typeof dnsLookup; export type SsrFPolicy = { allowPrivateNetwork?: boolean; dangerouslyAllowPrivateNetwork?: boolean; + allowRfc2544BenchmarkRange?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; @@ -65,6 +67,12 @@ function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { + return { + allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, + }; +} + function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); @@ -97,7 +105,7 @@ function looksLikeUnsupportedIpv4Literal(address: string): boolean { } // Returns true for private/internal and special-use non-global addresses. -export function isPrivateIpAddress(address: string): boolean { +export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { let normalized = address.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { normalized = normalized.slice(1, -1); @@ -105,18 +113,19 @@ export function isPrivateIpAddress(address: string): boolean { if (!normalized) { return false; } + const blockOptions = resolveIpv4SpecialUseBlockOptions(policy); const strictIp = parseCanonicalIpAddress(normalized); if (strictIp) { if (isIpv4Address(strictIp)) { - return isBlockedSpecialUseIpv4Address(strictIp); + return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); if (embeddedIpv4) { - return isBlockedSpecialUseIpv4Address(embeddedIpv4); + return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions); } return false; } @@ -154,27 +163,30 @@ function isBlockedHostnameNormalized(normalized: string): boolean { ); } -export function isBlockedHostnameOrIp(hostname: string): boolean { +export function isBlockedHostnameOrIp(hostname: string, policy?: SsrFPolicy): boolean { const normalized = normalizeHostname(hostname); if (!normalized) { return false; } - return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy); } const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; -function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void { - if (isBlockedHostnameOrIp(hostnameOrIp)) { +function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy): void { + if (isBlockedHostnameOrIp(hostnameOrIp, policy)) { throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); } } -function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void { +function assertAllowedResolvedAddressesOrThrow( + results: readonly LookupAddress[], + policy?: SsrFPolicy, +): void { for (const entry of results) { // Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift. - if (isBlockedHostnameOrIp(entry.address)) { + if (isBlockedHostnameOrIp(entry.address, policy)) { throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); } } @@ -243,6 +255,24 @@ export type PinnedHostname = { lookup: typeof dnsLookupCb; }; +function dedupeAndPreferIpv4(results: readonly LookupAddress[]): string[] { + const seen = new Set(); + const ipv4: string[] = []; + const otherFamilies: string[] = []; + for (const entry of results) { + if (seen.has(entry.address)) { + continue; + } + seen.add(entry.address); + if (entry.family === 4) { + ipv4.push(entry.address); + continue; + } + otherFamilies.push(entry.address); + } + return [...ipv4, ...otherFamilies]; +} + export async function resolvePinnedHostnameWithPolicy( hostname: string, params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {}, @@ -264,7 +294,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. - assertAllowedHostOrIpOrThrow(normalized); + assertAllowedHostOrIpOrThrow(normalized, params.policy); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -275,10 +305,12 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. - assertAllowedResolvedAddressesOrThrow(results); + assertAllowedResolvedAddressesOrThrow(results, params.policy); } - const addresses = Array.from(new Set(results.map((entry) => entry.address))); + // Prefer addresses returned as IPv4 by DNS family metadata before other + // families so Happy Eyeballs and pinned round-robin both attempt IPv4 first. + const addresses = dedupeAndPreferIpv4(results); if (addresses.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); } diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index 6a1ae858d7bc..b137ce2a73ff 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -96,4 +96,41 @@ describe("agent delivery helpers", () => { expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled(); expect(resolved.resolvedTo).toBe("+1555"); }); + + it("prefers turn-source delivery context over session last route", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + sessionId: "s4", + updatedAt: 4, + deliveryContext: { channel: "slack", to: "U_WRONG", accountId: "wrong" }, + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + turnSourceTo: "+17775550123", + turnSourceAccountId: "work", + accountId: undefined, + wantsDelivery: true, + }); + + expect(plan.resolvedChannel).toBe("whatsapp"); + expect(plan.resolvedTo).toBe("+17775550123"); + expect(plan.resolvedAccountId).toBe("work"); + }); + + it("does not reuse mutable session to when only turnSourceChannel is provided", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + sessionId: "s5", + updatedAt: 5, + deliveryContext: { channel: "slack", to: "U_WRONG" }, + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + accountId: undefined, + wantsDelivery: true, + }); + + expect(plan.resolvedChannel).toBe("whatsapp"); + expect(plan.resolvedTo).toBeUndefined(); + }); }); diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 7c856598d2d6..1eedcb695680 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -32,6 +32,20 @@ export function resolveAgentDeliveryPlan(params: { explicitThreadId?: string | number; accountId?: string; wantsDelivery: boolean; + /** + * The channel that originated the current agent turn. When provided, + * overrides session-level `lastChannel` to prevent cross-channel reply + * routing in shared sessions (dmScope="main"). + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: string; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): AgentDeliveryPlan { const requestedRaw = typeof params.requestedChannel === "string" ? params.requestedChannel.trim() : ""; @@ -43,11 +57,33 @@ export function resolveAgentDeliveryPlan(params: { ? params.explicitTo.trim() : undefined; + // Resolve turn-source channel for cross-channel safety. + const normalizedTurnSource = params.turnSourceChannel + ? normalizeMessageChannel(params.turnSourceChannel) + : undefined; + const turnSourceChannel = + normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource) + ? normalizedTurnSource + : undefined; + const turnSourceTo = + typeof params.turnSourceTo === "string" && params.turnSourceTo.trim() + ? params.turnSourceTo.trim() + : undefined; + const turnSourceAccountId = normalizeAccountId(params.turnSourceAccountId); + const turnSourceThreadId = + params.turnSourceThreadId != null && params.turnSourceThreadId !== "" + ? params.turnSourceThreadId + : undefined; + const baseDelivery = resolveSessionDeliveryTarget({ entry: params.sessionEntry, requestedChannel: requestedChannel === INTERNAL_MESSAGE_CHANNEL ? "last" : requestedChannel, explicitTo, explicitThreadId: params.explicitThreadId, + turnSourceChannel, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId, }); const resolvedChannel = (() => { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0927de7df991..94b5bee9891a 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c import { withEnvAsync } from "../../test-utils/env.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), @@ -202,6 +203,86 @@ describe("deliverOutboundPayloads", () => { ); }); + it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverOutboundPayloads({ + cfg: telegramChunkConfig, + channel: "telegram", + to: "123", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => { + const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); + + await deliverOutboundPayloads({ + cfg: { channels: { signal: {} } }, + channel: "signal", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => { + const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "imessage", + to: "imessage:+15551234567", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "imessage:+15551234567", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } }; @@ -478,7 +559,7 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); }); - it("does not emit internal message:sent hook when mirror sessionKey is missing", async () => { + it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => { const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); await deliverOutboundPayloads({ @@ -493,6 +574,35 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled(); }); + it("emits internal message:sent hook when sessionKey is provided without mirror", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { sendWhatsApp }, + sessionKey: "agent:main:main", + }); + + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1); + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith( + "message", + "sent", + "agent:main:main", + expect.objectContaining({ + to: "+1555", + content: "hello", + success: true, + channelId: "whatsapp", + conversationId: "+1555", + messageId: "w1", + }), + ); + expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); + }); + it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => { const sendWhatsApp = vi .fn() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 908b786e5ee0..f071a25d048f 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -216,6 +216,8 @@ type DeliverOutboundPayloadsCoreParams = { mediaUrls?: string[]; }; silent?: boolean; + /** Session key for internal hook dispatch (when `mirror` is not needed). */ + sessionKey?: string; }; type DeliverOutboundPayloadsParams = DeliverOutboundPayloadsCoreParams & { @@ -444,7 +446,7 @@ async function deliverOutboundPayloadsCore( return normalized ? [normalized] : []; }); const hookRunner = getGlobalHookRunner(); - const sessionKeyForInternalHooks = params.mirror?.sessionKey; + const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.sessionKey; for (const payload of normalizedPayloads) { const payloadSummary: NormalizedOutboundPayload = { text: payload.text ?? "", diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index cf230e77417e..a73912edc6e4 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -169,6 +169,59 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string }; } +export type AttachmentMediaPolicy = + | { + mode: "sandbox"; + sandboxRoot: string; + } + | { + mode: "host"; + localRoots?: readonly string[]; + }; + +export function resolveAttachmentMediaPolicy(params: { + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; +}): AttachmentMediaPolicy { + const sandboxRoot = params.sandboxRoot?.trim(); + if (sandboxRoot) { + return { + mode: "sandbox", + sandboxRoot, + }; + } + return { + mode: "host", + localRoots: params.mediaLocalRoots, + }; +} + +function buildAttachmentMediaLoadOptions(params: { + policy: AttachmentMediaPolicy; + maxBytes?: number; +}): + | { + maxBytes?: number; + sandboxValidated: true; + readFile: (filePath: string) => Promise; + } + | { + maxBytes?: number; + localRoots?: readonly string[]; + } { + if (params.policy.mode === "sandbox") { + return { + maxBytes: params.maxBytes, + sandboxValidated: true, + readFile: (filePath: string) => fs.readFile(filePath), + }; + } + return { + maxBytes: params.maxBytes, + localRoots: params.policy.localRoots, + }; +} + async function hydrateAttachmentPayload(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -178,6 +231,7 @@ async function hydrateAttachmentPayload(params: { contentTypeParam?: string | null; mediaHint?: string | null; fileHint?: string | null; + mediaPolicy: AttachmentMediaPolicy; }) { const contentTypeParam = params.contentTypeParam ?? undefined; const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); @@ -201,12 +255,10 @@ async function hydrateAttachmentPayload(params: { channel: params.channel, accountId: params.accountId, }); - // mediaSource already validated by normalizeSandboxMediaList; allow bypass but force explicit readFile. - const media = await loadWebMedia(mediaSource, { - maxBytes, - sandboxValidated: true, - readFile: (filePath: string) => fs.readFile(filePath), - }); + const media = await loadWebMedia( + mediaSource, + buildAttachmentMediaLoadOptions({ policy: params.mediaPolicy, maxBytes }), + ); params.args.buffer = media.buffer.toString("base64"); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; @@ -227,9 +279,10 @@ async function hydrateAttachmentPayload(params: { export async function normalizeSandboxMediaParams(params: { args: Record; - sandboxRoot?: string; + mediaPolicy: AttachmentMediaPolicy; }): Promise { - const sandboxRoot = params.sandboxRoot?.trim(); + const sandboxRoot = + params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined; const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; for (const key of mediaKeys) { const raw = readStringParam(params.args, key, { trim: false }); @@ -280,6 +333,7 @@ async function hydrateAttachmentActionPayload(params: { dryRun?: boolean; /** If caption is missing, copy message -> caption. */ allowMessageCaptionFallback?: boolean; + mediaPolicy: AttachmentMediaPolicy; }): Promise { const mediaHint = readStringParam(params.args, "media", { trim: false }); const fileHint = @@ -305,35 +359,31 @@ async function hydrateAttachmentActionPayload(params: { contentTypeParam, mediaHint, fileHint, + mediaPolicy: params.mediaPolicy, }); } -export async function hydrateSetGroupIconParams(params: { +export async function hydrateAttachmentParamsForAction(params: { cfg: OpenClawConfig; channel: ChannelId; accountId?: string | null; args: Record; action: ChannelMessageActionName; dryRun?: boolean; + mediaPolicy: AttachmentMediaPolicy; }): Promise { - if (params.action !== "setGroupIcon") { + if (params.action !== "sendAttachment" && params.action !== "setGroupIcon") { return; } - await hydrateAttachmentActionPayload(params); -} - -export async function hydrateSendAttachmentParams(params: { - cfg: OpenClawConfig; - channel: ChannelId; - accountId?: string | null; - args: Record; - action: ChannelMessageActionName; - dryRun?: boolean; -}): Promise { - if (params.action !== "sendAttachment") { - return; - } - await hydrateAttachmentActionPayload({ ...params, allowMessageCaptionFallback: true }); + await hydrateAttachmentActionPayload({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + args: params.args, + dryRun: params.dryRun, + mediaPolicy: params.mediaPolicy, + allowMessageCaptionFallback: params.action === "sendAttachment", + }); } export function parseButtonsParam(params: Record): void { diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index f2c36464e975..6fdec33ab492 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; vi.mock("../../web/media.js", async () => { @@ -423,6 +424,15 @@ describe("runMessageAction context isolation", () => { }); describe("runMessageAction sendAttachment hydration", () => { + const cfg = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + } as OpenClawConfig; const attachmentPlugin: ChannelPlugin = { id: "bluebubbles", meta: { @@ -432,15 +442,15 @@ describe("runMessageAction sendAttachment hydration", () => { docsPath: "/channels/bluebubbles", blurb: "BlueBubbles test plugin.", }, - capabilities: { chatTypes: ["direct"], media: true }, + capabilities: { chatTypes: ["direct", "group"], media: true }, config: { listAccountIds: () => ["default"], resolveAccount: () => ({ enabled: true }), isConfigured: () => true, }, actions: { - listActions: () => ["sendAttachment"], - supportsAction: ({ action }) => action === "sendAttachment", + listActions: () => ["sendAttachment", "setGroupIcon"], + supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon", handleAction: async ({ params }) => jsonResult({ ok: true, @@ -475,17 +485,46 @@ describe("runMessageAction sendAttachment hydration", () => { vi.clearAllMocks(); }); - it("hydrates buffer and filename from media for sendAttachment", async () => { - const cfg = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - } as OpenClawConfig; + async function restoreRealMediaLoader() { + const actual = await vi.importActual("../../web/media.js"); + vi.mocked(loadWebMedia).mockImplementation(actual.loadWebMedia); + } + + async function expectRejectsLocalAbsolutePathWithoutSandbox(params: { + action: "sendAttachment" | "setGroupIcon"; + target: string; + message?: string; + tempPrefix: string; + }) { + await restoreRealMediaLoader(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); + try { + const outsidePath = path.join(tempDir, "secret.txt"); + await fs.writeFile(outsidePath, "secret", "utf8"); + + const actionParams: Record = { + channel: "bluebubbles", + target: params.target, + media: outsidePath, + }; + if (params.message) { + actionParams.message = params.message; + } + + await expect( + runMessageAction({ + cfg, + action: params.action, + params: actionParams, + }), + ).rejects.toThrow(/allowed directory|path-not-allowed/i); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + it("hydrates buffer and filename from media for sendAttachment", async () => { const result = await runMessageAction({ cfg, action: "sendAttachment", @@ -507,18 +546,18 @@ describe("runMessageAction sendAttachment hydration", () => { expect((result.payload as { buffer?: string }).buffer).toBe( Buffer.from("hello").toString("base64"), ); + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[1]).toEqual( + expect.objectContaining({ + localRoots: expect.any(Array), + }), + ); + expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe( + true, + ); }); it("rewrites sandboxed media paths for sendAttachment", async () => { - const cfg = { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - } as OpenClawConfig; await withSandbox(async (sandboxDir) => { await runMessageAction({ cfg, @@ -534,6 +573,28 @@ describe("runMessageAction sendAttachment hydration", () => { const call = vi.mocked(loadWebMedia).mock.calls[0]; expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); + expect(call?.[1]).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); + }); + }); + + it("rejects local absolute path for sendAttachment when sandboxRoot is missing", async () => { + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "sendAttachment", + target: "+15551234567", + message: "caption", + tempPrefix: "msg-attachment-", + }); + }); + + it("rejects local absolute path for setGroupIcon when sandboxRoot is missing", async () => { + await expectRejectsLocalAbsolutePathWithoutSandbox({ + action: "setGroupIcon", + target: "group:123", + tempPrefix: "msg-group-icon-", }); }); }); @@ -622,10 +683,12 @@ describe("runMessageAction sandboxed media validation", () => { }); }); - it("allows media paths under os.tmpdir()", async () => { + it("allows media paths under preferred OpenClaw tmp root", async () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + await fs.mkdir(tmpRoot, { recursive: true }); const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); try { - const tmpFile = path.join(os.tmpdir(), "test-media-image.png"); + const tmpFile = path.join(tmpRoot, "test-media-image.png"); const result = await runMessageAction({ cfg: slackConfig, action: "send", @@ -643,7 +706,23 @@ describe("runMessageAction sandboxed media validation", () => { if (result.kind !== "send") { throw new Error("expected send result"); } - expect(result.sendResult?.mediaUrl).toBe(tmpFile); + // runMessageAction normalizes media paths through platform resolution. + expect(result.sendResult?.mediaUrl).toBe(path.resolve(tmpFile)); + const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: hostTmpOutsideOpenClaw, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }), + ).rejects.toThrow(/sandbox/i); } finally { await fs.rm(sandboxDir, { recursive: true, force: true }); } diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index b598a9bae542..dc404c53f262 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -28,14 +29,14 @@ import { import { applyTargetToParams } from "./channel-target.js"; import type { OutboundSendDeps } from "./deliver.js"; import { - hydrateSendAttachmentParams, - hydrateSetGroupIconParams, + hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, parseButtonsParam, parseCardParam, parseComponentsParam, readBooleanParam, + resolveAttachmentMediaPolicy, resolveSlackAutoThreadId, resolveTelegramAutoThreadId, } from "./message-action-params.js"; @@ -773,28 +774,25 @@ export async function runMessageAction( params.accountId = accountId; } const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); - - await normalizeSandboxMediaParams({ - args: params, + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId); + const mediaPolicy = resolveAttachmentMediaPolicy({ sandboxRoot: input.sandboxRoot, + mediaLocalRoots, }); - await hydrateSendAttachmentParams({ - cfg, - channel, - accountId, + await normalizeSandboxMediaParams({ args: params, - action, - dryRun, + mediaPolicy, }); - await hydrateSetGroupIconParams({ + await hydrateAttachmentParamsForAction({ cfg, channel, accountId, args: params, action, dryRun, + mediaPolicy, }); const resolvedTarget = await resolveActionTarget({ diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 8b57bb7a34fb..64960ec143c3 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -908,6 +908,14 @@ describe("normalizeOutboundPayloadsForJson", () => { expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); } }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloadsForJson([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrl: null, mediaUrls: undefined }]); + }); }); describe("normalizeOutboundPayloads", () => { @@ -916,6 +924,14 @@ describe("normalizeOutboundPayloads", () => { const normalized = normalizeOutboundPayloads([{ channelData }]); expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); }); + + it("suppresses reasoning payloads", () => { + const normalized = normalizeOutboundPayloads([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]); + expect(normalized).toEqual([{ text: "final answer", mediaUrls: [] }]); + }); }); describe("formatOutboundPayloadLog", () => { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index f61261939c11..c5c99d0038bc 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -1,5 +1,8 @@ import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; -import { isRenderablePayload } from "../../auto-reply/reply/reply-payloads.js"; +import { + isRenderablePayload, + shouldSuppressReasoningPayload, +} from "../../auto-reply/reply/reply-payloads.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; export type NormalizedOutboundPayload = { @@ -41,6 +44,9 @@ export function normalizeReplyPayloadsForDelivery( payloads: readonly ReplyPayload[], ): ReplyPayload[] { return payloads.flatMap((payload) => { + if (shouldSuppressReasoningPayload(payload)) { + return []; + } const parsed = parseReplyDirectives(payload.text ?? ""); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; const explicitMediaUrl = payload.mediaUrl ?? parsed.mediaUrl; diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index ac9fa08b1e72..8f120702de08 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js"; +import { + resolveHeartbeatDeliveryTarget, + resolveOutboundTarget, + resolveSessionDeliveryTarget, +} from "./targets.js"; import { installResolveOutboundTargetPluginRegistryHooks, runResolveOutboundTargetCoreTests, @@ -175,6 +179,22 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(999); }); + it("does not inherit lastThreadId in heartbeat mode", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-heartbeat-thread", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + requestedChannel: "last", + mode: "heartbeat", + }); + + expect(resolved.threadId).toBeUndefined(); + }); + it("falls back to a provided channel when requested is unsupported", () => { const resolved = resolveSessionDeliveryTarget({ entry: { @@ -280,4 +300,328 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.threadId).toBe(42); expect(resolved.to).toBe("63448508"); }); + + it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-outbound", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + expect(resolved.threadId).toBeUndefined(); + }); + + it("blocks heartbeat delivery to Discord DMs", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-dm", + updatedAt: 1, + lastChannel: "discord", + lastTo: "user:12345", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("blocks heartbeat delivery to Telegram direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Telegram groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-group", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1001234567890"); + }); + + it("blocks heartbeat delivery to WhatsApp direct chats", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-direct", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+15551234567", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to WhatsApp groups", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-whatsapp-group", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "120363140186826074@g.us", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("120363140186826074@g.us"); + }); + + it("uses session chatType hint when target parser cannot classify", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("dm-blocked"); + }); + + it("keeps heartbeat delivery to Discord channels", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-discord-channel", + updatedAt: 1, + lastChannel: "discord", + lastTo: "channel:999", + }, + heartbeat: { + target: "last", + }, + }); + + expect(resolved.channel).toBe("discord"); + expect(resolved.to).toBe("channel:999"); + }); + + it("keeps explicit threadId in heartbeat mode", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-heartbeat-explicit-thread", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-100123", + lastThreadId: 999, + }, + requestedChannel: "last", + mode: "heartbeat", + explicitThreadId: 42, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-100123"); + expect(resolved.threadId).toBe(42); + expect(resolved.threadIdExplicit).toBe(true); + }); + + it("parses explicit heartbeat topic targets into threadId", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + heartbeat: { + target: "telegram", + to: "-10063448508:topic:1008013", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-10063448508"); + expect(resolved.threadId).toBe(1008013); + }); +}); + +describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => { + it("uses turnSourceChannel over session lastChannel when provided", () => { + // Simulate: WhatsApp message originated the turn, but a Slack message + // arrived concurrently and updated lastChannel to "slack" + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-shared", + updatedAt: 1, + lastChannel: "slack", // <- concurrently overwritten + lastTo: "U0AEMECNCBV", // <- Slack user (wrong target) + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", // <- originated from WhatsApp + turnSourceTo: "+66972796305", // <- WhatsApp user (correct target) + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+66972796305"); + }); + + it("falls back to session lastChannel when turnSourceChannel is not set", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-normal", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "8587265585", + }, + requestedChannel: "last", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + }); + + it("respects explicit requestedChannel over turnSourceChannel", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-explicit", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U12345", + }, + requestedChannel: "telegram", + explicitTo: "8587265585", + turnSourceChannel: "whatsapp", + turnSourceTo: "+66972796305", + }); + + // Explicit requestedChannel "telegram" is not "last", so it takes priority + expect(resolved.channel).toBe("telegram"); + }); + + it("preserves turnSourceAccountId and turnSourceThreadId", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-meta", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong-account", + }, + requestedChannel: "last", + turnSourceChannel: "telegram", + turnSourceTo: "8587265585", + turnSourceAccountId: "bot-123", + turnSourceThreadId: 42, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("8587265585"); + expect(resolved.accountId).toBe("bot-123"); + expect(resolved.threadId).toBe(42); + }); + + it("does not fall back to session target metadata when turnSourceChannel is set", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-no-fallback", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + lastAccountId: "wrong-account", + lastThreadId: "1739142736.000100", + }, + requestedChannel: "last", + turnSourceChannel: "whatsapp", + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBeUndefined(); + expect(resolved.accountId).toBeUndefined(); + expect(resolved.threadId).toBeUndefined(); + expect(resolved.lastTo).toBeUndefined(); + expect(resolved.lastAccountId).toBeUndefined(); + expect(resolved.lastThreadId).toBeUndefined(); + }); + + it("uses explicitTo even when turnSourceTo is omitted", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-explicit-to", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + }, + requestedChannel: "last", + explicitTo: "+15551234567", + turnSourceChannel: "whatsapp", + }); + + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+15551234567"); + }); + + it("still allows mismatched lastTo only from turn-scoped metadata", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-mismatch-turn", + updatedAt: 1, + lastChannel: "slack", + lastTo: "U_WRONG", + }, + requestedChannel: "telegram", + allowMismatchedLastTo: true, + turnSourceChannel: "whatsapp", + turnSourceTo: "+15550000000", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("+15550000000"); + }); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 608e62c6005c..41baa5586539 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,11 +1,14 @@ +import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; +import { parseDiscordTarget } from "../../discord/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; +import { parseSlackTarget } from "../../slack/targets.js"; +import { parseTelegramTarget, resolveTelegramTargetChatType } from "../../telegram/targets.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { DeliverableMessageChannel, @@ -16,6 +19,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { missingTargetError } from "./target-errors.js"; export type OutboundChannel = DeliverableMessageChannel | "none"; @@ -62,13 +66,37 @@ export function resolveSessionDeliveryTarget(params: { fallbackChannel?: DeliverableMessageChannel; allowMismatchedLastTo?: boolean; mode?: ChannelOutboundTargetMode; + /** + * When set, this overrides the session-level `lastChannel` for "last" + * resolution. This prevents cross-channel reply routing when multiple + * channels share the same session (dmScope = "main") and an inbound + * message from a different channel updates `lastChannel` while an agent + * turn is still in flight. + * + * Callers should set this to the channel that originated the current + * agent turn so the reply always routes back to the correct channel. + * + * @see https://github.com/openclaw/openclaw/issues/24152 + */ + turnSourceChannel?: DeliverableMessageChannel; + /** Turn-source `to` — paired with `turnSourceChannel`. */ + turnSourceTo?: string; + /** Turn-source `accountId` — paired with `turnSourceChannel`. */ + turnSourceAccountId?: string; + /** Turn-source `threadId` — paired with `turnSourceChannel`. */ + turnSourceThreadId?: string | number; }): SessionDeliveryTarget { const context = deliveryContextFromSession(params.entry); - const lastChannel = + const sessionLastChannel = context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined; - const lastTo = context?.to; - const lastAccountId = context?.accountId; - const lastThreadId = context?.threadId; + + // When a turn-source channel is provided, use only turn-scoped metadata. + // Falling back to mutable session fields would re-introduce routing races. + const hasTurnSourceChannel = params.turnSourceChannel != null; + const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel; + const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to; + const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId; + const lastThreadId = hasTurnSourceChannel ? params.turnSourceThreadId : context?.threadId; const rawRequested = params.requestedChannel ?? "last"; const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested); @@ -115,9 +143,10 @@ export function resolveSessionDeliveryTarget(params: { } } - const accountId = channel && channel === lastChannel ? lastAccountId : undefined; - const threadId = channel && channel === lastChannel ? lastThreadId : undefined; const mode = params.mode ?? (explicitTo ? "explicit" : "implicit"); + const accountId = channel && channel === lastChannel ? lastAccountId : undefined; + const threadId = + mode !== "heartbeat" && channel && channel === lastChannel ? lastThreadId : undefined; const resolvedThreadId = explicitThreadId ?? threadId; return { @@ -209,7 +238,7 @@ export function resolveHeartbeatDeliveryTarget(params: { const { cfg, entry } = params; const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat; const rawTarget = heartbeat?.target; - let target: HeartbeatTarget = "last"; + let target: HeartbeatTarget = "none"; if (rawTarget === "none" || rawTarget === "last") { target = rawTarget; } else if (typeof rawTarget === "string") { @@ -221,13 +250,11 @@ export function resolveHeartbeatDeliveryTarget(params: { if (target === "none") { const base = resolveSessionDeliveryTarget({ entry }); - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "target-none", - accountId: undefined, lastChannel: base.lastChannel, lastAccountId: base.lastAccountId, - }; + }); } const resolvedTarget = resolveSessionDeliveryTarget({ @@ -251,26 +278,24 @@ export function resolveHeartbeatDeliveryTarget(params: { accountIds.map((accountId) => normalizeAccountId(accountId)), ); if (!normalizedAccountIds.has(normalizedAccountId)) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "unknown-account", accountId: normalizedAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } effectiveAccountId = normalizedAccountId; } } if (!resolvedTarget.channel || !resolvedTarget.to) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); } const resolved = resolveOutboundTarget({ @@ -281,13 +306,28 @@ export function resolveHeartbeatDeliveryTarget(params: { mode: "heartbeat", }); if (!resolved.ok) { - return { - channel: "none", + return buildNoHeartbeatDeliveryTarget({ reason: "no-target", accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, - }; + }); + } + + const sessionChatTypeHint = + target === "last" && !heartbeat?.to ? normalizeChatType(entry?.chatType) : undefined; + const deliveryChatType = resolveHeartbeatDeliveryChatType({ + channel: resolvedTarget.channel, + to: resolved.to, + sessionChatType: sessionChatTypeHint, + }); + if (deliveryChatType === "direct") { + return buildNoHeartbeatDeliveryTarget({ + reason: "dm-blocked", + accountId: effectiveAccountId, + lastChannel: resolvedTarget.lastChannel, + lastAccountId: resolvedTarget.lastAccountId, + }); } let reason: string | undefined; @@ -316,6 +356,120 @@ export function resolveHeartbeatDeliveryTarget(params: { }; } +function buildNoHeartbeatDeliveryTarget(params: { + reason: string; + accountId?: string; + lastChannel?: DeliverableMessageChannel; + lastAccountId?: string; +}): OutboundTarget { + return { + channel: "none", + reason: params.reason, + accountId: params.accountId, + lastChannel: params.lastChannel, + lastAccountId: params.lastAccountId, + }; +} + +function inferDiscordTargetChatType(to: string): ChatType | undefined { + try { + const target = parseDiscordTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; + } catch { + return undefined; + } +} + +function inferSlackTargetChatType(to: string): ChatType | undefined { + const target = parseSlackTarget(to, { defaultKind: "channel" }); + if (!target) { + return undefined; + } + return target.kind === "user" ? "direct" : "channel"; +} + +function inferTelegramTargetChatType(to: string): ChatType | undefined { + const chatType = resolveTelegramTargetChatType(to); + return chatType === "unknown" ? undefined : chatType; +} + +function inferWhatsAppTargetChatType(to: string): ChatType | undefined { + const normalized = normalizeWhatsAppTarget(to); + if (!normalized) { + return undefined; + } + return isWhatsAppGroupJid(normalized) ? "group" : "direct"; +} + +function inferSignalTargetChatType(rawTo: string): ChatType | undefined { + let to = rawTo.trim(); + if (!to) { + return undefined; + } + if (/^signal:/i.test(to)) { + to = to.replace(/^signal:/i, "").trim(); + } + if (!to) { + return undefined; + } + const lower = to.toLowerCase(); + if (lower.startsWith("group:")) { + return "group"; + } + if (lower.startsWith("username:") || lower.startsWith("u:")) { + return "direct"; + } + return "direct"; +} + +const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial< + Record ChatType | undefined> +> = { + discord: inferDiscordTargetChatType, + slack: inferSlackTargetChatType, + telegram: inferTelegramTargetChatType, + whatsapp: inferWhatsAppTargetChatType, + signal: inferSignalTargetChatType, +}; + +function inferChatTypeFromTarget(params: { + channel: DeliverableMessageChannel; + to: string; +}): ChatType | undefined { + const to = params.to.trim(); + if (!to) { + return undefined; + } + + if (/^user:/i.test(to)) { + return "direct"; + } + if (/^(channel:|thread:)/i.test(to)) { + return "channel"; + } + if (/^group:/i.test(to)) { + return "group"; + } + return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to); +} + +function resolveHeartbeatDeliveryChatType(params: { + channel: DeliverableMessageChannel; + to: string; + sessionChatType?: ChatType; +}): ChatType | undefined { + if (params.sessionChatType) { + return params.sessionChatType; + } + return inferChatTypeFromTarget({ + channel: params.channel, + to: params.to, + }); +} + function resolveHeartbeatSenderId(params: { allowFrom: Array; deliveryTo?: string; diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index ac4638483b3e..311849ba9fdb 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { sameFileIdentity as hasSameFileIdentity } from "./file-identity.js"; export type SafeOpenSyncFailureReason = "path" | "validation" | "io"; @@ -17,7 +18,7 @@ function isExpectedPathError(error: unknown): boolean { } export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; + return hasSameFileIdentity(left, right); } export function openVerifiedFileSync(params: { diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 80eda1da5809..1696028b39da 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -28,6 +28,7 @@ describe("shell env fallback", () => { } function runShellEnvFallbackForShell(shell: string) { + resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = { SHELL: shell }; const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0")); const res = loadShellEnvFallback({ @@ -58,6 +59,23 @@ describe("shell env fallback", () => { expect(receivedEnv?.HOME).toBe(os.homedir()); } + function withEtcShells(shells: string[], fn: () => void) { + const etcShellsContent = `${shells.join("\n")}\n`; + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return etcShellsContent; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); + try { + fn(); + } finally { + readFileSyncSpy.mockRestore(); + } + } + it("is disabled by default", () => { expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "0" })).toBe(false); @@ -170,19 +188,28 @@ describe("shell env fallback", () => { expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); }); - it("uses trusted absolute SHELL path when executable on posix-style paths", () => { - const accessSyncSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); - try { - const trustedShell = "/usr/bin/zsh-trusted"; + it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => { + withEtcShells(["/bin/sh", "/bin/bash", "/bin/zsh"], () => { + const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell"); + + expect(res.ok).toBe(true); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); + }); + }); + + it("uses SHELL when it is explicitly registered in /etc/shells", () => { + const trustedShell = + process.platform === "win32" + ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + : "/usr/bin/zsh-trusted"; + withEtcShells(["/bin/sh", trustedShell], () => { const { res, exec } = runShellEnvFallbackForShell(trustedShell); - const expectedShell = process.platform === "win32" ? "/bin/sh" : trustedShell; expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); - expect(exec).toHaveBeenCalledWith(expectedShell, ["-l", "-c", "env -0"], expect.any(Object)); - } finally { - accessSyncSpy.mockRestore(); - } + expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); + }); }); it("sanitizes startup-related env vars before shell fallback exec", () => { diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 30f255cbce64..796c19b2666c 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -8,13 +8,6 @@ import { sanitizeHostExecEnv } from "./host-env-security.js"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const DEFAULT_SHELL = "/bin/sh"; -const TRUSTED_SHELL_PREFIXES = [ - "/bin/", - "/usr/bin/", - "/usr/local/bin/", - "/opt/homebrew/bin/", - "/run/current-system/sw/bin/", -]; let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; @@ -70,21 +63,7 @@ function isTrustedShellPath(shell: string): boolean { // Primary trust anchor: shell registered in /etc/shells. const registeredShells = readEtcShells(); - if (registeredShells?.has(shell)) { - return true; - } - - // Fallback for environments where /etc/shells is incomplete/unavailable. - if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) { - return false; - } - - try { - fs.accessSync(shell, fs.constants.X_OK); - return true; - } catch { - return false; - } + return registeredShells?.has(shell) === true; } function resolveShell(env: NodeJS.ProcessEnv): string { @@ -131,6 +110,28 @@ function parseShellEnv(stdout: Buffer): Map { return shellEnv; } +type LoginShellEnvProbeResult = + | { ok: true; shellEnv: Map } + | { ok: false; error: string }; + +function probeLoginShellEnv(params: { + env: NodeJS.ProcessEnv; + timeoutMs?: number; + exec?: typeof execFileSync; +}): LoginShellEnvProbeResult { + const exec = params.exec ?? execFileSync; + const timeoutMs = resolveTimeoutMs(params.timeoutMs); + const shell = resolveShell(params.env); + const execEnv = resolveShellExecEnv(params.env); + + try { + const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); + return { ok: true, shellEnv: parseShellEnv(stdout) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export type ShellEnvFallbackResult = | { ok: true; applied: string[]; skippedReason?: never } | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } @@ -147,7 +148,6 @@ export type ShellEnvFallbackOptions = { export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult { const logger = opts.logger ?? console; - const exec = opts.exec ?? execFileSync; if (!opts.enabled) { lastAppliedKeys = []; @@ -160,29 +160,23 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal return { ok: true, applied: [], skippedReason: "already-has-keys" }; } - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.warn(`[openclaw] shell env fallback failed: ${msg}`); + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { + logger.warn(`[openclaw] shell env fallback failed: ${probe.error}`); lastAppliedKeys = []; - return { ok: false, error: msg, applied: [] }; + return { ok: false, error: probe.error, applied: [] }; } - const shellEnv = parseShellEnv(stdout); - const applied: string[] = []; for (const key of opts.expectedKeys) { if (opts.env[key]?.trim()) { continue; } - const value = shellEnv.get(key); + const value = probe.shellEnv.get(key); if (!value?.trim()) { continue; } @@ -229,21 +223,17 @@ export function getShellPathFromLoginShell(opts: { return cachedShellPath; } - const exec = opts.exec ?? execFileSync; - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch { + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { cachedShellPath = null; return cachedShellPath; } - const shellEnv = parseShellEnv(stdout); - const shellPath = shellEnv.get("PATH")?.trim(); + const shellPath = probe.shellEnv.get("PATH")?.trim(); cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; return cachedShellPath; } diff --git a/src/infra/system-run-command.contract.test.ts b/src/infra/system-run-command.contract.test.ts new file mode 100644 index 000000000000..a0555355d42c --- /dev/null +++ b/src/infra/system-run-command.contract.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { resolveSystemRunCommand } from "./system-run-command.js"; + +type ContractFixture = { + cases: ContractCase[]; +}; + +type ContractCase = { + name: string; + command: string[]; + rawCommand?: string; + expected: { + valid: boolean; + displayCommand?: string; + errorContains?: string; + }; +}; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../test/fixtures/system-run-command-contract.json", +); +const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as ContractFixture; + +describe("system-run command contract fixtures", () => { + for (const entry of fixture.cases) { + test(entry.name, () => { + const result = resolveSystemRunCommand({ + command: entry.command, + rawCommand: entry.rawCommand, + }); + + if (!entry.expected.valid) { + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected validation failure"); + } + if (entry.expected.errorContains) { + expect(result.message).toContain(entry.expected.errorContains); + } + return; + } + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(`unexpected validation failure: ${result.message}`); + } + expect(result.cmdText).toBe(entry.expected.displayCommand); + }); + } +}); diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 43a1b6fae79b..7186823d84b3 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -57,6 +57,11 @@ describe("system run command helpers", () => { expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date"); }); + test("extractShellCommandFromArgv unwraps busybox/toybox shell applets", () => { + expect(extractShellCommandFromArgv(["busybox", "sh", "-c", "echo hi"])).toBe("echo hi"); + expect(extractShellCommandFromArgv(["toybox", "ash", "-lc", "echo hi"])).toBe("echo hi"); + }); + test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => { expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe( null, @@ -98,6 +103,13 @@ describe("system run command helpers", () => { expect(res.ok).toBe(true); }); + test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => { + expectRawCommandMismatch({ + argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '$0 "$1"', + }); + }); + test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => { const res = validateSystemRunCommandConsistency({ argv: ["/usr/bin/env", "bash", "-lc", "echo hi"], @@ -165,6 +177,18 @@ describe("system run command helpers", () => { expect(res.cmdText).toBe("echo SAFE&&whoami"); }); + test("resolveSystemRunCommand binds cmdText to full argv for shell-wrapper positional-argv carriers", () => { + const res = resolveSystemRunCommand({ + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + }); + expect(res.ok).toBe(true); + if (!res.ok) { + throw new Error("unreachable"); + } + expect(res.shellCommand).toBe('$0 "$1"'); + expect(res.cmdText).toBe('/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker'); + }); + test("resolveSystemRunCommand binds cmdText to full argv when env prelude modifies shell wrapper", () => { const res = resolveSystemRunCommand({ command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index c8bbac6e7a9e..b03d715fc72d 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -1,6 +1,9 @@ import { extractShellWrapperCommand, hasEnvManipulationBeforeShellWrapper, + normalizeExecutableToken, + unwrapDispatchWrappersForResolution, + unwrapKnownShellMultiplexerInvocation, } from "./exec-wrapper-resolution.js"; export type SystemRunCommandValidation = @@ -49,6 +52,77 @@ export function extractShellCommandFromArgv(argv: string[]): string | null { return extractShellWrapperCommand(argv).command; } +const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", +]); + +const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); +const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]); + +function unwrapShellWrapperArgv(argv: string[]): string[] { + const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv); + const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(dispatchUnwrapped); + return shellMultiplexer.kind === "unwrapped" ? shellMultiplexer.argv : dispatchUnwrapped; +} + +function resolveInlineCommandTokenIndex( + argv: string[], + flags: ReadonlySet, + options: { allowCombinedC?: boolean } = {}, +): number | null { + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + const lower = token.toLowerCase(); + if (lower === "--") { + break; + } + if (flags.has(lower)) { + return i + 1 < argv.length ? i + 1 : null; + } + if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) { + const commandIndex = lower.indexOf("c"); + const inline = token.slice(commandIndex + 1).trim(); + return inline ? i : i + 1 < argv.length ? i + 1 : null; + } + } + return null; +} + +function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean { + const wrapperArgv = unwrapShellWrapperArgv(argv); + const token0 = wrapperArgv[0]?.trim(); + if (!token0) { + return false; + } + + const wrapper = normalizeExecutableToken(token0); + if (!POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES.has(wrapper)) { + return false; + } + + const inlineCommandIndex = + wrapper === "powershell" || wrapper === "pwsh" + ? resolveInlineCommandTokenIndex(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS) + : resolveInlineCommandTokenIndex(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, { + allowCombinedC: true, + }); + if (inlineCommandIndex === null) { + return false; + } + return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0); +} + export function validateSystemRunCommandConsistency(params: { argv: string[]; rawCommand?: string | null; @@ -59,10 +133,12 @@ export function validateSystemRunCommandConsistency(params: { : null; const shellWrapperResolution = extractShellWrapperCommand(params.argv); const shellCommand = shellWrapperResolution.command; + const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(params.argv); const envManipulationBeforeShellWrapper = shellWrapperResolution.isWrapper && hasEnvManipulationBeforeShellWrapper(params.argv); + const mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv; const inferred = - shellCommand !== null && !envManipulationBeforeShellWrapper + shellCommand !== null && !mustBindDisplayToFullArgv ? shellCommand.trim() : formatExecCommand(params.argv); diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index d2377f579618..1e8250b3210f 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -66,35 +66,48 @@ export function resolvePreferredOpenClawTmpDir( return path.join(base, suffix); }; - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); - if (!isSecureDirForUser(preferred)) { - return fallback(); + const isTrustedPreferredDir = (st: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode?: number; + uid?: number; + }): boolean => { + return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st); + }; + + const resolvePreferredState = ( + requireWritableAccess: boolean, + ): "available" | "missing" | "invalid" => { + try { + const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); + if (!isTrustedPreferredDir(preferred)) { + return "invalid"; + } + if (requireWritableAccess) { + accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); + } + return "available"; + } catch (err) { + if (isNodeErrorWithCode(err, "ENOENT")) { + return "missing"; + } + return "invalid"; } + }; + + const existingPreferredState = resolvePreferredState(true); + if (existingPreferredState === "available") { return POSIX_OPENCLAW_TMP_DIR; - } catch (err) { - if (!isNodeErrorWithCode(err, "ENOENT")) { - return fallback(); - } + } + if (existingPreferredState === "invalid") { + return fallback(); } try { accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); // Create with a safe default; subsequent callers expect it exists. mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 }); - try { - const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); - if (!preferred.isDirectory() || preferred.isSymbolicLink()) { - return fallback(); - } - if (!isSecureDirForUser(preferred)) { - return fallback(); - } - } catch { + if (resolvePreferredState(true) !== "available") { return fallback(); } return POSIX_OPENCLAW_TMP_DIR; diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 85b17376b9f1..08dd3df0a14f 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -90,7 +90,8 @@ export async function detectGlobalInstallManagerForRoot( const globalReal = await tryRealpath(globalRoot); for (const name of ALL_PACKAGE_NAMES) { const expected = path.join(globalReal, name); - if (path.resolve(expected) === path.resolve(pkgReal)) { + const expectedReal = await tryRealpath(expected); + if (path.resolve(expectedReal) === path.resolve(pkgReal)) { return manager; } } @@ -100,7 +101,8 @@ export async function detectGlobalInstallManagerForRoot( const bunGlobalReal = await tryRealpath(bunGlobalRoot); for (const name of ALL_PACKAGE_NAMES) { const bunExpected = path.join(bunGlobalReal, name); - if (path.resolve(bunExpected) === path.resolve(pkgReal)) { + const bunExpectedReal = await tryRealpath(bunExpected); + if (path.resolve(bunExpectedReal) === path.resolve(pkgReal)) { return "bun"; } } diff --git a/src/line/download.test.ts b/src/line/download.test.ts index 2e64473b734e..677f20492001 100644 --- a/src/line/download.test.ts +++ b/src/line/download.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const getMessageContentMock = vi.hoisted(() => vi.fn()); @@ -54,7 +54,7 @@ describe("downloadLineMedia", () => { expect(writtenPath).not.toContain(messageId); expect(writtenPath).not.toContain(".."); - const tmpRoot = path.resolve(os.tmpdir()); + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const rel = path.relative(tmpRoot, path.resolve(writtenPath)); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); }); diff --git a/src/line/webhook-node.test.ts b/src/line/webhook-node.test.ts index c3840ec92df5..0414f63d243a 100644 --- a/src/line/webhook-node.test.ts +++ b/src/line/webhook-node.test.ts @@ -104,6 +104,28 @@ describe("createLineNodeWebhookHandler", () => { expect(bot.handleWebhook).not.toHaveBeenCalled(); }); + it("uses a tight body-read limit for unsigned POST requests", async () => { + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number) => { + expect(maxBytes).toBe(4096); + return JSON.stringify({ events: [{ type: "message" }] }); + }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody, + }); + + const { res } = createRes(); + await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res); + + expect(res.statusCode).toBe(400); + expect(readBody).toHaveBeenCalledTimes(1); + expect(bot.handleWebhook).not.toHaveBeenCalled(); + }); + it("rejects invalid signature", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); const { bot, handler } = createPostWebhookTestHarness(rawBody); diff --git a/src/line/webhook-node.ts b/src/line/webhook-node.ts index 493f00e186ba..da914c90a065 100644 --- a/src/line/webhook-node.ts +++ b/src/line/webhook-node.ts @@ -11,6 +11,7 @@ import { validateLineSignature } from "./signature.js"; import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js"; const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES = 4 * 1024; const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; export async function readLineWebhookRequestBody( @@ -54,8 +55,18 @@ export function createLineNodeWebhookHandler(params: { } try { - const rawBody = await readBody(req, maxBodyBytes); - const signature = req.headers["x-line-signature"]; + const signatureHeader = req.headers["x-line-signature"]; + const signature = + typeof signatureHeader === "string" + ? signatureHeader + : Array.isArray(signatureHeader) + ? signatureHeader[0] + : undefined; + const hasSignature = typeof signature === "string" && signature.trim().length > 0; + const bodyLimit = hasSignature + ? maxBodyBytes + : Math.min(maxBodyBytes, LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES); + const rawBody = await readBody(req, bodyLimit); // Parse once; we may need it for verification requests and for event processing. const body = parseLineWebhookBody(rawBody); @@ -63,7 +74,7 @@ export function createLineNodeWebhookHandler(params: { // LINE webhook verification sends POST {"events":[]} without a // signature header. Return 200 so the LINE Developers Console // "Verify" button succeeds. - if (!signature || typeof signature !== "string") { + if (!hasSignature) { if (isLineWebhookVerificationRequest(body)) { logVerbose("line: webhook verification request (empty events, no signature) - 200 OK"); res.statusCode = 200; diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 17203c6972dc..bab451bc3e63 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -144,8 +144,31 @@ function applySpoilerTokens(tokens: MarkdownToken[]): void { } function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { + let totalDelims = 0; + for (const token of tokens) { + if (token.type !== "text") { + continue; + } + const content = token.content ?? ""; + let i = 0; + while (i < content.length) { + const next = content.indexOf("||", i); + if (next === -1) { + break; + } + totalDelims += 1; + i = next + 2; + } + } + + if (totalDelims < 2) { + return tokens; + } + const usableDelims = totalDelims - (totalDelims % 2); + const result: MarkdownToken[] = []; const state = { spoilerOpen: false }; + let consumedDelims = 0; for (const token of tokens) { if (token.type !== "text") { @@ -168,9 +191,14 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { } break; } + if (consumedDelims >= usableDelims) { + result.push(createTextToken(token, content.slice(index))); + break; + } if (next > index) { result.push(createTextToken(token, content.slice(index, next))); } + consumedDelims += 1; state.spoilerOpen = !state.spoilerOpen; result.push({ type: state.spoilerOpen ? "spoiler_open" : "spoiler_close", diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 3e80caae9bcf..36e6a89b4388 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { collectProviderApiKeysForExecution, @@ -14,6 +13,7 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; import { MediaAttachmentCache } from "./attachments.js"; import { @@ -566,7 +566,9 @@ export async function runCliEntry(params: { maxBytes, timeoutMs, }); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cli-")); + const outputDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-cli-"), + ); const mediaPath = pathResult.path; const outputBase = path.join(outputDir, path.parse(mediaPath).name); diff --git a/src/media/local-roots.ts b/src/media/local-roots.ts index 8f203d15f7b0..51476200ca16 100644 --- a/src/media/local-roots.ts +++ b/src/media/local-roots.ts @@ -4,9 +4,25 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -function buildMediaLocalRoots(stateDir: string): string[] { +type BuildMediaLocalRootsOptions = { + preferredTmpDir?: string; +}; + +let cachedPreferredTmpDir: string | undefined; + +function resolveCachedPreferredTmpDir(): string { + if (!cachedPreferredTmpDir) { + cachedPreferredTmpDir = resolvePreferredOpenClawTmpDir(); + } + return cachedPreferredTmpDir; +} + +function buildMediaLocalRoots( + stateDir: string, + options: BuildMediaLocalRootsOptions = {}, +): string[] { const resolvedStateDir = path.resolve(stateDir); - const preferredTmpDir = resolvePreferredOpenClawTmpDir(); + const preferredTmpDir = options.preferredTmpDir ?? resolveCachedPreferredTmpDir(); return [ preferredTmpDir, path.join(resolvedStateDir, "media"), diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index bffe6c638bae..2d939c7726ea 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { saveExecApprovals } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; @@ -20,6 +21,30 @@ describe("formatSystemRunAllowlistMissMessage", () => { }); describe("handleSystemRunInvoke mac app exec host routing", () => { + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { + return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; + } + + async function withTempApprovalsHome(params: { + approvals: Parameters[0]; + run: (ctx: { tempHome: string }) => Promise; + }): Promise { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-")); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + process.env.OPENCLAW_HOME = tempHome; + saveExecApprovals(params.approvals); + try { + return await params.run({ tempHome }); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + } + async function runSystemInvoke(params: { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; @@ -49,7 +74,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -120,12 +145,51 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); }); - it("runs canonical argv in allowlist mode for transparent env wrappers", async () => { + it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => { + const { runViaMacAppExecHost } = await runSystemInvoke({ + preferMacAppExecHost: true, + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + runViaResponse: { + ok: true, + payload: { + success: true, + stdout: "app-ok", + stderr: "", + timedOut: false, + exitCode: 0, + error: null, + }, + }, + }); + + expect(runViaMacAppExecHost).toHaveBeenCalledWith({ + approvals: expect.anything(), + request: expect.objectContaining({ + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', + }), + }); + }); + + it("handles transparent env wrappers in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, security: "allowlist", command: ["env", "tr", "a", "b"], }); + if (process.platform === "win32") { + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("allowlist miss"), + }), + }), + ); + return; + } + expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined); expect(sendInvokeResult).toHaveBeenCalledWith( expect.objectContaining({ @@ -174,7 +238,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -213,6 +277,77 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { + const runCommand = vi.fn(async () => ({ + success: true, + stdout: "local-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + })); + const sendInvokeResult = vi.fn(async () => {}); + const sendNodeEvent = vi.fn(async () => {}); + + await withTempApprovalsHome({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + autoAllowSkills: true, + }, + agents: {}, + }, + run: async ({ tempHome }) => { + const skillBinPath = path.join(tempHome, "skill-bin"); + fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); + fs.chmodSync(skillBinPath, 0o755); + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: ["./skill-bin", "--help"], + cwd: tempHome, + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expect(sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + }); + it("denies env -S shell payloads in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, @@ -229,4 +364,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }), ); }); + + it("denies nested env shell payloads when wrapper depth is exceeded", async () => { + if (process.platform === "win32") { + return; + } + const runCommand = vi.fn(async () => { + throw new Error("runCommand should not be called for nested env depth overflow"); + }); + const sendInvokeResult = vi.fn(async () => {}); + const sendNodeEvent = vi.fn(async () => {}); + + await withTempApprovalsHome({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + }, + agents: { + main: { + allowlist: [{ pattern: "/usr/bin/env" }], + }, + }, + }, + run: async ({ tempHome }) => { + const marker = path.join(tempHome, "pwned.txt"); + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: buildNestedEnvShellCommand({ + depth: 5, + payload: `echo PWNED > ${marker}`, + }), + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + expect(fs.existsSync(marker)).toBe(false); + }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expect(sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + }); }); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index aeef1522fcc0..39e6766f7d55 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -14,6 +14,7 @@ import { type ExecAsk, type ExecCommandSegment, type ExecSecurity, + type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -54,6 +55,47 @@ type SystemRunAllowlistAnalysis = { segments: ExecCommandSegment[]; }; +type ResolvedExecApprovals = ReturnType; + +type SystemRunParsePhase = { + argv: string[]; + shellCommand: string | null; + cmdText: string; + agentId: string | undefined; + sessionKey: string; + runId: string; + execution: SystemRunExecutionContext; + approvalDecision: ReturnType; + envOverrides: Record | undefined; + env: Record | undefined; + cwd: string | undefined; + timeoutMs: number | undefined; + needsScreenRecording: boolean; + approved: boolean; +}; + +type SystemRunPolicyPhase = SystemRunParsePhase & { + approvals: ResolvedExecApprovals; + security: ExecSecurity; + policy: ReturnType; + allowlistMatches: ExecAllowlistEntry[]; + analysisOk: boolean; + allowlistSatisfied: boolean; + segments: ExecCommandSegment[]; + plannedAllowlistArgv: string[] | undefined; + isWindows: boolean; +}; + +const safeBinTrustedDirWarningCache = new Set(); + +function warnWritableTrustedDirOnce(message: string): void { + if (safeBinTrustedDirWarningCache.has(message)) { + return; + } + safeBinTrustedDirWarningCache.add(message); + console.warn(message); +} + function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeniedReason { switch (reason) { case "security=deny": @@ -145,7 +187,7 @@ function evaluateSystemRunAllowlist(params: { trustedSafeBinDirs: ReturnType["trustedSafeBinDirs"]; cwd: string | undefined; env: Record | undefined; - skillBins: Set; + skillBins: SkillBinTrustEntry[]; autoAllowSkills: boolean; }): SystemRunAllowlistAnalysis { if (params.shellCommand) { @@ -259,7 +301,9 @@ function applyOutputTruncation(result: RunResult) { export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; -export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { +async function parseSystemRunPhase( + opts: HandleSystemRunInvokeOptions, +): Promise { const command = resolveSystemRunCommand({ command: opts.params.command, rawCommand: opts.params.rawCommand, @@ -269,140 +313,187 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): ok: false, error: { code: "INVALID_REQUEST", message: command.message }, }); - return; + return null; } if (command.argv.length === 0) { await opts.sendInvokeResult({ ok: false, error: { code: "INVALID_REQUEST", message: "command required" }, }); - return; + return null; } - const argv = command.argv; - const rawCommand = command.rawCommand ?? ""; const shellCommand = command.shellCommand; const cmdText = command.cmdText; const agentId = opts.params.agentId?.trim() || undefined; + const sessionKey = opts.params.sessionKey?.trim() || "node"; + const runId = opts.params.runId?.trim() || crypto.randomUUID(); + const envOverrides = sanitizeSystemRunEnvOverrides({ + overrides: opts.params.env ?? undefined, + shellWrapper: shellCommand !== null, + }); + return { + argv: command.argv, + shellCommand, + cmdText, + agentId, + sessionKey, + runId, + execution: { sessionKey, runId, cmdText }, + approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision), + envOverrides, + env: opts.sanitizeEnv(envOverrides), + cwd: opts.params.cwd?.trim() || undefined, + timeoutMs: opts.params.timeoutMs ?? undefined, + needsScreenRecording: opts.params.needsScreenRecording === true, + approved: opts.params.approved === true, + }; +} + +async function evaluateSystemRunPolicyPhase( + opts: HandleSystemRunInvokeOptions, + parsed: SystemRunParsePhase, +): Promise { const cfg = loadConfig(); - const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; + const agentExec = parsed.agentId + ? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec + : undefined; const configuredSecurity = opts.resolveExecSecurity( agentExec?.security ?? cfg.tools?.exec?.security, ); const configuredAsk = opts.resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); - const approvals = resolveExecApprovals(agentId, { + const approvals = resolveExecApprovals(parsed.agentId, { security: configuredSecurity, ask: configuredAsk, }); const security = approvals.agent.security; const ask = approvals.agent.ask; const autoAllowSkills = approvals.agent.autoAllowSkills; - const sessionKey = opts.params.sessionKey?.trim() || "node"; - const runId = opts.params.runId?.trim() || crypto.randomUUID(); - const execution: SystemRunExecutionContext = { sessionKey, runId, cmdText }; - const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision); - const envOverrides = sanitizeSystemRunEnvOverrides({ - overrides: opts.params.env ?? undefined, - shellWrapper: shellCommand !== null, - }); - const env = opts.sanitizeEnv(envOverrides); const { safeBins, safeBinProfiles, trustedSafeBinDirs } = resolveExecSafeBinRuntimePolicy({ global: cfg.tools?.exec, local: agentExec, + onWarning: warnWritableTrustedDirOnce, }); - const bins = autoAllowSkills ? await opts.skillBins.current() : new Set(); + const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ - shellCommand, - argv, + shellCommand: parsed.shellCommand, + argv: parsed.argv, approvals, security, safeBins, safeBinProfiles, trustedSafeBinDirs, - cwd: opts.params.cwd ?? undefined, - env, + cwd: parsed.cwd, + env: parsed.env, skillBins: bins, autoAllowSkills, }); const isWindows = process.platform === "win32"; - const cmdInvocation = shellCommand + const cmdInvocation = parsed.shellCommand ? opts.isCmdExeInvocation(segments[0]?.argv ?? []) - : opts.isCmdExeInvocation(argv); + : opts.isCmdExeInvocation(parsed.argv); const policy = evaluateSystemRunPolicy({ security, ask, analysisOk, allowlistSatisfied, - approvalDecision, - approved: opts.params.approved === true, + approvalDecision: parsed.approvalDecision, + approved: parsed.approved, isWindows, cmdInvocation, - shellWrapperInvocation: shellCommand !== null, + shellWrapperInvocation: parsed.shellCommand !== null, }); analysisOk = policy.analysisOk; allowlistSatisfied = policy.allowlistSatisfied; if (!policy.allowed) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, parsed.execution, { reason: policy.eventReason, message: policy.errorMessage, }); - return; + return null; } // Fail closed if policy/runtime drift re-allows unapproved shell wrappers. - if (security === "allowlist" && shellCommand && !policy.approvedByAsk) { - await sendSystemRunDenied(opts, execution, { + if (security === "allowlist" && parsed.shellCommand && !policy.approvedByAsk) { + await sendSystemRunDenied(opts, parsed.execution, { reason: "approval-required", message: "SYSTEM_RUN_DENIED: approval required", }); - return; + return null; } const plannedAllowlistArgv = resolvePlannedAllowlistArgv({ security, - shellCommand, + shellCommand: parsed.shellCommand, policy, segments, }); if (plannedAllowlistArgv === null) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, parsed.execution, { reason: "execution-plan-miss", message: "SYSTEM_RUN_DENIED: execution plan mismatch", }); - return; + return null; } + return { + ...parsed, + approvals, + security, + policy, + allowlistMatches, + analysisOk, + allowlistSatisfied, + segments, + plannedAllowlistArgv: plannedAllowlistArgv ?? undefined, + isWindows, + }; +} +async function executeSystemRunPhase( + opts: HandleSystemRunInvokeOptions, + phase: SystemRunPolicyPhase, +): Promise { const useMacAppExec = opts.preferMacAppExecHost; if (useMacAppExec) { const execRequest: ExecHostRequest = { - command: plannedAllowlistArgv ?? argv, - rawCommand: rawCommand || shellCommand || null, - cwd: opts.params.cwd ?? null, - env: envOverrides ?? null, - timeoutMs: opts.params.timeoutMs ?? null, - needsScreenRecording: opts.params.needsScreenRecording ?? null, - agentId: agentId ?? null, - sessionKey: sessionKey ?? null, - approvalDecision, + command: phase.plannedAllowlistArgv ?? phase.argv, + // Forward canonical display text so companion approval/prompt surfaces bind to + // the exact command context already validated on the node-host. + rawCommand: phase.cmdText || null, + cwd: phase.cwd ?? null, + env: phase.envOverrides ?? null, + timeoutMs: phase.timeoutMs ?? null, + needsScreenRecording: phase.needsScreenRecording, + agentId: phase.agentId ?? null, + sessionKey: phase.sessionKey ?? null, + approvalDecision: phase.approvalDecision, }; - const response = await opts.runViaMacAppExecHost({ approvals, request: execRequest }); + const response = await opts.runViaMacAppExecHost({ + approvals: phase.approvals, + request: execRequest, + }); if (!response) { if (opts.execHostEnforced || !opts.execHostFallbackAllowed) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, phase.execution, { reason: "companion-unavailable", message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable", }); return; } } else if (!response.ok) { - await sendSystemRunDenied(opts, execution, { + await sendSystemRunDenied(opts, phase.execution, { reason: normalizeDeniedReason(response.error.reason), message: response.error.message, }); return; } else { const result: ExecHostRunResult = response.payload; - await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result }); + await opts.sendExecFinishedEvent({ + sessionKey: phase.sessionKey, + runId: phase.runId, + cmdText: phase.cmdText, + result, + }); await opts.sendInvokeResult({ ok: true, payloadJSON: JSON.stringify(result), @@ -411,41 +502,41 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): } } - if (policy.approvalDecision === "allow-always" && security === "allowlist") { - if (policy.analysisOk) { + if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") { + if (phase.policy.analysisOk) { const patterns = resolveAllowAlwaysPatterns({ - segments, - cwd: opts.params.cwd ?? undefined, - env, + segments: phase.segments, + cwd: phase.cwd, + env: phase.env, platform: process.platform, }); for (const pattern of patterns) { if (pattern) { - addAllowlistEntry(approvals.file, agentId, pattern); + addAllowlistEntry(phase.approvals.file, phase.agentId, pattern); } } } } - if (allowlistMatches.length > 0) { + if (phase.allowlistMatches.length > 0) { const seen = new Set(); - for (const match of allowlistMatches) { + for (const match of phase.allowlistMatches) { if (!match?.pattern || seen.has(match.pattern)) { continue; } seen.add(match.pattern); recordAllowlistUse( - approvals.file, - agentId, + phase.approvals.file, + phase.agentId, match, - cmdText, - segments[0]?.resolution?.resolvedPath, + phase.cmdText, + phase.segments[0]?.resolution?.resolvedPath, ); } } - if (opts.params.needsScreenRecording === true) { - await sendSystemRunDenied(opts, execution, { + if (phase.needsScreenRecording) { + await sendSystemRunDenied(opts, phase.execution, { reason: "permission:screenRecording", message: "PERMISSION_MISSING: screenRecording", }); @@ -453,23 +544,23 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): } const execArgv = resolveSystemRunExecArgv({ - plannedAllowlistArgv: plannedAllowlistArgv ?? undefined, - argv, - security, - isWindows, - policy, - shellCommand, - segments, + plannedAllowlistArgv: phase.plannedAllowlistArgv, + argv: phase.argv, + security: phase.security, + isWindows: phase.isWindows, + policy: phase.policy, + shellCommand: phase.shellCommand, + segments: phase.segments, }); - const result = await opts.runCommand( - execArgv, - opts.params.cwd?.trim() || undefined, - env, - opts.params.timeoutMs ?? undefined, - ); + const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs); applyOutputTruncation(result); - await opts.sendExecFinishedEvent({ sessionKey, runId, cmdText, result }); + await opts.sendExecFinishedEvent({ + sessionKey: phase.sessionKey, + runId: phase.runId, + cmdText: phase.cmdText, + result, + }); await opts.sendInvokeResult({ ok: true, @@ -483,3 +574,15 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): }), }); } + +export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { + const parsed = await parseSystemRunPhase(opts); + if (!parsed) { + return; + } + const policyPhase = await evaluateSystemRunPolicyPhase(opts, parsed); + if (!policyPhase) { + return; + } + await executeSystemRunPhase(opts, policyPhase); +} diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index ae41d56b9610..7246ba2925f0 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -1,3 +1,5 @@ +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; + export type SystemRunParams = { command: string[]; rawCommand?: string | null; @@ -35,5 +37,5 @@ export type ExecEventPayload = { }; export type SkillBinsProvider = { - current(force?: boolean): Promise>; + current(force?: boolean): Promise; }; diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index e8b5df74f0ee..edf2cc122159 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,7 +1,10 @@ +import fs from "node:fs"; +import path from "node:path"; import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig } from "../config/config.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -27,17 +30,83 @@ type NodeHostRunOptions = { const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +function isExecutableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (process.platform !== "win32") { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null { + if (bin.includes("/") || bin.includes("\\")) { + return null; + } + const hasExtension = process.platform === "win32" && path.extname(bin).length > 0; + const extensions = + process.platform === "win32" + ? hasExtension + ? [""] + : (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((ext) => ext.toLowerCase()) + : [""]; + for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + const candidate = path.join(dir, bin + ext); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] { + const trustEntries: SkillBinTrustEntry[] = []; + const seen = new Set(); + for (const bin of bins) { + const name = bin.trim(); + if (!name) { + continue; + } + const resolvedPath = resolveExecutablePathFromEnv(name, pathEnv); + if (!resolvedPath) { + continue; + } + const key = `${name}\u0000${resolvedPath}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + trustEntries.push({ name, resolvedPath }); + } + return trustEntries.toSorted( + (left, right) => + left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath), + ); +} + class SkillBinsCache implements SkillBinsProvider { - private bins = new Set(); + private bins: SkillBinTrustEntry[] = []; private lastRefresh = 0; private readonly ttlMs = 90_000; private readonly fetch: () => Promise; + private readonly pathEnv: string; - constructor(fetch: () => Promise) { + constructor(fetch: () => Promise, pathEnv: string) { this.fetch = fetch; + this.pathEnv = pathEnv; } - async current(force = false): Promise> { + async current(force = false): Promise { if (force || Date.now() - this.lastRefresh > this.ttlMs) { await this.refresh(); } @@ -47,11 +116,11 @@ class SkillBinsCache implements SkillBinsProvider { private async refresh() { try { const bins = await this.fetch(); - this.bins = new Set(bins); + this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv); this.lastRefresh = Date.now(); } catch { if (!this.lastRefresh) { - this.bins = new Set(); + this.bins = []; } } } @@ -155,7 +224,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const res = await client.request<{ bins: Array }>("skills.bins", {}); const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : []; return bins; - }); + }, pathEnv); client.start(); await new Promise(() => {}); diff --git a/src/plugin-sdk/allow-from.test.ts b/src/plugin-sdk/allow-from.test.ts index cc69376c5fe5..8ad13fe98f6c 100644 --- a/src/plugin-sdk/allow-from.test.ts +++ b/src/plugin-sdk/allow-from.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isAllowedParsedChatSender } from "./allow-from.js"; +import { isAllowedParsedChatSender, isNormalizedSenderAllowed } from "./allow-from.js"; function parseAllowTarget( entry: string, @@ -71,3 +71,34 @@ describe("isAllowedParsedChatSender", () => { expect(allowed).toBe(true); }); }); + +describe("isNormalizedSenderAllowed", () => { + it("allows wildcard", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "attacker", + allowFrom: ["*"], + }), + ).toBe(true); + }); + + it("normalizes case and strips prefixes", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "12345", + allowFrom: ["ZALO:12345", "zl:777"], + stripPrefixRe: /^(zalo|zl):/i, + }), + ).toBe(true); + }); + + it("rejects when sender is missing", () => { + expect( + isNormalizedSenderAllowed({ + senderId: "999", + allowFrom: ["zl:12345"], + stripPrefixRe: /^(zalo|zl):/i, + }), + ).toBe(false); + }); +}); diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index 39ef277876ab..93c3d52c7125 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -9,6 +9,25 @@ export function formatAllowFromLowercase(params: { .map((entry) => entry.toLowerCase()); } +export function isNormalizedSenderAllowed(params: { + senderId: string | number; + allowFrom: Array; + stripPrefixRe?: RegExp; +}): boolean { + const normalizedAllow = formatAllowFromLowercase({ + allowFrom: params.allowFrom, + stripPrefixRe: params.stripPrefixRe, + }); + if (normalizedAllow.length === 0) { + return false; + } + if (normalizedAllow.includes("*")) { + return true; + } + const sender = String(params.senderId).trim().toLowerCase(); + return normalizedAllow.includes(sender); +} + type ParsedChatAllowTarget = | { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } diff --git a/src/plugin-sdk/group-access.test.ts b/src/plugin-sdk/group-access.test.ts new file mode 100644 index 000000000000..77eaf7a0fa26 --- /dev/null +++ b/src/plugin-sdk/group-access.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSenderGroupAccess } from "./group-access.js"; + +describe("evaluateSenderGroupAccess", () => { + it("defaults missing provider config to allowlist", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: false, + configuredGroupPolicy: undefined, + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toEqual({ + allowed: true, + groupPolicy: "allowlist", + providerMissingFallbackApplied: true, + reason: "allowed", + }); + }); + + it("blocks disabled policy", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "disabled", + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toMatchObject({ allowed: false, reason: "disabled", groupPolicy: "disabled" }); + }); + + it("blocks allowlist with empty list", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: [], + senderId: "123", + isSenderAllowed: () => true, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "empty_allowlist", + groupPolicy: "allowlist", + }); + }); + + it("blocks sender not allowlisted", () => { + const decision = evaluateSenderGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["123"], + senderId: "999", + isSenderAllowed: () => false, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "sender_not_allowlisted", + groupPolicy: "allowlist", + }); + }); +}); diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts new file mode 100644 index 000000000000..872b7dc8d76b --- /dev/null +++ b/src/plugin-sdk/group-access.ts @@ -0,0 +1,64 @@ +import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; +import type { GroupPolicy } from "../config/types.base.js"; + +export type SenderGroupAccessReason = + | "allowed" + | "disabled" + | "empty_allowlist" + | "sender_not_allowlisted"; + +export type SenderGroupAccessDecision = { + allowed: boolean; + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; + reason: SenderGroupAccessReason; +}; + +export function evaluateSenderGroupAccess(params: { + providerConfigPresent: boolean; + configuredGroupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; + groupAllowFrom: string[]; + senderId: string; + isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; +}): SenderGroupAccessDecision { + const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.configuredGroupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + }); + + if (groupPolicy === "disabled") { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "disabled", + }; + } + if (groupPolicy === "allowlist") { + if (params.groupAllowFrom.length === 0) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "empty_allowlist", + }; + } + if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) { + return { + allowed: false, + groupPolicy, + providerMissingFallbackApplied, + reason: "sender_not_allowlisted", + }; + } + } + + return { + allowed: true, + groupPolicy, + providerMissingFallbackApplied, + reason: "allowed", + }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 57c1777d243e..5709304a48c9 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -182,7 +182,16 @@ export { normalizeAccountId, resolveThreadSessionKeys, } from "../routing/session-key.js"; -export { formatAllowFromLowercase, isAllowedParsedChatSender } from "./allow-from.js"; +export { + formatAllowFromLowercase, + isAllowedParsedChatSender, + isNormalizedSenderAllowed, +} from "./allow-from.js"; +export { + evaluateSenderGroupAccess, + type SenderGroupAccessDecision, + type SenderGroupAccessReason, +} from "./group-access.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; @@ -201,6 +210,7 @@ export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { runPluginCommandWithTimeout, type PluginCommandRunOptions, diff --git a/src/plugin-sdk/temp-path.test.ts b/src/plugin-sdk/temp-path.test.ts index dbd2d46ee0ff..166a2373b15f 100644 --- a/src/plugin-sdk/temp-path.test.ts +++ b/src/plugin-sdk/temp-path.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; describe("buildRandomTempFilePath", () => { @@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => { }); it("sanitizes prefix and extension to avoid path traversal segments", () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const result = buildRandomTempFilePath({ prefix: "../../line/../media", extension: "/../.jpg", now: 123, uuid: "abc", }); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(result); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); @@ -45,11 +45,12 @@ describe("withTempDownloadPath", () => { }, ); - expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-")); + expect(capturedPath).toContain(path.join(resolvePreferredOpenClawTmpDir(), "line-media-")); await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("sanitizes prefix and fileName", async () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); let capturedPath = ""; await withTempDownloadPath( { @@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => { }, ); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(capturedPath); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index ed1b149135af..c418fe9f664d 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; function sanitizePrefix(prefix: string): string { const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); @@ -27,6 +27,19 @@ function sanitizeFileName(fileName: string): string { return normalized || "download.bin"; } +function resolveTempRoot(tmpDir?: string): string { + return tmpDir ?? resolvePreferredOpenClawTmpDir(); +} + +function isNodeErrorWithCode(err: unknown, code: string): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: string }).code === code + ); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -42,7 +55,7 @@ export function buildRandomTempFilePath(params: { ? Math.trunc(nowCandidate) : Date.now(); const uuid = params.uuid?.trim() || crypto.randomUUID(); - return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`); + return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`); } export async function withTempDownloadPath( @@ -53,13 +66,19 @@ export async function withTempDownloadPath( }, fn: (tmpPath: string) => Promise, ): Promise { - const tempRoot = params.tmpDir ?? os.tmpdir(); + const tempRoot = resolveTempRoot(params.tmpDir); const prefix = `${sanitizePrefix(params.prefix)}-`; const dir = await mkdtemp(path.join(tempRoot, prefix)); const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin")); try { return await fn(tmpPath); } finally { - await rm(dir, { recursive: true, force: true }).catch(() => {}); + try { + await rm(dir, { recursive: true, force: true }); + } catch (err) { + if (!isNodeErrorWithCode(err, "ENOENT")) { + console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`); + } + } } } diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts new file mode 100644 index 000000000000..fca12e4dc113 --- /dev/null +++ b/src/plugins/http-registry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerPluginHttpRoute } from "./http-registry.js"; +import { createEmptyPluginRegistry } from "./registry.js"; + +describe("registerPluginHttpRoute", () => { + it("registers route and unregisters it", () => { + const registry = createEmptyPluginRegistry(); + const handler = vi.fn(); + + const unregister = registerPluginHttpRoute({ + path: "/plugins/demo", + handler, + registry, + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); + expect(registry.httpRoutes[0]?.handler).toBe(handler); + + unregister(); + expect(registry.httpRoutes).toHaveLength(0); + }); + + it("returns noop unregister when path is missing", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const unregister = registerPluginHttpRoute({ + path: "", + handler: vi.fn(), + registry, + accountId: "default", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(0); + expect(logs).toEqual(['plugin: webhook path missing for account "default"']); + expect(() => unregister()).not.toThrow(); + }); + + it("replaces stale route on same path and keeps latest registration", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + + const unregisterFirst = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: firstHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + const unregisterSecond = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: secondHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + expect(logs).toContain( + 'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)', + ); + + // Old unregister must not remove the replacement route. + unregisterFirst(); + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + + unregisterSecond(); + expect(registry.httpRoutes).toHaveLength(0); + }); +}); diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 5e2df3b522dd..5987fd173705 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -29,10 +29,11 @@ export function registerPluginHttpRoute(params: { return () => {}; } - if (routes.some((entry) => entry.path === normalizedPath)) { + const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath); + if (existingIndex >= 0) { const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; - params.log?.(`plugin: webhook path ${normalizedPath} already registered${suffix}${pluginHint}`); - return () => {}; + params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`); + routes.splice(existingIndex, 1); } const entry: PluginHttpRouteRegistration = { diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 87409e7eee0d..9f67e69430bd 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -513,6 +513,87 @@ describe("installPluginFromDir", () => { expect(manifest.devDependencies?.openclaw).toBeUndefined(); expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); }); + + it("uses openclaw.plugin.json id as install key when it differs from package name", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const infoMessages: string[] = []; + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + expect( + infoMessages.some((msg) => + msg.includes( + 'Plugin manifest id "memory-cognee" differs from npm package name "cognee-openclaw"', + ), + ), + ).toBe(true); + }); + + it("normalizes scoped manifest ids to unscoped install keys", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "@team/memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + expectedPluginId: "memory-cognee", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + }); }); describe("installPluginFromNpmSpec", () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 40aeb3c5a637..baf3eb690ad7 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -26,6 +26,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { loadPluginManifest } from "./manifest.js"; type PluginInstallLogger = { info?: (message: string) => void; @@ -149,7 +150,19 @@ async function installPluginFromPackageDir(params: { } const pkgName = typeof manifest.name === "string" ? manifest.name : ""; - const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + + // Prefer the canonical `id` from openclaw.plugin.json over the npm package name. + // This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee") + // differs from the npm package name (e.g. "cognee-openclaw"), the plugin registry + // uses the manifest id as the authoritative key, so the config entry must match it. + const ocManifestResult = loadPluginManifest(params.packageDir); + const manifestPluginId = + ocManifestResult.ok && ocManifestResult.manifest.id + ? unscopedPackageName(ocManifestResult.manifest.id) + : undefined; + + const pluginId = manifestPluginId ?? npmPluginId; const pluginIdError = validatePluginId(pluginId); if (pluginIdError) { return { ok: false, error: pluginIdError }; @@ -161,6 +174,12 @@ async function installPluginFromPackageDir(params: { }; } + if (manifestPluginId && manifestPluginId !== npmPluginId) { + logger.info?.( + `Plugin manifest id "${manifestPluginId}" differs from npm package name "${npmPluginId}"; using manifest id as the config key.`, + ); + } + const packageDir = path.resolve(params.packageDir); const forcedScanEntries: string[] = []; for (const entry of extensions) { diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 2292d95b7609..05e63a2b2f93 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -41,7 +41,11 @@ describe("compaction hook wiring", () => { hookMocks.runner.hasHooks.mockReturnValue(true); const ctx = { - params: { runId: "r1", session: { messages: [1, 2, 3] } }, + params: { + runId: "r1", + sessionKey: "agent:main:web-abc123", + session: { messages: [1, 2, 3], sessionFile: "/tmp/test.jsonl" }, + }, state: { compactionInFlight: false }, log: { debug: vi.fn(), warn: vi.fn() }, incrementCompactionCount: vi.fn(), @@ -53,10 +57,16 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runBeforeCompaction).toHaveBeenCalledTimes(1); const beforeCalls = hookMocks.runner.runBeforeCompaction.mock.calls as unknown as Array< - [unknown] + [unknown, unknown] >; - const event = beforeCalls[0]?.[0] as { messageCount?: number } | undefined; + const event = beforeCalls[0]?.[0] as + | { messageCount?: number; messages?: unknown[]; sessionFile?: string } + | undefined; expect(event?.messageCount).toBe(3); + expect(event?.messages).toEqual([1, 2, 3]); + expect(event?.sessionFile).toBe("/tmp/test.jsonl"); + const hookCtx = beforeCalls[0]?.[1] as { sessionKey?: string } | undefined; + expect(hookCtx?.sessionKey).toBe("agent:main:web-abc123"); }); it("calls runAfterCompaction when willRetry is false", () => { diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 1f41edaa040e..67c443cb2e29 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -101,16 +101,16 @@ describe("runCommandWithTimeout", () => { "let count = 0;", 'const ticker = setInterval(() => { process.stdout.write(".");', "count += 1;", - "if (count === 2) {", + "if (count === 6) {", "clearInterval(ticker);", "process.exit(0);", "}", - "}, 40);", + "}, 200);", ].join(" "), ], { - timeoutMs: 5_000, - noOutputTimeoutMs: 1_500, + timeoutMs: 7_000, + noOutputTimeoutMs: 450, }, ); @@ -118,7 +118,7 @@ describe("runCommandWithTimeout", () => { expect(result.code ?? 0).toBe(0); expect(result.termination).toBe("exit"); expect(result.noOutputTimedOut).toBe(false); - expect(result.stdout.length).toBeGreaterThanOrEqual(3); + expect(result.stdout.length).toBeGreaterThanOrEqual(7); }); it("reports global timeout termination when overall timeout elapses", async () => { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 5337731f3e21..c92bfe2ba17f 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -521,6 +521,30 @@ describe("backward compatibility: peer.kind dm → direct", () => { expect(route.agentId).toBe("alex"); expect(route.matchedBy).toBe("binding.peer"); }); + + test("runtime dm peer.kind matches config direct binding (#22730)", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "alex", + match: { + channel: "whatsapp", + // Config uses canonical "direct" + peer: { kind: "direct", id: "+15551234567" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + // Plugin sends "dm" instead of "direct" + peer: { kind: "dm" as ChatType, id: "+15551234567" }, + }); + expect(route.agentId).toBe("alex"); + expect(route.matchedBy).toBe("binding.peer"); + }); }); describe("role-based agent routing", () => { diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 6dab84d34209..74f1b3831b4d 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -291,7 +291,12 @@ function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope) export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); - const peer = input.peer ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } : null; + const peer = input.peer + ? { + kind: normalizeChatType(input.peer.kind) ?? input.peer.kind, + id: normalizeId(input.peer.id), + } + : null; const guildId = normalizeId(input.guildId); const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; @@ -351,7 +356,10 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } // Thread parent inheritance: if peer (thread) didn't match, check parent peer binding const parentPeer = input.parentPeer - ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } + ? { + kind: normalizeChatType(input.parentPeer.kind) ?? input.parentPeer.kind, + id: normalizeId(input.parentPeer.id), + } : null; const baseScope = { guildId, diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index e5417a0f9be1..daa60aed73f0 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -3,6 +3,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; /** * Synchronous security audit collector functions. * @@ -338,6 +339,137 @@ function listGroupPolicyOpen(cfg: OpenClawConfig): string[] { return out; } +function hasConfiguredGroupTargets(section: Record): boolean { + const groupKeys = ["groups", "guilds", "channels", "rooms"]; + return groupKeys.some((key) => { + const value = section[key]; + return Boolean(value && typeof value === "object" && Object.keys(value).length > 0); + }); +} + +function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] { + const out = new Set(); + const channels = cfg.channels as Record | undefined; + if (!channels || typeof channels !== "object") { + return []; + } + + const inspectSection = (section: Record, basePath: string) => { + const groupPolicy = typeof section.groupPolicy === "string" ? section.groupPolicy : null; + if (groupPolicy === "open") { + out.add(`${basePath}.groupPolicy="open"`); + } else if (groupPolicy === "allowlist" && hasConfiguredGroupTargets(section)) { + out.add(`${basePath}.groupPolicy="allowlist" with configured group targets`); + } + + const dmPolicy = typeof section.dmPolicy === "string" ? section.dmPolicy : null; + if (dmPolicy === "open") { + out.add(`${basePath}.dmPolicy="open"`); + } + + const allowFrom = Array.isArray(section.allowFrom) ? section.allowFrom : []; + if (allowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.allowFrom includes "*"`); + } + + const groupAllowFrom = Array.isArray(section.groupAllowFrom) ? section.groupAllowFrom : []; + if (groupAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.groupAllowFrom includes "*"`); + } + + const dm = section.dm; + if (dm && typeof dm === "object") { + const dmSection = dm as Record; + const dmLegacyPolicy = typeof dmSection.policy === "string" ? dmSection.policy : null; + if (dmLegacyPolicy === "open") { + out.add(`${basePath}.dm.policy="open"`); + } + const dmAllowFrom = Array.isArray(dmSection.allowFrom) ? dmSection.allowFrom : []; + if (dmAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.dm.allowFrom includes "*"`); + } + } + }; + + for (const [channelId, value] of Object.entries(channels)) { + if (!value || typeof value !== "object") { + continue; + } + const section = value as Record; + inspectSection(section, `channels.${channelId}`); + const accounts = section.accounts; + if (!accounts || typeof accounts !== "object") { + continue; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountValue || typeof accountValue !== "object") { + continue; + } + inspectSection( + accountValue as Record, + `channels.${channelId}.accounts.${accountId}`, + ); + } + } + + return Array.from(out); +} + +function collectRiskyToolExposureContexts(cfg: OpenClawConfig): { + riskyContexts: string[]; + hasRuntimeRisk: boolean; +} { + const contexts: Array<{ + label: string; + agentId?: string; + tools?: AgentToolsConfig; + }> = [{ label: "agents.defaults" }]; + for (const agent of cfg.agents?.list ?? []) { + if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { + continue; + } + contexts.push({ + label: `agents.list.${agent.id}`, + agentId: agent.id, + tools: agent.tools, + }); + } + + const riskyContexts: string[] = []; + let hasRuntimeRisk = false; + for (const context of contexts) { + const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId ?? null, + }); + const runtimeTools = ["exec", "process"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; + const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; + const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; + if (!runtimeUnguarded && !fsUnguarded) { + continue; + } + if (runtimeUnguarded) { + hasRuntimeRisk = true; + } + riskyContexts.push( + `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ + fsWorkspaceOnly === true ? "true" : "false" + })`, + ); + } + + return { riskyContexts, hasRuntimeRisk }; +} + // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- @@ -358,7 +490,9 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi `\n` + `hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` + `\n` + - `browser control: ${browserEnabled ? "enabled" : "disabled"}`; + `browser control: ${browserEnabled ? "enabled" : "disabled"}` + + `\n` + + "trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway"; return [ { @@ -697,13 +831,21 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu } const network = typeof docker.network === "string" ? docker.network : undefined; - if (network && network.trim().toLowerCase() === "host") { + const normalizedNetwork = normalizeNetworkMode(network); + if (isDangerousNetworkMode(network)) { + const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`; + const detail = + normalizedNetwork === "host" + ? `${source}.network is "host" which bypasses container network isolation entirely.` + : `${source}.network is ${modeLabel} which joins another container namespace and can bypass sandbox network isolation.`; findings.push({ checkId: "sandbox.dangerous_network_mode", severity: "critical", - title: "Network host mode in sandbox config", - detail: `${source}.network is "host" which bypasses container network isolation entirely.`, - remediation: `Set ${source}.network to "bridge" or "none".`, + title: "Dangerous network mode in sandbox config", + detail, + remediation: + `Set ${source}.network to "bridge", "none", or a custom bridge network name.` + + ` Use ${source}.dangerouslyAllowContainerNamespaceJoin=true only as a break-glass override when you fully trust this runtime.`, }); } @@ -1096,53 +1238,7 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi }); } - const contexts: Array<{ - label: string; - agentId?: string; - tools?: AgentToolsConfig; - }> = [{ label: "agents.defaults" }]; - for (const agent of cfg.agents?.list ?? []) { - if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { - continue; - } - contexts.push({ - label: `agents.list.${agent.id}`, - agentId: agent.id, - tools: agent.tools, - }); - } - - const riskyContexts: string[] = []; - let hasRuntimeRisk = false; - for (const context of contexts) { - const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; - const policies = resolveToolPolicies({ - cfg, - agentTools: context.tools, - sandboxMode, - agentId: context.agentId ?? null, - }); - const runtimeTools = ["exec", "process"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; - const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; - const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; - if (!runtimeUnguarded && !fsUnguarded) { - continue; - } - if (runtimeUnguarded) { - hasRuntimeRisk = true; - } - riskyContexts.push( - `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ - fsWorkspaceOnly === true ? "true" : "false" - })`, - ); - } + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); if (riskyContexts.length > 0) { findings.push({ @@ -1160,3 +1256,35 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi return findings; } + +export function collectLikelyMultiUserSetupFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const signals = listPotentialMultiUserSignals(cfg); + if (signals.length === 0) { + return findings; + } + + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); + const impactLine = hasRuntimeRisk + ? "Runtime/process tools are exposed without full sandboxing in at least one context." + : "No unguarded runtime/process tools were detected by this heuristic."; + const riskyContextsDetail = + riskyContexts.length > 0 + ? `Potential high-impact tool exposure contexts:\n${riskyContexts.map((line) => `- ${line}`).join("\n")}` + : "No unguarded runtime/filesystem contexts detected."; + + findings.push({ + checkId: "security.trust_model.multi_user_heuristic", + severity: "warn", + title: "Potential multi-user setup detected (personal-assistant model warning)", + detail: + "Heuristic signals indicate this gateway may be reachable by multiple users:\n" + + signals.map((signal) => `- ${signal}`).join("\n") + + `\n${impactLine}\n${riskyContextsDetail}\n` + + "OpenClaw's default security model is personal-assistant (one trusted operator boundary), not hostile multi-tenant isolation on one shared gateway.", + remediation: + 'If users may be mutually untrusted, split trust boundaries (separate gateways + credentials, ideally separate OS users/hosts). If you intentionally run shared-user access, set agents.defaults.sandbox.mode="all", keep tools.fs.workspaceOnly=true, deny runtime/fs/web tools unless required, and keep personal/private identities + credentials off that runtime.', + }); + + return findings; +} diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index fa2b82fa150a..9345cb8732ba 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -14,6 +14,7 @@ export { collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, + collectLikelyMultiUserSetupFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDangerousAllowCommandFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2b4fbebe033e..93e9d1131741 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -178,12 +178,14 @@ describe("security audit", () => { }; const res = await audit(cfg); + const summary = res.findings.find((f) => f.checkId === "summary.attack_surface"); expect(res.findings).toEqual( expect.arrayContaining([ expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), ]), ); + expect(summary?.detail).toContain("trust model: personal assistant"); }); it("flags non-loopback bind without auth as critical", async () => { @@ -436,6 +438,54 @@ describe("security audit", () => { ); }); + it("warns for risky safeBinTrustedDirs entries", async () => { + const riskyGlobalTrustedDirs = + process.platform === "win32" + ? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`] + : ["/usr/local/bin", "/tmp/openclaw-safe-bins"]; + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBinTrustedDirs: riskyGlobalTrustedDirs, + }, + }, + agents: { + list: [ + { + id: "ops", + tools: { + exec: { + safeBinTrustedDirs: ["./relative-bin-dir"], + }, + }, + }, + ], + }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "tools.exec.safe_bin_trusted_dirs_risky", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[0]); + expect(finding?.detail).toContain(riskyGlobalTrustedDirs[1]); + expect(finding?.detail).toContain("agents.list.ops.tools.exec"); + }); + + it("does not warn for non-risky absolute safeBinTrustedDirs entries", async () => { + const cfg: OpenClawConfig = { + tools: { + exec: { + safeBinTrustedDirs: ["/usr/libexec"], + }, + }, + }; + + const res = await audit(cfg); + expectNoFinding(res, "tools.exec.safe_bin_trusted_dirs_risky"); + }); + it("evaluates loopback control UI and logging exposure findings", async () => { const cases: Array<{ name: string; @@ -853,6 +903,31 @@ describe("security audit", () => { ); }); + it("flags container namespace join network mode in sandbox config", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + network: "container:peer", + }, + }, + }, + }, + }; + const res = await audit(cfg); + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Dangerous network mode in sandbox config", + }), + ]), + ); + }); + it("checks sandbox browser bridge-network restrictions", async () => { const cases: Array<{ name: string; @@ -2696,6 +2771,51 @@ description: test skill ).toBe(false); }); + it("warns when config heuristics suggest a likely multi-user setup", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "1234567890": { + channels: { + "7777777777": { allow: true }, + }, + }, + }, + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "security.trust_model.multi_user_heuristic", + ); + + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain( + 'channels.discord.groupPolicy="allowlist" with configured group targets', + ); + expect(finding?.detail).toContain("personal-assistant"); + expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); + }); + + it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + + expectNoFinding(res, "security.trust_model.multi_user_heuristic"); + }); + describe("maybeProbeGateway auth selection", () => { const makeProbeCapture = () => { let capturedAuth: { token?: string; password?: string } | undefined; diff --git a/src/security/audit.ts b/src/security/audit.ts index 6d4aa90d380b..e6254b5cc805 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,4 +1,5 @@ import { isIP } from "node:net"; +import path from "node:path"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { execDockerRaw } from "../agents/sandbox/docker.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; @@ -15,6 +16,7 @@ import { listInterpreterLikeSafeBins, resolveMergedSafeBinProfileFixtures, } from "../infra/exec-safe-bin-runtime-policy.js"; +import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; import { collectAttackSurfaceSummaryFindings, @@ -24,6 +26,7 @@ import { collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, + collectLikelyMultiUserSetupFindings, collectSandboxBrowserHashLabelFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, @@ -747,8 +750,77 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] ), ).toSorted(); }; - const interpreterHits: string[] = []; + const normalizeConfiguredTrustedDirs = (entries: unknown): string[] => { + if (!Array.isArray(entries)) { + return []; + } + return normalizeTrustedSafeBinDirs( + entries.filter((entry): entry is string => typeof entry === "string"), + ); + }; + const classifyRiskySafeBinTrustedDir = (entry: string): string | null => { + const raw = entry.trim(); + if (!raw) { + return null; + } + if (!path.isAbsolute(raw)) { + return "relative path (trust boundary depends on process cwd)"; + } + const normalized = path.resolve(raw).replace(/\\/g, "/").toLowerCase(); + if ( + normalized === "/tmp" || + normalized.startsWith("/tmp/") || + normalized === "/var/tmp" || + normalized.startsWith("/var/tmp/") || + normalized === "/private/tmp" || + normalized.startsWith("/private/tmp/") + ) { + return "temporary directory is mutable and easy to poison"; + } + if ( + normalized === "/usr/local/bin" || + normalized === "/opt/homebrew/bin" || + normalized === "/opt/local/bin" || + normalized === "/home/linuxbrew/.linuxbrew/bin" + ) { + return "package-manager bin directory (often user-writable)"; + } + if ( + normalized.startsWith("/users/") || + normalized.startsWith("/home/") || + normalized.includes("/.local/bin") + ) { + return "home-scoped bin directory (typically user-writable)"; + } + if (/^[a-z]:\/users\//.test(normalized)) { + return "home-scoped bin directory (typically user-writable)"; + } + return null; + }; + const globalExec = cfg.tools?.exec; + const riskyTrustedDirHits: string[] = []; + const collectRiskyTrustedDirHits = (scopePath: string, entries: unknown): void => { + for (const entry of normalizeConfiguredTrustedDirs(entries)) { + const reason = classifyRiskySafeBinTrustedDir(entry); + if (!reason) { + continue; + } + riskyTrustedDirHits.push(`- ${scopePath}.safeBinTrustedDirs: ${entry} (${reason})`); + } + }; + collectRiskyTrustedDirHits("tools.exec", globalExec?.safeBinTrustedDirs); + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + collectRiskyTrustedDirHits( + `agents.list.${entry.id}.tools.exec`, + entry.tools?.exec?.safeBinTrustedDirs, + ); + } + + const interpreterHits: string[] = []; const globalSafeBins = normalizeConfiguredSafeBins(globalExec?.safeBins); if (globalSafeBins.length > 0) { const merged = resolveMergedSafeBinProfileFixtures({ global: globalExec }) ?? {}; @@ -794,6 +866,21 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] }); } + if (riskyTrustedDirHits.length > 0) { + findings.push({ + checkId: "tools.exec.safe_bin_trusted_dirs_risky", + severity: "warn", + title: "safeBinTrustedDirs includes risky mutable directories", + detail: + `Detected risky safeBinTrustedDirs entries:\n${riskyTrustedDirHits.slice(0, 10).join("\n")}` + + (riskyTrustedDirHits.length > 10 + ? `\n- +${riskyTrustedDirHits.length - 10} more entries.` + : ""), + remediation: + "Prefer root-owned immutable bins, keep default trust dirs (/bin, /usr/bin), and avoid trusting temporary/home/package-manager paths unless tightly controlled.", + }); + } + return findings; } @@ -866,6 +953,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { expect(isExternalHookSession("hook:custom:456")).toBe(true); }); + it("identifies mixed-case hook prefixes", () => { + expect(isExternalHookSession("HOOK:gmail:msg-123")).toBe(true); + expect(isExternalHookSession("Hook:custom:456")).toBe(true); + expect(isExternalHookSession(" HOOK:webhook:123 ")).toBe(true); + }); + it("rejects non-hook sessions", () => { expect(isExternalHookSession("cron:daily-task")).toBe(false); expect(isExternalHookSession("agent:main")).toBe(false); @@ -266,6 +272,12 @@ describe("external-content security", () => { expect(getHookType("hook:custom:456")).toBe("webhook"); }); + it("returns hook type for mixed-case hook prefixes", () => { + expect(getHookType("HOOK:gmail:msg-123")).toBe("email"); + expect(getHookType(" HOOK:webhook:123 ")).toBe("webhook"); + expect(getHookType("Hook:custom:456")).toBe("webhook"); + }); + it("returns unknown for non-hook sessions", () => { expect(getHookType("cron:daily")).toBe("unknown"); }); diff --git a/src/security/external-content.ts b/src/security/external-content.ts index 49629db9aef0..e1fd9335d7da 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -286,10 +286,11 @@ export function buildSafeExternalPrompt(params: { * Checks if a session key indicates an external hook source. */ export function isExternalHookSession(sessionKey: string): boolean { + const normalized = sessionKey.trim().toLowerCase(); return ( - sessionKey.startsWith("hook:gmail:") || - sessionKey.startsWith("hook:webhook:") || - sessionKey.startsWith("hook:") // Generic hook prefix + normalized.startsWith("hook:gmail:") || + normalized.startsWith("hook:webhook:") || + normalized.startsWith("hook:") // Generic hook prefix ); } @@ -297,13 +298,14 @@ export function isExternalHookSession(sessionKey: string): boolean { * Extracts the hook type from a session key. */ export function getHookType(sessionKey: string): ExternalContentSource { - if (sessionKey.startsWith("hook:gmail:")) { + const normalized = sessionKey.trim().toLowerCase(); + if (normalized.startsWith("hook:gmail:")) { return "email"; } - if (sessionKey.startsWith("hook:webhook:")) { + if (normalized.startsWith("hook:webhook:")) { return "webhook"; } - if (sessionKey.startsWith("hook:")) { + if (normalized.startsWith("hook:")) { return "webhook"; } return "unknown"; diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 21770a20e29b..2342bdedafe0 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -29,6 +29,9 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "uniqueLocal", ]); const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; +export type Ipv4SpecialUseBlockOptions = { + allowRfc2544BenchmarkRange?: boolean; +}; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ matches: (parts: number[]) => boolean; @@ -247,10 +250,15 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { return parsed.range() === "carrierGradeNat"; } -export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - return ( - BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || address.match(RFC2544_BENCHMARK_PREFIX) - ); +export function isBlockedSpecialUseIpv4Address( + address: ipaddr.IPv4, + options: Ipv4SpecialUseBlockOptions = {}, +): boolean { + const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); + if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { + return false; + } + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 8454de9d5259..b095626ab460 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -222,6 +222,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { await deps.deliverReplies({ replies: [payload], @@ -237,7 +238,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onError: (err, info) => { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: typingCallbacks.onReplyStart, }); const { queuedFinal } = await dispatchInboundMessage({ diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index ecf049749370..f43f77a0d763 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -38,15 +38,20 @@ export function normalizeSlackChannelType( channelId?: string | null, ): SlackMessageEvent["channel_type"] { const normalized = channelType?.trim().toLowerCase(); + const inferred = inferSlackChannelType(channelId); if ( normalized === "im" || normalized === "mpim" || normalized === "channel" || normalized === "group" ) { + // D-prefix channel IDs are always DMs — override a contradicting channel_type. + if (inferred === "im" && normalized !== "im") { + return "im"; + } return normalized; } - return inferSlackChannelType(channelId) ?? "channel"; + return inferred ?? "channel"; } export type SlackMonitorContext = { diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 964aec1107a5..169e5571da0d 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -131,7 +131,7 @@ export type SlackMediaResult = { placeholder: string; }; -const MAX_SLACK_MEDIA_FILES = 8; +export const MAX_SLACK_MEDIA_FILES = 8; const MAX_SLACK_MEDIA_CONCURRENCY = 3; const MAX_SLACK_FORWARDED_ATTACHMENTS = 8; diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index d726f804c106..35db7c2f70ea 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -243,6 +243,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, deliver: async (payload) => { if (useStreaming) { await deliverWithStreaming(payload); @@ -304,8 +305,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`)); typingCallbacks.onIdle?.(); }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, }); const draftStream = createSlackDraftStream({ diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 07e6b8345015..548e2b0b4715 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -222,6 +222,35 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeNull(); }); + it("delivers file-only message with placeholder when media download fails", async () => { + // Files without url_private will fail to download, simulating a download + // failure. The message should still be delivered with a fallback + // placeholder instead of being silently dropped (#25064). + const prepared = await prepareWithDefaultCtx( + createSlackMessage({ + text: "", + files: [{ name: "voice.ogg" }, { name: "photo.jpg" }], + }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.RawBody).toContain("[Slack file:"); + expect(prepared!.ctxPayload.RawBody).toContain("voice.ogg"); + expect(prepared!.ctxPayload.RawBody).toContain("photo.jpg"); + }); + + it("falls back to generic file label when a Slack file name is empty", async () => { + const prepared = await prepareWithDefaultCtx( + createSlackMessage({ + text: "", + files: [{ name: "" }], + }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.RawBody).toContain("[Slack file: file]"); + }); + it("keeps channel metadata out of GroupSystemPrompt", async () => { const slackCtx = createInboundSlackCtx({ cfg: { @@ -264,6 +293,166 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(untrusted).toContain("Do dangerous things"); }); + it("classifies D-prefix DMs correctly even when channel_type is wrong", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + session: { dmScope: "main" }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + allowNameMatching: false, + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + // Simulate API returning correct type for DM channel + slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const }); + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + // Bug scenario: D-prefix channel but Slack event says channel_type: "channel" + const message: SlackMessageEvent = { + channel: "D0ACP6B1T8V", + channel_type: "channel", + user: "U1", + text: "hello from DM", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // oxlint-disable-next-line typescript/no-explicit-any + expectInboundContextContract(prepared!.ctxPayload as any); + // Should be classified as DM, not channel + expect(prepared!.isDirectMessage).toBe(true); + // DM with dmScope: "main" should route to the main session + expect(prepared!.route.sessionKey).toBe("agent:main:main"); + // ChatType should be "direct", not "channel" + expect(prepared!.ctxPayload.ChatType).toBe("direct"); + // From should use user ID (DM pattern), not channel ID + expect(prepared!.ctxPayload.From).toContain("slack:U1"); + }); + + it("classifies D-prefix DMs when channel_type is missing", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + session: { dmScope: "main" }, + } as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + allowNameMatching: false, + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + // Simulate API returning correct type for DM channel + slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const }); + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + // channel_type missing — should infer from D-prefix + const message: SlackMessageEvent = { + channel: "D0ACP6B1T8V", + user: "U1", + text: "hello from DM", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + // oxlint-disable-next-line typescript/no-explicit-any + expectInboundContextContract(prepared!.ctxPayload as any); + expect(prepared!.isDirectMessage).toBe(true); + expect(prepared!.route.sessionKey).toBe("agent:main:main"); + expect(prepared!.ctxPayload.ChatType).toBe("direct"); + }); + it("sets MessageThreadId for top-level messages when replyToMode=all", async () => { const slackCtx = createInboundSlackCtx({ cfg: { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 39515ad621d9..6a0121d996ed 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,6 +44,7 @@ import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; import { resolveSlackAttachmentContent, + MAX_SLACK_MEDIA_FILES, resolveSlackMedia, resolveSlackThreadHistory, resolveSlackThreadStarter, @@ -362,8 +363,21 @@ export async function prepareSlackMessage(params: { const mediaPlaceholder = effectiveDirectMedia ? effectiveDirectMedia.map((m) => m.placeholder).join(" ") : undefined; + + // When files were attached but all downloads failed, create a fallback + // placeholder so the message is still delivered to the agent instead of + // being silently dropped (#25064). + const fileOnlyFallback = + !mediaPlaceholder && (message.files?.length ?? 0) > 0 + ? message + .files!.slice(0, MAX_SLACK_MEDIA_FILES) + .map((f) => f.name?.trim() || "file") + .join(", ") + : undefined; + const fileOnlyPlaceholder = fileOnlyFallback ? `[Slack file: ${fileOnlyFallback}]` : undefined; + const rawBody = - [(message.text ?? "").trim(), attachmentContent?.text, mediaPlaceholder] + [(message.text ?? "").trim(), attachmentContent?.text, mediaPlaceholder, fileOnlyPlaceholder] .filter(Boolean) .join("\n") || ""; if (!rawBody) { diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 2a6072d93dd8..3262873718da 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -134,6 +134,25 @@ describe("normalizeSlackChannelType", () => { it("prefers explicit channel_type values", () => { expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); }); + + it("overrides wrong channel_type for D-prefix DM channels", () => { + // Slack DM channel IDs always start with "D" — if the event + // reports a wrong channel_type, the D-prefix should win. + expect(normalizeSlackChannelType("channel", "D123")).toBe("im"); + expect(normalizeSlackChannelType("group", "D456")).toBe("im"); + expect(normalizeSlackChannelType("mpim", "D789")).toBe("im"); + }); + + it("preserves correct channel_type for D-prefix DM channels", () => { + expect(normalizeSlackChannelType("im", "D123")).toBe("im"); + }); + + it("does not override G-prefix channel_type (ambiguous prefix)", () => { + // G-prefix can be either "group" (private channel) or "mpim" (group DM) + // — trust the provided channel_type since the prefix is ambiguous. + expect(normalizeSlackChannelType("group", "G123")).toBe("group"); + expect(normalizeSlackChannelType("mpim", "G456")).toBe("mpim"); + }); }); describe("resolveSlackSystemEventSessionKey", () => { diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 5d3cfc30b4a7..e4d42cd889e5 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -45,6 +45,7 @@ import { resolveTelegramGroupAllowFromContext, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -71,6 +72,14 @@ function isRecoverableMediaGroupError(err: unknown): boolean { return err instanceof MediaFetchError || isMediaSizeLimitError(err); } +function hasInboundMedia(msg: Message): boolean { + return ( + Boolean(msg.media_group_id) || + (Array.isArray(msg.photo) && msg.photo.length > 0) || + Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker) + ); +} + export const registerTelegramHandlers = ({ cfg, accountId, @@ -79,6 +88,7 @@ export const registerTelegramHandlers = ({ runtime, mediaMaxBytes, telegramCfg, + allowFrom, groupAllowFrom, resolveGroupPolicy, resolveTelegramGroupConfig, @@ -1141,11 +1151,12 @@ export const registerTelegramHandlers = ({ if (shouldSkipUpdate(event.ctxForDedupe)) { return; } + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId: event.chatId, accountId, - dmPolicy: telegramCfg.dmPolicy ?? "pairing", + dmPolicy, isForum: event.isForum, messageThreadId: event.messageThreadId, groupAllowFrom, @@ -1159,6 +1170,11 @@ export const registerTelegramHandlers = ({ effectiveGroupAllow, hasGroupAllowOverride, } = groupAllowContext; + const effectiveDmAllow = normalizeAllowFromWithStore({ + allowFrom, + storeAllowFrom, + dmPolicy, + }); if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) { logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`); @@ -1182,6 +1198,22 @@ export const registerTelegramHandlers = ({ return; } + if (!event.isGroup && hasInboundMedia(event.msg)) { + const dmAuthorized = await enforceTelegramDmAccess({ + isGroup: event.isGroup, + dmPolicy, + msg: event.msg, + chatId: event.chatId, + effectiveDmAllow, + accountId, + bot, + logger, + }); + if (!dmAuthorized) { + return; + } + } + await processInboundMessage({ ctx: event.ctx, msg: event.msg, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index b3fa5b9f60f1..3ea805c944d5 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -33,17 +33,10 @@ import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; -import { buildPairingReply } from "../pairing/pairing-messages.js"; -import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { - firstDefined, - isSenderAllowed, - normalizeAllowFromWithStore, - resolveSenderAllowMatch, -} from "./bot-access.js"; +import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { buildGroupLabel, buildSenderLabel, @@ -61,6 +54,7 @@ import { resolveTelegramThreadSpec, } from "./bot/helpers.js"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; import { @@ -159,11 +153,6 @@ export const buildTelegramMessageContext = async ({ resolveTelegramGroupConfig, }: BuildTelegramMessageContextParams) => { const msg = primaryCtx.message; - recordChannelActivity({ - channel: "telegram", - accountId: account.accountId, - direction: "inbound", - }); const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; @@ -268,87 +257,27 @@ export const buildTelegramMessageContext = async ({ } }; - // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" - if (!isGroup) { - if (dmPolicy === "disabled") { - return null; - } - - if (dmPolicy !== "open") { - const senderUsername = msg.from?.username ?? ""; - const senderUserId = msg.from?.id != null ? String(msg.from.id) : null; - const candidate = senderUserId ?? String(chatId); - const allowMatch = resolveSenderAllowMatch({ - allow: effectiveDmAllow, - senderId: candidate, - senderUsername, - }); - const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ - allowMatch.matchSource ?? "none" - }`; - const allowed = - effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); - if (!allowed) { - if (dmPolicy === "pairing") { - try { - const from = msg.from as - | { - first_name?: string; - last_name?: string; - username?: string; - id?: number; - } - | undefined; - const telegramUserId = from?.id ? String(from.id) : candidate; - const { code, created } = await upsertChannelPairingRequest({ - channel: "telegram", - id: telegramUserId, - accountId: account.accountId, - meta: { - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, - }, - }); - if (created) { - logger.info( - { - chatId: String(chatId), - senderUserId: senderUserId ?? undefined, - username: from?.username, - firstName: from?.first_name, - lastName: from?.last_name, - matchKey: allowMatch.matchKey ?? "none", - matchSource: allowMatch.matchSource ?? "none", - }, - "telegram pairing request", - ); - await withTelegramApiErrorLogging({ - operation: "sendMessage", - fn: () => - bot.api.sendMessage( - chatId, - buildPairingReply({ - channel: "telegram", - idLine: `Your Telegram user id: ${telegramUserId}`, - code, - }), - ), - }); - } - } catch (err) { - logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); - } - } else { - logVerbose( - `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return null; - } - } + if ( + !(await enforceTelegramDmAccess({ + isGroup, + dmPolicy, + msg, + chatId, + effectiveDmAllow, + accountId: account.accountId, + bot, + logger, + })) + ) { + return null; } + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "inbound", + }); + const botUsername = primaryCtx.me?.username?.toLowerCase(); const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; const senderAllowedForCommands = isSenderAllowed({ diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 7dd0c48450ac..f45b79fb9abd 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -418,12 +418,25 @@ export const dispatchTelegramMessage = async ({ void statusReactionController.setThinking(); } + const typingCallbacks = createTypingCallbacks({ + start: sendTyping, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(chatId), + error: err, + }); + }, + }); + try { ({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { ...prefixOptions, + typingCallbacks, deliver: async (payload, info) => { const previewButtons = ( payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined @@ -528,17 +541,6 @@ export const dispatchTelegramMessage = async ({ deliveryState.markNonSilentFailure(); runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: createTypingCallbacks({ - start: sendTyping, - onStartError: (err) => { - logTypingFailure({ - log: logVerbose, - channel: "telegram", - target: String(chatId), - error: err, - }); - }, - }).onReplyStart, }, replyOptions: { skillFilter, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index adf413fbfb9a..88316cbeb82b 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -91,6 +91,7 @@ export type RegisterTelegramHandlerParams = { opts: TelegramBotOptions; runtime: RuntimeEnv; telegramCfg: TelegramAccountConfig; + allowFrom?: Array; groupAllowFrom?: Array; resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; resolveTelegramGroupConfig: ( diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index fdd2eb32ecc4..942a1c6c2b3f 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -183,7 +183,22 @@ describe("createTelegramBot", () => { getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), - ).toBe("telegram:123"); + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }), + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }), + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }), + }), + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }), @@ -194,6 +209,11 @@ describe("createTelegramBot", () => { message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }), }), ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }), + }), + ).toBe("telegram:123"); }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); @@ -319,6 +339,133 @@ describe("createTelegramBot", () => { } } }); + it("blocks unauthorized DM media before download and sends pairing reply", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 410, + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks DM media downloads completely when dmPolicy is disabled", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "disabled" } }, + }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 411, + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks unauthorized DM media groups before any photo download", async () => { + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true }); + sendMessageSpy.mockClear(); + replySpy.mockClear(); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" })); + + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + message_id: 412, + media_group_id: "dm-album-1", + date: 1736380800, + photo: [{ file_id: "p1" }], + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: getFileSpy, + }); + + expect(getFileSpy).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:"); + expect(replySpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); it("triggers typing cue via onReplyStart", async () => { createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; @@ -666,6 +813,29 @@ describe("createTelegramBot", () => { }, expectedReplyCount: 1, }, + { + name: "blocks group messages when per-group allowFrom override is explicitly empty", + config: { + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-100123456789": { + allowFrom: [], + requireMention: false, + }, + }, + }, + }, + }, + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, + text: "hello", + date: 1736380800, + }, + expectedReplyCount: 0, + }, { name: "allows all group messages when groupPolicy is 'open'", config: { diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 8f34fcdeb2bd..2c02d69d33f6 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -1,111 +1,12 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; -import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; - -const cacheStickerSpy = vi.fn(); -const getCachedStickerSpy = vi.fn(); -const describeStickerImageSpy = vi.fn(); -const resolvePinnedHostname = ssrf.resolvePinnedHostname; -const lookupMock = vi.fn(); -let resolvePinnedHostnameSpy: ReturnType = null; -const TELEGRAM_TEST_TIMINGS = { - mediaGroupFlushMs: 20, - textFragmentGapMs: 30, -} as const; -const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 150_000; -let createTelegramBot: typeof import("./bot.js").createTelegramBot; -let replySpy: ReturnType; - -async function createBotHandler(): Promise<{ - handler: (ctx: Record) => Promise; - replySpy: ReturnType; - runtimeError: ReturnType; -}> { - return createBotHandlerWithOptions({}); -} - -async function createBotHandlerWithOptions(options: { - proxyFetch?: typeof fetch; - runtimeLog?: ReturnType; - runtimeError?: ReturnType; -}): Promise<{ - handler: (ctx: Record) => Promise; - replySpy: ReturnType; - runtimeError: ReturnType; -}> { - onSpy.mockClear(); - replySpy.mockClear(); - sendChatActionSpy.mockClear(); - - const runtimeError = options.runtimeError ?? vi.fn(); - const runtimeLog = options.runtimeLog ?? vi.fn(); - createTelegramBot({ - token: "tok", - testTimings: TELEGRAM_TEST_TIMINGS, - ...(options.proxyFetch ? { proxyFetch: options.proxyFetch } : {}), - runtime: { - log: runtimeLog as (...data: unknown[]) => void, - error: runtimeError as (...data: unknown[]) => void, - exit: () => { - throw new Error("exit"); - }, - }, - }); - const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( - ctx: Record, - ) => Promise; - expect(handler).toBeDefined(); - return { handler, replySpy, runtimeError }; -} - -function mockTelegramFileDownload(params: { - contentType: string; - bytes: Uint8Array; -}): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => params.contentType }, - arrayBuffer: async () => params.bytes.buffer, - } as unknown as Response); -} - -function mockTelegramPngDownload(): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/png" }, - arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, - } as unknown as Response); -} - -beforeEach(() => { - vi.useRealTimers(); - lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); - resolvePinnedHostnameSpy = vi - .spyOn(ssrf, "resolvePinnedHostname") - .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); -}); - -afterEach(() => { - lookupMock.mockClear(); - resolvePinnedHostnameSpy?.mockRestore(); - resolvePinnedHostnameSpy = null; -}); - -beforeAll(async () => { - ({ createTelegramBot } = await import("./bot.js")); - const replyModule = await import("../auto-reply/reply.js"); - replySpy = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; -}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); - -vi.mock("./sticker-cache.js", () => ({ - cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), - getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), - describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), -})); +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setNextSavedMediaPath } from "./bot.media.e2e-harness.js"; +import { + TELEGRAM_TEST_TIMINGS, + createBotHandler, + createBotHandlerWithOptions, + mockTelegramFileDownload, + mockTelegramPngDownload, +} from "./bot.media.test-utils.js"; describe("telegram inbound media", () => { // Parallel vitest shards can make this suite slower than the standalone run. @@ -184,6 +85,46 @@ describe("telegram inbound media", () => { INBOUND_MEDIA_TEST_TIMEOUT_MS, ); + it( + "keeps Telegram inbound media paths with triple-dash ids", + async () => { + const runtimeError = vi.fn(); + const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/jpeg", + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), + }); + const inboundPath = "/tmp/media/inbound/file_1095---f00a04a2-99a0-4d98-99b0-dfe61c5a4198.jpg"; + setNextSavedMediaPath({ + path: inboundPath, + size: 4, + contentType: "image/jpeg", + }); + + try { + await handler({ + message: { + message_id: 1001, + chat: { id: 1234, type: "private" }, + photo: [{ file_id: "fid" }], + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "photos/1.jpg" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0]?.[0] as { Body?: string; MediaPaths?: string[] }; + expect(payload.Body).toContain(""); + expect(payload.MediaPaths).toContain(inboundPath); + } finally { + fetchSpy.mockRestore(); + } + }, + INBOUND_MEDIA_TEST_TIMEOUT_MS, + ); + it("prefers proxyFetch over global fetch", async () => { const runtimeLog = vi.fn(); const runtimeError = vi.fn(); @@ -370,7 +311,7 @@ describe("telegram media groups", () => { () => { expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount); }, - { timeout: MEDIA_GROUP_FLUSH_MS * 2, interval: 2 }, + { timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 }, ); expect(runtimeError).not.toHaveBeenCalled(); @@ -442,245 +383,3 @@ describe("telegram forwarded bursts", () => { FORWARD_BURST_TEST_TIMEOUT_MS, ); }); - -describe("telegram stickers", () => { - const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; - - beforeEach(() => { - cacheStickerSpy.mockClear(); - getCachedStickerSpy.mockClear(); - describeStickerImageSpy.mockClear(); - // Re-seed defaults so per-test overrides do not leak when using mockClear. - getCachedStickerSpy.mockReturnValue(undefined); - describeStickerImageSpy.mockReturnValue(undefined); - }); - - it( - "downloads static sticker (WEBP) and includes sticker metadata", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - const fetchSpy = mockTelegramFileDownload({ - contentType: "image/webp", - bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF header - }); - - await handler({ - message: { - message_id: 100, - chat: { id: 1234, type: "private" }, - sticker: { - file_id: "sticker_file_id_123", - file_unique_id: "sticker_unique_123", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: false, - emoji: "🎉", - set_name: "TestStickerPack", - }, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "stickers/sticker.webp" }), - }); - - expect(runtimeError).not.toHaveBeenCalled(); - expect(fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/stickers/sticker.webp", - expect.objectContaining({ redirect: "manual" }), - ); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain(""); - expect(payload.Sticker?.emoji).toBe("🎉"); - expect(payload.Sticker?.setName).toBe("TestStickerPack"); - expect(payload.Sticker?.fileId).toBe("sticker_file_id_123"); - - fetchSpy.mockRestore(); - }, - STICKER_TEST_TIMEOUT_MS, - ); - - it( - "refreshes cached sticker metadata on cache hit", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - - getCachedStickerSpy.mockReturnValue({ - fileId: "old_file_id", - fileUniqueId: "sticker_unique_456", - emoji: "😴", - setName: "OldSet", - description: "Cached description", - cachedAt: "2026-01-20T10:00:00.000Z", - }); - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/webp" }, - arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, - } as unknown as Response); - - await handler({ - message: { - message_id: 103, - chat: { id: 1234, type: "private" }, - sticker: { - file_id: "new_file_id", - file_unique_id: "sticker_unique_456", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: false, - emoji: "🔥", - set_name: "NewSet", - }, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "stickers/sticker.webp" }), - }); - - expect(runtimeError).not.toHaveBeenCalled(); - expect(cacheStickerSpy).toHaveBeenCalledWith( - expect.objectContaining({ - fileId: "new_file_id", - emoji: "🔥", - setName: "NewSet", - }), - ); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Sticker?.fileId).toBe("new_file_id"); - expect(payload.Sticker?.cachedDescription).toBe("Cached description"); - - fetchSpy.mockRestore(); - }, - STICKER_TEST_TIMEOUT_MS, - ); - - it( - "skips animated and video sticker formats that cannot be downloaded", - async () => { - const { handler, replySpy, runtimeError } = await createBotHandler(); - - for (const scenario of [ - { - messageId: 101, - filePath: "stickers/animated.tgs", - sticker: { - file_id: "animated_sticker_id", - file_unique_id: "animated_unique", - type: "regular", - width: 512, - height: 512, - is_animated: true, - is_video: false, - emoji: "😎", - set_name: "AnimatedPack", - }, - }, - { - messageId: 102, - filePath: "stickers/video.webm", - sticker: { - file_id: "video_sticker_id", - file_unique_id: "video_unique", - type: "regular", - width: 512, - height: 512, - is_animated: false, - is_video: true, - emoji: "🎬", - set_name: "VideoPack", - }, - }, - ]) { - replySpy.mockClear(); - runtimeError.mockClear(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - await handler({ - message: { - message_id: scenario.messageId, - chat: { id: 1234, type: "private" }, - sticker: scenario.sticker, - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: scenario.filePath }), - }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(replySpy).not.toHaveBeenCalled(); - expect(runtimeError).not.toHaveBeenCalled(); - fetchSpy.mockRestore(); - } - }, - STICKER_TEST_TIMEOUT_MS, - ); -}); - -describe("telegram text fragments", () => { - afterEach(() => { - vi.clearAllTimers(); - }); - - const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; - const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80; - - it( - "buffers near-limit text and processes sequential parts as one message", - async () => { - onSpy.mockClear(); - replySpy.mockClear(); - vi.useFakeTimers(); - try { - createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); - const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( - ctx: Record, - ) => Promise; - expect(handler).toBeDefined(); - - const part1 = "A".repeat(4050); - const part2 = "B".repeat(50); - - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 10, - date: 1736380800, - text: part1, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 11, - date: 1736380801, - text: part2, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - expect(replySpy).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); - expect(replySpy).toHaveBeenCalledTimes(1); - - const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string }; - expect(payload.RawBody).toContain(part1.slice(0, 32)); - expect(payload.RawBody).toContain(part2.slice(0, 32)); - } finally { - vi.useRealTimers(); - } - }, - TEXT_FRAGMENT_TEST_TIMEOUT_MS, - ); -}); diff --git a/src/telegram/bot.media.e2e-harness.ts b/src/telegram/bot.media.e2e-harness.ts index 7fff9e1e2745..fec64cbdbf0c 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/src/telegram/bot.media.e2e-harness.ts @@ -7,6 +7,38 @@ export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); +async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { + return { + id: "media", + path: "/tmp/telegram-media", + size: buffer.byteLength, + contentType: contentType ?? "application/octet-stream", + }; +} + +const saveMediaBufferSpy: Mock = vi.fn(defaultSaveMediaBuffer); + +export function setNextSavedMediaPath(params: { + path: string; + id?: string; + contentType?: string; + size?: number; +}) { + saveMediaBufferSpy.mockImplementationOnce( + async (buffer: Buffer, detectedContentType?: string) => ({ + id: params.id ?? "media", + path: params.path, + size: params.size ?? buffer.byteLength, + contentType: params.contentType ?? detectedContentType ?? "application/octet-stream", + }), + ); +} + +export function resetSaveMediaBufferMock() { + saveMediaBufferSpy.mockReset(); + saveMediaBufferSpy.mockImplementation(defaultSaveMediaBuffer); +} + type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: Mock; @@ -23,6 +55,7 @@ const apiStub: ApiStub = { beforeEach(() => { resetInboundDedupe(); + resetSaveMediaBufferMock(); }); vi.mock("grammy", () => ({ @@ -52,12 +85,8 @@ vi.mock("../media/store.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - saveMediaBuffer: vi.fn(async (buffer: Buffer, contentType?: string) => ({ - id: "media", - path: "/tmp/telegram-media", - size: buffer.byteLength, - contentType: contentType ?? "application/octet-stream", - })), + saveMediaBuffer: (...args: Parameters) => + saveMediaBufferSpy(...args), }; }); diff --git a/src/telegram/bot.media.stickers-and-fragments.test.ts b/src/telegram/bot.media.stickers-and-fragments.test.ts new file mode 100644 index 000000000000..fc1b372f778c --- /dev/null +++ b/src/telegram/bot.media.stickers-and-fragments.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TELEGRAM_TEST_TIMINGS, + cacheStickerSpy, + createBotHandler, + createBotHandlerWithOptions, + describeStickerImageSpy, + getCachedStickerSpy, + mockTelegramFileDownload, +} from "./bot.media.test-utils.js"; + +describe("telegram stickers", () => { + const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; + + beforeEach(() => { + cacheStickerSpy.mockClear(); + getCachedStickerSpy.mockClear(); + describeStickerImageSpy.mockClear(); + // Re-seed defaults so per-test overrides do not leak when using mockClear. + getCachedStickerSpy.mockReturnValue(undefined); + describeStickerImageSpy.mockReturnValue(undefined); + }); + + it( + "downloads static sticker (WEBP) and includes sticker metadata", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/webp", + bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), // RIFF header + }); + + await handler({ + message: { + message_id: 100, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "sticker_file_id_123", + file_unique_id: "sticker_unique_123", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + emoji: "🎉", + set_name: "TestStickerPack", + }, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "stickers/sticker.webp" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.telegram.org/file/bottok/stickers/sticker.webp", + expect.objectContaining({ redirect: "manual" }), + ); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain(""); + expect(payload.Sticker?.emoji).toBe("🎉"); + expect(payload.Sticker?.setName).toBe("TestStickerPack"); + expect(payload.Sticker?.fileId).toBe("sticker_file_id_123"); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "refreshes cached sticker metadata on cache hit", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + + getCachedStickerSpy.mockReturnValue({ + fileId: "old_file_id", + fileUniqueId: "sticker_unique_456", + emoji: "😴", + setName: "OldSet", + description: "Cached description", + cachedAt: "2026-01-20T10:00:00.000Z", + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/webp" }, + arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, + } as unknown as Response); + + await handler({ + message: { + message_id: 103, + chat: { id: 1234, type: "private" }, + sticker: { + file_id: "new_file_id", + file_unique_id: "sticker_unique_456", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: false, + emoji: "🔥", + set_name: "NewSet", + }, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "stickers/sticker.webp" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(cacheStickerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: "new_file_id", + emoji: "🔥", + setName: "NewSet", + }), + ); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Sticker?.fileId).toBe("new_file_id"); + expect(payload.Sticker?.cachedDescription).toBe("Cached description"); + + fetchSpy.mockRestore(); + }, + STICKER_TEST_TIMEOUT_MS, + ); + + it( + "skips animated and video sticker formats that cannot be downloaded", + async () => { + const { handler, replySpy, runtimeError } = await createBotHandler(); + + for (const scenario of [ + { + messageId: 101, + filePath: "stickers/animated.tgs", + sticker: { + file_id: "animated_sticker_id", + file_unique_id: "animated_unique", + type: "regular", + width: 512, + height: 512, + is_animated: true, + is_video: false, + emoji: "😎", + set_name: "AnimatedPack", + }, + }, + { + messageId: 102, + filePath: "stickers/video.webm", + sticker: { + file_id: "video_sticker_id", + file_unique_id: "video_unique", + type: "regular", + width: 512, + height: 512, + is_animated: false, + is_video: true, + emoji: "🎬", + set_name: "VideoPack", + }, + }, + ]) { + replySpy.mockClear(); + runtimeError.mockClear(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await handler({ + message: { + message_id: scenario.messageId, + chat: { id: 1234, type: "private" }, + sticker: scenario.sticker, + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: scenario.filePath }), + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(runtimeError).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + } + }, + STICKER_TEST_TIMEOUT_MS, + ); +}); + +describe("telegram text fragments", () => { + afterEach(() => { + vi.clearAllTimers(); + }); + + const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; + const TEXT_FRAGMENT_FLUSH_MS = TELEGRAM_TEST_TIMINGS.textFragmentGapMs + 80; + + it( + "buffers near-limit text and processes sequential parts as one message", + async () => { + const { handler, replySpy } = await createBotHandlerWithOptions({}); + vi.useFakeTimers(); + try { + const part1 = "A".repeat(4050); + const part2 = "B".repeat(50); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 10, + date: 1736380800, + text: part1, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 11, + date: 1736380801, + text: part2, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); + expect(replySpy).toHaveBeenCalledTimes(1); + + const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; + expect(payload.RawBody).toContain(part1.slice(0, 32)); + expect(payload.RawBody).toContain(part2.slice(0, 32)); + } finally { + vi.useRealTimers(); + } + }, + TEXT_FRAGMENT_TEST_TIMEOUT_MS, + ); +}); diff --git a/src/telegram/bot.media.test-utils.ts b/src/telegram/bot.media.test-utils.ts new file mode 100644 index 000000000000..94084bad31c4 --- /dev/null +++ b/src/telegram/bot.media.test-utils.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; +import * as ssrf from "../infra/net/ssrf.js"; +import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; + +type StickerSpy = Mock<(...args: unknown[]) => unknown>; + +export const cacheStickerSpy: StickerSpy = vi.fn(); +export const getCachedStickerSpy: StickerSpy = vi.fn(); +export const describeStickerImageSpy: StickerSpy = vi.fn(); + +const resolvePinnedHostname = ssrf.resolvePinnedHostname; +const lookupMock = vi.fn(); +let resolvePinnedHostnameSpy: ReturnType = null; + +export const TELEGRAM_TEST_TIMINGS = { + mediaGroupFlushMs: 20, + textFragmentGapMs: 30, +} as const; + +const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 150_000; + +let createTelegramBotRef: typeof import("./bot.js").createTelegramBot; +let replySpyRef: ReturnType; + +export async function createBotHandler(): Promise<{ + handler: (ctx: Record) => Promise; + replySpy: ReturnType; + runtimeError: ReturnType; +}> { + return createBotHandlerWithOptions({}); +} + +export async function createBotHandlerWithOptions(options: { + proxyFetch?: typeof fetch; + runtimeLog?: ReturnType; + runtimeError?: ReturnType; +}): Promise<{ + handler: (ctx: Record) => Promise; + replySpy: ReturnType; + runtimeError: ReturnType; +}> { + onSpy.mockClear(); + replySpyRef.mockClear(); + sendChatActionSpy.mockClear(); + + const runtimeError = options.runtimeError ?? vi.fn(); + const runtimeLog = options.runtimeLog ?? vi.fn(); + createTelegramBotRef({ + token: "tok", + testTimings: TELEGRAM_TEST_TIMINGS, + ...(options.proxyFetch ? { proxyFetch: options.proxyFetch } : {}), + runtime: { + log: runtimeLog as (...data: unknown[]) => void, + error: runtimeError as (...data: unknown[]) => void, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( + ctx: Record, + ) => Promise; + expect(handler).toBeDefined(); + return { handler, replySpy: replySpyRef, runtimeError }; +} + +export function mockTelegramFileDownload(params: { + contentType: string; + bytes: Uint8Array; +}): ReturnType { + return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => params.contentType }, + arrayBuffer: async () => params.bytes.buffer, + } as unknown as Response); +} + +export function mockTelegramPngDownload(): ReturnType { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as unknown as Response); +} + +beforeEach(() => { + vi.useRealTimers(); + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock)); +}); + +afterEach(() => { + lookupMock.mockClear(); + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameSpy = null; +}); + +beforeAll(async () => { + ({ createTelegramBot: createTelegramBotRef } = await import("./bot.js")); + const replyModule = await import("../auto-reply/reply.js"); + replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; +}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); + +vi.mock("./sticker-cache.js", () => ({ + cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), + getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), + describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), +})); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 438ed1c9bb80..409815fa3ae5 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -398,6 +398,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { runtime, mediaMaxBytes, telegramCfg, + allowFrom, groupAllowFrom, resolveGroupPolicy, resolveTelegramGroupConfig, diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2c54396a8342..d6f4e8fadc09 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -92,6 +92,15 @@ async function expectTransientGetFileRetrySuccess() { await flushRetryTimers(); const result = await promise; expect(getFile).toHaveBeenCalledTimes(2); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, + ssrfPolicy: { + allowRfc2544BenchmarkRange: true, + allowedHostnames: ["api.telegram.org"], + }, + }), + ); return result; } diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index d4606ac14147..971ee679c261 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -244,6 +244,59 @@ describe("deliverReplies", () => { ); }); + it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn(async (_chatId: string, text: string) => { + if (text === "") { + throw new Error("400: Bad Request: message text is empty"); + } + return { + message_id: 6, + chat: { id: "123" }, + }; + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: ">" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + thread: { id: 42, scope: "forum" }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + ">", + expect.objectContaining({ + message_thread_id: 42, + }), + ); + }); + + it("throws when formatted and plain fallback text are both empty", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn(); + const bot = { api: { sendMessage } } as unknown as Bot; + + await expect( + deliverReplies({ + replies: [{ text: " " }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("empty formatted text and empty plain fallback"); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("uses reply_to_message_id when quote text is provided", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 945cd2c25579..748fca00a4d0 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -33,8 +33,15 @@ import { import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const EMPTY_TEXT_ERR_RE = /message text is empty/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; +const TELEGRAM_MEDIA_SSRF_POLICY = { + // Telegram file downloads should trust api.telegram.org even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, +}; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -320,6 +327,7 @@ export async function resolveMedia( fetchImpl, filePathHint: filePath, maxBytes, + ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, }); const originalName = fetched.fileName ?? filePath; return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName); @@ -536,7 +544,7 @@ async function sendTelegramText( linkPreview?: boolean; replyMarkup?: ReturnType; }, -): Promise { +): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, @@ -546,11 +554,38 @@ async function sendTelegramText( const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const fallbackText = opts?.plainText ?? text; + const hasFallbackText = fallbackText.trim().length > 0; + const sendPlainFallback = async () => { + const res = await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, fallbackText, { + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...baseParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); + return res.message_id; + }; + + // Markdown can render to empty HTML for syntax-only chunks; recover with plain text. + if (!htmlText.trim()) { + if (!hasFallbackText) { + throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback"); + } + return await sendPlainFallback(); + } try { const res = await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, - shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)), + shouldLog: (err) => { + const errText = formatErrorMessage(err); + return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText); + }, fn: () => bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", @@ -563,21 +598,12 @@ async function sendTelegramText( return res.message_id; } catch (err) { const errText = formatErrorMessage(err); - if (PARSE_ERR_RE.test(errText)) { - runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); - const fallbackText = opts?.plainText ?? text; - const res = await withTelegramApiErrorLogging({ - operation: "sendMessage", - runtime, - fn: () => - bot.api.sendMessage(chatId, fallbackText, { - ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), - ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), - ...baseParams, - }), - }); - runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); - return res.message_id; + if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) { + if (!hasFallbackText) { + throw err; + } + runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`); + return await sendPlainFallback(); } throw err; } diff --git a/src/telegram/dm-access.ts b/src/telegram/dm-access.ts new file mode 100644 index 000000000000..1c68dd43d69d --- /dev/null +++ b/src/telegram/dm-access.ts @@ -0,0 +1,119 @@ +import type { Message } from "@grammyjs/types"; +import type { Bot } from "grammy"; +import type { DmPolicy } from "../config/types.js"; +import { logVerbose } from "../globals.js"; +import { buildPairingReply } from "../pairing/pairing-messages.js"; +import { upsertChannelPairingRequest } from "../pairing/pairing-store.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; + +type TelegramDmAccessLogger = { + info: (obj: Record, msg: string) => void; +}; + +type TelegramSenderIdentity = { + username: string; + userId: string | null; + candidateId: string; + firstName?: string; + lastName?: string; +}; + +function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSenderIdentity { + const from = msg.from; + const userId = from?.id != null ? String(from.id) : null; + return { + username: from?.username ?? "", + userId, + candidateId: userId ?? String(chatId), + firstName: from?.first_name, + lastName: from?.last_name, + }; +} + +export async function enforceTelegramDmAccess(params: { + isGroup: boolean; + dmPolicy: DmPolicy; + msg: Message; + chatId: number; + effectiveDmAllow: NormalizedAllowFrom; + accountId: string; + bot: Bot; + logger: TelegramDmAccessLogger; +}): Promise { + const { isGroup, dmPolicy, msg, chatId, effectiveDmAllow, accountId, bot, logger } = params; + if (isGroup) { + return true; + } + if (dmPolicy === "disabled") { + return false; + } + if (dmPolicy === "open") { + return true; + } + + const sender = resolveTelegramSenderIdentity(msg, chatId); + const allowMatch = resolveSenderAllowMatch({ + allow: effectiveDmAllow, + senderId: sender.candidateId, + senderUsername: sender.username, + }); + const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ + allowMatch.matchSource ?? "none" + }`; + const allowed = + effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); + if (allowed) { + return true; + } + + if (dmPolicy === "pairing") { + try { + const telegramUserId = sender.userId ?? sender.candidateId; + const { code, created } = await upsertChannelPairingRequest({ + channel: "telegram", + id: telegramUserId, + accountId, + meta: { + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, + }, + }); + if (created) { + logger.info( + { + chatId: String(chatId), + senderUserId: sender.userId ?? undefined, + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, + matchKey: allowMatch.matchKey ?? "none", + matchSource: allowMatch.matchSource ?? "none", + }, + "telegram pairing request", + ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => + bot.api.sendMessage( + chatId, + buildPairingReply({ + channel: "telegram", + idLine: `Your Telegram user id: ${telegramUserId}`, + code, + }), + ), + }); + } + } catch (err) { + logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); + } + return false; + } + + logVerbose( + `Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + return false; +} diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 9f1c676119be..b36f5dab7a83 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -4,6 +4,12 @@ import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.j const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); const setDefaultResultOrder = vi.hoisted(() => vi.fn()); +const setGlobalDispatcher = vi.hoisted(() => vi.fn()); +const AgentCtor = vi.hoisted(() => + vi.fn(function MockAgent(this: { options: unknown }, options: unknown) { + this.options = options; + }), +); vi.mock("node:net", async () => { const actual = await vi.importActual("node:net"); @@ -21,12 +27,19 @@ vi.mock("node:dns", async () => { }; }); +vi.mock("undici", () => ({ + Agent: AgentCtor, + setGlobalDispatcher, +})); + const originalFetch = globalThis.fetch; afterEach(() => { resetTelegramFetchStateForTests(); setDefaultAutoSelectFamily.mockReset(); setDefaultResultOrder.mockReset(); + setGlobalDispatcher.mockReset(); + AgentCtor.mockClear(); vi.unstubAllEnvs(); vi.clearAllMocks(); if (originalFetch) { @@ -133,4 +146,45 @@ describe("resolveTelegramFetch", () => { expect(setDefaultResultOrder).toHaveBeenCalledTimes(2); }); + + it("replaces global undici dispatcher with autoSelectFamily-enabled agent", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledWith({ + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); + + it("sets global dispatcher only once across repeated equal decisions", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + + it("updates global dispatcher when autoSelectFamily decision changes", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: false } }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); + expect(AgentCtor).toHaveBeenNthCalledWith(1, { + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + expect(AgentCtor).toHaveBeenNthCalledWith(2, { + connect: { + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }, + }); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 48fdf72eff79..3dec18cc0ddb 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,5 +1,6 @@ import * as dns from "node:dns"; import * as net from "node:net"; +import { Agent, setGlobalDispatcher } from "undici"; import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveFetch } from "../infra/fetch.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -10,6 +11,7 @@ import { let appliedAutoSelectFamily: boolean | null = null; let appliedDnsResultOrder: string | null = null; +let appliedGlobalDispatcherAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); // Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. @@ -31,6 +33,33 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void } } + // Node 22's built-in globalThis.fetch uses undici's internal Agent whose + // connect options are frozen at construction time. Calling + // net.setDefaultAutoSelectFamily() after that agent is created has no + // effect on it. Replace the global dispatcher with one that carries the + // current autoSelectFamily setting so subsequent globalThis.fetch calls + // inherit the same decision. + // See: https://github.com/openclaw/openclaw/issues/25676 + if ( + autoSelectDecision.value !== null && + autoSelectDecision.value !== appliedGlobalDispatcherAutoSelectFamily + ) { + try { + setGlobalDispatcher( + new Agent({ + connect: { + autoSelectFamily: autoSelectDecision.value, + autoSelectFamilyAttemptTimeout: 300, + }, + }), + ); + appliedGlobalDispatcherAutoSelectFamily = autoSelectDecision.value; + log.info(`global undici dispatcher autoSelectFamily=${autoSelectDecision.value}`); + } catch { + // ignore if setGlobalDispatcher is unavailable + } + } + // Apply DNS result order workaround for IPv4/IPv6 issues. // Some APIs (including Telegram) may fail with IPv6 on certain networks. // See: https://github.com/openclaw/openclaw/issues/5311 @@ -68,4 +97,5 @@ export function resolveTelegramFetch( export function resetTelegramFetchStateForTests(): void { appliedAutoSelectFamily = null; appliedDnsResultOrder = null; + appliedGlobalDispatcherAutoSelectFamily = null; } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 0e27bc074e32..ac4163b96f06 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -94,4 +94,22 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("||**secret** text||"); expect(res).toBe("secret text"); }); + + it("does not treat single pipe as spoiler", () => { + const res = markdownToTelegramHtml("( ̄_ ̄|) face"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("|"); + }); + + it("does not treat unpaired || as spoiler", () => { + const res = markdownToTelegramHtml("before || after"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("||"); + }); + + it("keeps valid spoiler pairs when a trailing || is unmatched", () => { + const res = markdownToTelegramHtml("||secret|| trailing ||"); + expect(res).toContain("secret"); + expect(res).toContain("trailing ||"); + }); }); diff --git a/src/telegram/group-access.base-access.test.ts b/src/telegram/group-access.base-access.test.ts new file mode 100644 index 000000000000..d8d559feab49 --- /dev/null +++ b/src/telegram/group-access.base-access.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { NormalizedAllowFrom } from "./bot-access.js"; +import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; + +function allow(entries: string[], hasWildcard = false): NormalizedAllowFrom { + return { + entries, + hasWildcard, + hasEntries: entries.length > 0 || hasWildcard, + invalidEntries: [], + }; +} + +describe("evaluateTelegramGroupBaseAccess", () => { + it("fails closed when explicit group allowFrom override is empty", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: true, + effectiveGroupAllow: allow([]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: false, reason: "group-override-unauthorized" }); + }); + + it("allows group message when override is not configured", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: false, + effectiveGroupAllow: allow([]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: true }); + }); + + it("allows sender explicitly listed in override", () => { + const result = evaluateTelegramGroupBaseAccess({ + isGroup: true, + hasGroupAllowOverride: true, + effectiveGroupAllow: allow(["12345"]), + senderId: "12345", + senderUsername: "tester", + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + + expect(result).toEqual({ allowed: true }); + }); +}); diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index dcd0dd2ef6e5..1702277da6b5 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -42,6 +42,11 @@ export const evaluateTelegramGroupBaseAccess = (params: { return { allowed: true }; } + // Explicit per-group/topic allowFrom override must fail closed when empty. + if (!params.effectiveGroupAllow.hasEntries) { + return { allowed: false, reason: "group-override-unauthorized" }; + } + const senderId = params.senderId ?? ""; if (params.requireSenderForAllowOverride && !senderId) { return { allowed: false, reason: "group-override-unauthorized" }; diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 5cbec2e02990..f55bbf5f3543 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -146,6 +146,10 @@ export class GatewayChatClient { }); }, onClose: (_code, reason) => { + // Reset so waitForReady() blocks again until the next successful reconnect. + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); this.onDisconnected?.(reason); }, onGap: (info) => { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index f9e4ca3e40ff..bb17cbed9a4e 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -9,6 +9,7 @@ function createHarness(params?: { resetSession?: ReturnType; loadHistory?: LoadHistoryMock; setActivityStatus?: SetActivityStatusMock; + isConnected?: boolean; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); @@ -27,6 +28,7 @@ function createHarness(params?: { state: { currentSessionKey: "agent:main:main", activeChatRunId: null, + isConnected: params?.isConnected ?? true, sessionInfo: {}, } as never, deliverDefault: false, @@ -126,4 +128,17 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down"); expect(setActivityStatus).toHaveBeenLastCalledWith("error"); }); + + it("reports disconnected status and skips gateway send when offline", async () => { + const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({ + isConnected: false, + }); + + await handleCommand("/context"); + + expect(sendChat).not.toHaveBeenCalled(); + expect(addUser).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("not connected to gateway — message not sent"); + expect(setActivityStatus).toHaveBeenLastCalledWith("disconnected"); + }); }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 4e5a56f6238a..989c942beb6e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -456,6 +456,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { }; const sendMessage = async (text: string) => { + if (!state.isConnected) { + chatLog.addSystem("not connected to gateway — message not sent"); + setActivityStatus("disconnected"); + tui.requestRender(); + return; + } try { chatLog.addUser(text); tui.requestRender(); diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index f6eca287621d..678cf0d37c61 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -145,6 +145,56 @@ describe("web auto-reply", () => { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(scenario.expectedError)); } }); + + it("treats status 440 as non-retryable and stops without retrying", async () => { + const closeResolvers: Array<(reason?: unknown) => void> = []; + const sleep = vi.fn(async () => {}); + const listenerFactory = vi.fn(async () => { + const onClose = new Promise((res) => { + closeResolvers.push(res); + }); + return { close: vi.fn(), onClose }; + }); + const { runtime, controller, run } = startMonitorWebChannel({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + }); + + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); + closeResolvers.shift()?.({ + status: 440, + isLoggedOut: false, + error: "Unknown Stream Errored (conflict)", + }); + + const completedQuickly = await Promise.race([ + run.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 60)), + ]); + + if (!completedQuickly) { + await vi.waitFor( + () => { + expect(listenerFactory).toHaveBeenCalledTimes(2); + }, + { timeout: 250, interval: 2 }, + ); + controller.abort(); + closeResolvers[1]?.({ status: 499, isLoggedOut: false, error: "aborted" }); + await run; + } + + expect(completedQuickly).toBe(true); + expect(listenerFactory).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("status 440")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("session conflict")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); + }); + it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); try { diff --git a/src/web/auto-reply/deliver-reply.test.ts b/src/web/auto-reply/deliver-reply.test.ts index 24f6e2eb82ed..e3dfe6126bbd 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/src/web/auto-reply/deliver-reply.test.ts @@ -70,6 +70,56 @@ const replyLogger = { }; describe("deliverWebReply", () => { + it("suppresses payloads flagged as reasoning", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: "Reasoning:\n_hidden_", isReasoning: true }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).not.toHaveBeenCalled(); + expect(msg.sendMedia).not.toHaveBeenCalled(); + }); + + it("suppresses payloads that start with reasoning prefix text", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: " \n Reasoning:\n_hidden_" }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).not.toHaveBeenCalled(); + expect(msg.sendMedia).not.toHaveBeenCalled(); + }); + + it("does not suppress messages that mention Reasoning: mid-text", async () => { + const msg = makeMsg(); + + await deliverWebReply({ + replyResult: { text: "Intro line\nReasoning: appears in content but is not a prefix" }, + msg, + maxMediaBytes: 1024 * 1024, + textLimit: 200, + replyLogger, + skipLog: true, + }); + + expect(msg.reply).toHaveBeenCalledTimes(1); + expect(msg.reply).toHaveBeenCalledWith( + "Intro line\nReasoning: appears in content but is not a prefix", + ); + }); + it("sends chunked text replies and logs a summary", async () => { const msg = makeMsg(); diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 664e8acee852..7866fea0c8a0 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -12,6 +12,19 @@ import { whatsappOutboundLog } from "./loggers.js"; import type { WebInboundMsg } from "./types.js"; import { elide } from "./util.js"; +const REASONING_PREFIX = "reasoning:"; + +function shouldSuppressReasoningReply(payload: ReplyPayload): boolean { + if (payload.isReasoning === true) { + return true; + } + const text = payload.text; + if (typeof text !== "string") { + return false; + } + return text.trimStart().toLowerCase().startsWith(REASONING_PREFIX); +} + export async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; @@ -29,6 +42,10 @@ export async function deliverWebReply(params: { }) { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); + if (shouldSuppressReasoningReply(replyResult)) { + whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`); + return; + } const tableMode = params.tableMode ?? "code"; const chunkMode = params.chunkMode ?? "length"; const convertedText = markdownToWhatsApp( diff --git a/src/web/auto-reply/heartbeat-runner.test.ts b/src/web/auto-reply/heartbeat-runner.test.ts index 78014787ad35..87d8d8a7ca94 100644 --- a/src/web/auto-reply/heartbeat-runner.test.ts +++ b/src/web/auto-reply/heartbeat-runner.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { getReplyFromConfig } from "../../auto-reply/reply.js"; import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import type { sendMessageWhatsApp } from "../outbound.js"; const state = vi.hoisted(() => ({ @@ -15,6 +16,10 @@ const state = vi.hoisted(() => ({ idleExpiresAt: null as number | null, }, events: [] as unknown[], + loggerInfoCalls: [] as unknown[][], + loggerWarnCalls: [] as unknown[][], + heartbeatInfoLogs: [] as string[], + heartbeatWarnLogs: [] as string[], })); vi.mock("../../agents/current-time.js", () => ({ @@ -64,15 +69,15 @@ vi.mock("../../infra/heartbeat-events.js", () => ({ vi.mock("../../logging.js", () => ({ getChildLogger: () => ({ - info: () => {}, - warn: () => {}, + info: (...args: unknown[]) => state.loggerInfoCalls.push(args), + warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), }), })); vi.mock("./loggers.js", () => ({ whatsappHeartbeatLog: { - info: () => {}, - warn: () => {}, + info: (msg: string) => state.heartbeatInfoLogs.push(msg), + warn: (msg: string) => state.heartbeatWarnLogs.push(msg), }, })); @@ -115,6 +120,10 @@ describe("runWebHeartbeatOnce", () => { idleExpiresAt: null, }; state.events = []; + state.loggerInfoCalls = []; + state.loggerWarnCalls = []; + state.heartbeatInfoLogs = []; + state.heartbeatWarnLogs = []; senderMock = vi.fn(async () => ({ messageId: "m1" })); sender = senderMock as unknown as typeof sendMessageWhatsApp; @@ -187,4 +196,23 @@ describe("runWebHeartbeatOnce", () => { ]), ); }); + + it("redacts recipient and omits body preview in heartbeat logs", async () => { + replyResolverMock.mockResolvedValue({ text: "sensitive heartbeat body" }); + const { runWebHeartbeatOnce } = await getModules(); + await runWebHeartbeatOnce(buildRunArgs({ dryRun: true })); + + const expected = redactIdentifier("+123"); + const heartbeatLogs = state.heartbeatInfoLogs.join("\n"); + const childLoggerLogs = state.loggerInfoCalls.map((entry) => JSON.stringify(entry)).join("\n"); + + expect(heartbeatLogs).toContain(expected); + expect(heartbeatLogs).not.toContain("+123"); + expect(heartbeatLogs).not.toContain("sensitive heartbeat body"); + + expect(childLoggerLogs).toContain(expected); + expect(childLoggerLogs).not.toContain("+123"); + expect(childLoggerLogs).not.toContain("sensitive heartbeat body"); + expect(childLoggerLogs).not.toContain('"preview"'); + }); }); diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index 5b89c785c658..e393339a7810 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -18,13 +18,13 @@ import { import { emitHeartbeatEvent, resolveIndicatorType } from "../../infra/heartbeat-events.js"; import { resolveHeartbeatVisibility } from "../../infra/heartbeat-visibility.js"; import { getChildLogger } from "../../logging.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { sendMessageWhatsApp } from "../outbound.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; import { getSessionSnapshot } from "./session-snapshot.js"; -import { elide } from "./util.js"; export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; @@ -40,10 +40,11 @@ export async function runWebHeartbeatOnce(opts: { const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWhatsApp; const runId = newConnectionId(); + const redactedTo = redactIdentifier(to); const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId, - to, + to: redactedTo, }); const cfg = cfgOverride ?? loadConfig(); @@ -57,20 +58,20 @@ export async function runWebHeartbeatOnce(opts: { return false; } if (dryRun) { - whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${to}`); + whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`); return false; } const sendResult = await sender(to, heartbeatOkText, { verbose }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: heartbeatOkText.length, reason: "heartbeat-ok", }, "heartbeat ok sent", ); - whatsappHeartbeatLog.info(`heartbeat ok sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`); return true; }; @@ -100,7 +101,7 @@ export async function runWebHeartbeatOnce(opts: { if (verbose) { heartbeatLogger.info( { - to, + to: redactedTo, sessionKey: sessionSnapshot.key, sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null, sessionFresh: sessionSnapshot.fresh, @@ -122,7 +123,7 @@ export async function runWebHeartbeatOnce(opts: { if (overrideBody) { if (dryRun) { whatsappHeartbeatLog.info( - `[dry-run] web send -> ${to}: ${elide(overrideBody.trim(), 200)} (manual message)`, + `[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`, ); return; } @@ -137,19 +138,21 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: overrideBody.length, reason: "manual-message", }, "manual heartbeat message sent", ); - whatsappHeartbeatLog.info(`manual heartbeat sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info( + `manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`, + ); return; } if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -181,7 +184,7 @@ export async function runWebHeartbeatOnce(opts: { ) { heartbeatLogger.info( { - to, + to: redactedTo, reason: "empty-reply", sessionId: sessionSnapshot.entry?.sessionId ?? null, }, @@ -226,7 +229,7 @@ export async function runWebHeartbeatOnce(opts: { } heartbeatLogger.info( - { to, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, + { to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); const okSent = await maybeSendHeartbeatOk(); @@ -241,14 +244,17 @@ export async function runWebHeartbeatOnce(opts: { } if (hasMedia) { - heartbeatLogger.warn({ to }, "heartbeat reply contained media; sending text only"); + heartbeatLogger.warn( + { to: redactedTo }, + "heartbeat reply contained media; sending text only", + ); } const finalText = stripped.text || replyPayload.text || ""; // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -262,8 +268,11 @@ export async function runWebHeartbeatOnce(opts: { } if (dryRun) { - heartbeatLogger.info({ to, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run"); - whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`); + heartbeatLogger.info( + { to: redactedTo, reason: "dry-run", chars: finalText.length }, + "heartbeat dry-run", + ); + whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`); return; } @@ -278,17 +287,16 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: finalText.length, - preview: elide(finalText, 140), }, "heartbeat sent", ); - whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`); + whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`); } catch (err) { const reason = formatError(err); - heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); + heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); emitHeartbeatEvent({ status: "failed", diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index cab3490feddc..b7e2bb2683f1 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -31,6 +31,12 @@ import { createWebOnMessageHandler } from "./monitor/on-message.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; +function isNonRetryableWebCloseStatus(statusCode: unknown): boolean { + // WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)"). + // This is persistent until the operator resolves the conflicting session. + return statusCode === 440; +} + export async function monitorWebChannel( verbose: boolean, listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox, @@ -402,6 +408,22 @@ export async function monitorWebChannel( break; } + if (isNonRetryableWebCloseStatus(statusCode)) { + reconnectLogger.warn( + { + connectionId, + status: statusCode, + error: errorStr, + }, + "web reconnect: non-retryable close status; stopping monitor", + ); + runtime.error( + `WhatsApp Web connection closed (status ${statusCode}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`, + ); + await closeListener(); + break; + } + reconnectAttempts += 1; status.reconnectAttempts = reconnectAttempts; emitStatus(); diff --git a/src/web/auto-reply/monitor/group-activation.ts b/src/web/auto-reply/monitor/group-activation.ts index aeb16428fbe1..01f96e945287 100644 --- a/src/web/auto-reply/monitor/group-activation.ts +++ b/src/web/auto-reply/monitor/group-activation.ts @@ -16,10 +16,17 @@ export function resolveGroupPolicyFor(cfg: ReturnType, conver ChatType: "group", Provider: "whatsapp", })?.id; + const whatsappCfg = cfg.channels?.whatsapp as + | { groupAllowFrom?: string[]; allowFrom?: string[] } + | undefined; + const hasGroupAllowFrom = Boolean( + whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length, + ); return resolveChannelGroupPolicy({ cfg, channel: "whatsapp", groupId: groupId ?? conversationId, + hasGroupAllowFrom, }); } diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0404ec431454..8458487d8e96 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -9,6 +9,9 @@ let capturedDispatchParams: unknown; let sessionDir: string | undefined; let sessionStorePath: string; let backgroundTasks: Set>; +const { deliverWebReplyMock } = vi.hoisted(() => ({ + deliverWebReplyMock: vi.fn(async () => {}), +})); const defaultReplyLogger = { info: () => {}, @@ -24,6 +27,7 @@ function makeProcessMessageArgs(params: { cfg?: unknown; groupHistories?: Map>; groupHistory?: Array<{ sender: string; body: string }>; + rememberSentText?: (text: string | undefined, opts: unknown) => void; }) { return { // oxlint-disable-next-line typescript/no-explicit-any @@ -47,7 +51,8 @@ function makeProcessMessageArgs(params: { // oxlint-disable-next-line typescript/no-explicit-any replyLogger: defaultReplyLogger as any, backgroundTasks, - rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + rememberSentText: + params.rememberSentText ?? ((_text: string | undefined, _opts: unknown) => {}), echoHas: () => false, echoForget: () => {}, buildCombinedEchoKey: () => "echo", @@ -75,6 +80,11 @@ vi.mock("./last-route.js", () => ({ updateLastRouteInBackground: vi.fn(), })); +vi.mock("../deliver-reply.js", () => ({ + deliverWebReply: deliverWebReplyMock, +})); + +import { updateLastRouteInBackground } from "./last-route.js"; import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -82,6 +92,7 @@ describe("web processMessage inbound contract", () => { capturedCtx = undefined; capturedDispatchParams = undefined; backgroundTasks = new Set(); + deliverWebReplyMock.mockClear(); sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-process-message-")); sessionStorePath = path.join(sessionDir, "sessions.json"); }); @@ -229,4 +240,121 @@ describe("web processMessage inbound contract", () => { expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0); }); + + it("suppresses non-final WhatsApp payload delivery", async () => { + const rememberSentText = vi.fn(); + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + rememberSentText, + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const deliver = (capturedDispatchParams as any)?.dispatcherOptions?.deliver as + | ((payload: { text?: string }, info: { kind: "tool" | "block" | "final" }) => Promise) + | undefined; + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "tool payload" }, { kind: "tool" }); + await deliver?.({ text: "block payload" }, { kind: "block" }); + expect(deliverWebReplyMock).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + + await deliver?.({ text: "final payload" }, { kind: "final" }); + expect(deliverWebReplyMock).toHaveBeenCalledTimes(1); + expect(rememberSentText).toHaveBeenCalledTimes(1); + }); + + it("forces disableBlockStreaming for WhatsApp dispatch", async () => { + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const replyOptions = (capturedDispatchParams as any)?.replyOptions; + expect(replyOptions?.disableBlockStreaming).toBe(true); + }); + + it("updates main last route for DM when session key matches main session key", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1000", + groupHistoryKey: "+1000", + msg: { + id: "msg-last-route-1", + from: "+1000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+1000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:direct:+1000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).toHaveBeenCalledTimes(1); + }); + + it("does not update main last route for isolated DM scope sessions", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + groupHistoryKey: "+3000", + msg: { + id: "msg-last-route-2", + from: "+3000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+3000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index cf3b4d60554a..3ef85b6eb2df 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -324,7 +324,10 @@ export async function processMessage(params: { OriginatingTo: params.msg.from, }); - if (dmRouteTarget) { + // Only update main session's lastRoute when DM actually IS the main session. + // When dmScope="per-channel-peer", the DM uses an isolated sessionKey, + // and updating mainSessionKey would corrupt routing for the session owner. + if (dmRouteTarget && params.route.sessionKey === params.route.mainSessionKey) { updateLastRouteInBackground({ cfg: params.cfg, backgroundTasks: params.backgroundTasks, @@ -368,6 +371,12 @@ export async function processMessage(params: { } }, deliver: async (payload: ReplyPayload, info) => { + if (info.kind !== "final") { + // Only deliver final replies to external messaging channels (WhatsApp). + // Block (reasoning/thinking) and tool updates are meant for the internal + // web UI only; sending them here leaks chain-of-thought to end users. + return; + } await deliverWebReply({ replyResult: payload, msg: params.msg, @@ -377,30 +386,23 @@ export async function processMessage(params: { chunkMode, replyLogger: params.replyLogger, connectionId: params.connectionId, - // Tool + block updates are noisy; skip their log lines. - skipLog: info.kind !== "final", + skipLog: false, tableMode, }); didSendReply = true; - if (info.kind === "tool") { - params.rememberSentText(payload.text, {}); - return; - } - const shouldLog = info.kind === "final" && payload.text ? true : undefined; + const shouldLog = payload.text ? true : undefined; params.rememberSentText(payload.text, { combinedBody, combinedBodySessionKey: params.route.sessionKey, logVerboseMessage: shouldLog, }); - if (info.kind === "final") { - const fromDisplay = - params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); - whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); - if (shouldLogVerbose()) { - const preview = payload.text != null ? elide(payload.text, 400) : ""; - whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); - } + const fromDisplay = + params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); + const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); + if (shouldLogVerbose()) { + const preview = payload.text != null ? elide(payload.text, 400) : ""; + whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); } }, onError: (err, info) => { @@ -417,10 +419,9 @@ export async function processMessage(params: { onReplyStart: params.msg.sendComposing, }, replyOptions: { - disableBlockStreaming: - typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" - ? !params.cfg.channels.whatsapp.blockStreaming - : undefined, + // WhatsApp delivery intentionally suppresses non-final payloads. + // Keep block streaming disabled so final replies are still produced. + disableBlockStreaming: true, onModelSelected, }, }); diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 794897a53885..2e759507cb9c 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -75,7 +75,7 @@ export async function checkInboundAccessControl(params: { account.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; - const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom); + const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 ? params.pairingGraceMs diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 4bfcc7fddb19..d91ed4b7d66f 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -50,6 +50,10 @@ async function createLargeTestJpeg(): Promise<{ buffer: Buffer; file: string }> return { buffer: largeJpegBuffer, file: largeJpegFile }; } +function cloneStatWithDev(stat: T, dev: number | bigint): T { + return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, { dev }) as T; +} + beforeAll(async () => { fixtureRoot = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-test-"), @@ -357,6 +361,30 @@ describe("local media root guard", () => { expect(result.kind).toBe("image"); }); + it("accepts win32 dev=0 stat mismatch for local file loads", async () => { + const actualLstat = await fs.lstat(tinyPngFile); + const actualStat = await fs.stat(tinyPngFile); + const zeroDev = typeof actualLstat.dev === "bigint" ? 0n : 0; + + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const lstatSpy = vi + .spyOn(fs, "lstat") + .mockResolvedValue(cloneStatWithDev(actualLstat, zeroDev)); + const statSpy = vi.spyOn(fs, "stat").mockResolvedValue(cloneStatWithDev(actualStat, zeroDev)); + + try { + const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { + localRoots: [resolvePreferredOpenClawTmpDir()], + }); + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + } finally { + statSpy.mockRestore(); + lstatSpy.mockRestore(); + platformSpy.mockRestore(); + } + }); + it("requires readFile override for localRoots bypass", async () => { await expect( loadWebMedia(tinyPngFile, { diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 5f627b454ac5..e60d15158fc2 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,5 +1,10 @@ +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { setActiveWebListener } from "./active-listener.js"; const loadWebMediaMock = vi.fn(); @@ -154,6 +159,31 @@ describe("web outbound", () => { }); }); + it("redacts recipients and poll text in outbound logs", async () => { + const logPath = path.join(os.tmpdir(), `openclaw-outbound-${crypto.randomUUID()}.log`); + setLoggerOverride({ level: "trace", file: logPath }); + + await sendPollWhatsApp( + "+1555", + { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 }, + { verbose: false }, + ); + + await vi.waitFor( + () => { + expect(fsSync.existsSync(logPath)).toBe(true); + }, + { timeout: 2_000, interval: 5 }, + ); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain(redactIdentifier("+1555")); + expect(content).toContain(redactIdentifier("1555@s.whatsapp.net")); + expect(content).not.toContain(`"to":"+1555"`); + expect(content).not.toContain(`"jid":"1555@s.whatsapp.net"`); + expect(content).not.toContain("Lunch?"); + }); + it("sends reactions via active listener", async () => { await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { verbose: false, diff --git a/src/web/outbound.ts b/src/web/outbound.ts index ce8b44669498..da1428a6980c 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { generateSecureUuid } from "../infra/secure-random.js"; import { getChildLogger } from "../logging/logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { convertMarkdownTables } from "../markdown/tables.js"; import { markdownToWhatsApp } from "../markdown/whatsapp.js"; @@ -37,13 +38,15 @@ export async function sendMessageWhatsApp( }); text = convertMarkdownTables(text ?? "", tableMode); text = markdownToWhatsApp(text); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); let mediaBuffer: Buffer | undefined; let mediaType: string | undefined; let documentFileName: string | undefined; @@ -69,8 +72,8 @@ export async function sendMessageWhatsApp( documentFileName = media.fileName; } } - outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); - logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); + outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`); + logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); await active.sendComposingTo(to); const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; @@ -88,13 +91,13 @@ export async function sendMessageWhatsApp( const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; outboundLog.info( - `Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, + `Sent message ${messageId} -> ${redactedJid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, ); - logger.info({ jid, messageId }, "sent message"); + logger.info({ jid: redactedJid, messageId }, "sent message"); return { messageId, toJid: jid }; } catch (err) { logger.error( - { err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, + { err: String(err), to: redactedTo, hasMedia: Boolean(options.mediaUrl) }, "failed to send via web session", ); throw err; @@ -114,16 +117,18 @@ export async function sendReactionWhatsApp( ): Promise { const correlationId = generateSecureUuid(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedChatJid = redactIdentifier(chatJid); const logger = getChildLogger({ module: "web-outbound", correlationId, - chatJid, + chatJid: redactedChatJid, messageId, }); try { const jid = toWhatsappJid(chatJid); + const redactedJid = redactIdentifier(jid); outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sending reaction"); await active.sendReaction( chatJid, messageId, @@ -132,10 +137,10 @@ export async function sendReactionWhatsApp( options.participant, ); outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sent reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sent reaction"); } catch (err) { logger.error( - { err: String(err), chatJid, messageId, emoji }, + { err: String(err), chatJid: redactedChatJid, messageId, emoji }, "failed to send reaction via web session", ); throw err; @@ -150,19 +155,20 @@ export async function sendPollWhatsApp( const correlationId = generateSecureUuid(); const startedAt = Date.now(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); const normalized = normalizePollInput(poll, { maxOptions: 12 }); - outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); + outboundLog.info(`Sending poll -> ${redactedJid}`); logger.info( { - jid, - question: normalized.question, + jid: redactedJid, optionCount: normalized.options.length, maxSelections: normalized.maxSelections, }, @@ -171,14 +177,11 @@ export async function sendPollWhatsApp( const result = await active.sendPoll(to, normalized); const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; - outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); - logger.info({ jid, messageId }, "sent poll"); + outboundLog.info(`Sent poll ${messageId} -> ${redactedJid} (${durationMs}ms)`); + logger.info({ jid: redactedJid, messageId }, "sent poll"); return { messageId, toJid: jid }; } catch (err) { - logger.error( - { err: String(err), to, question: poll.question }, - "failed to send poll via web session", - ); + logger.error({ err: String(err), to: redactedTo }, "failed to send poll via web session"); throw err; } } diff --git a/test/fixtures/system-run-command-contract.json b/test/fixtures/system-run-command-contract.json new file mode 100644 index 000000000000..60b76bf1bf48 --- /dev/null +++ b/test/fixtures/system-run-command-contract.json @@ -0,0 +1,75 @@ +{ + "cases": [ + { + "name": "direct argv infers display command", + "command": ["echo", "hi there"], + "expected": { + "valid": true, + "displayCommand": "echo \"hi there\"" + } + }, + { + "name": "direct argv rejects mismatched raw command", + "command": ["uname", "-a"], + "rawCommand": "echo hi", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "shell wrapper accepts shell payload raw command when no positional argv carriers", + "command": ["/bin/sh", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": true, + "displayCommand": "echo hi" + } + }, + { + "name": "shell wrapper positional argv carrier requires full argv display binding", + "command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"], + "rawCommand": "$0 \"$1\"", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "shell wrapper positional argv carrier accepts canonical full argv raw command", + "command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"], + "rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker", + "expected": { + "valid": true, + "displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker" + } + }, + { + "name": "env wrapper shell payload accepted when prelude has no env modifiers", + "command": ["/usr/bin/env", "bash", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": true, + "displayCommand": "echo hi" + } + }, + { + "name": "env assignment prelude requires full argv display binding", + "command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], + "rawCommand": "echo hi", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "env assignment prelude accepts canonical full argv raw command", + "command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], + "rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"", + "expected": { + "valid": true, + "displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"" + } + } + ] +} diff --git a/test/scripts/check-no-random-messaging-tmp.test.ts b/test/scripts/check-no-random-messaging-tmp.test.ts new file mode 100644 index 000000000000..276a19962af3 --- /dev/null +++ b/test/scripts/check-no-random-messaging-tmp.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs"; + +describe("check-no-random-messaging-tmp", () => { + it("finds os.tmpdir calls imported from node:os", () => { + const source = ` + import os from "node:os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("finds tmpdir named import calls from node:os", () => { + const source = ` + import { tmpdir } from "node:os"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("finds tmpdir calls imported from os", () => { + const source = ` + import os from "os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("ignores mentions in comments and strings", () => { + const source = ` + // os.tmpdir() + const text = "tmpdir()"; + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); + + it("ignores tmpdir symbols that are not imported from node:os", () => { + const source = ` + const tmpdir = () => "/tmp"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); +}); diff --git a/test/scripts/check-no-raw-window-open.test.ts b/test/scripts/check-no-raw-window-open.test.ts new file mode 100644 index 000000000000..543c4b797935 --- /dev/null +++ b/test/scripts/check-no-raw-window-open.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { findRawWindowOpenLines } from "../../scripts/check-no-raw-window-open.mjs"; + +describe("check-no-raw-window-open", () => { + it("finds direct window.open calls", () => { + const source = ` + function openDocs() { + window.open("https://docs.openclaw.ai"); + } + `; + expect(findRawWindowOpenLines(source)).toEqual([3]); + }); + + it("finds globalThis.open calls", () => { + const source = ` + function openDocs() { + globalThis.open("https://docs.openclaw.ai"); + } + `; + expect(findRawWindowOpenLines(source)).toEqual([3]); + }); + + it("ignores mentions in strings and comments", () => { + const source = ` + // window.open("https://example.com") + const text = "window.open('https://example.com')"; + `; + expect(findRawWindowOpenLines(source)).toEqual([]); + }); + + it("handles parenthesized and asserted window references", () => { + const source = ` + const openRef = (window as Window).open; + openRef("https://example.com"); + (window as Window).open("https://example.com"); + `; + expect(findRawWindowOpenLines(source)).toEqual([4]); + }); +}); diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts new file mode 100644 index 000000000000..d39d1a7de6f4 --- /dev/null +++ b/test/scripts/ios-team-id.test.ts @@ -0,0 +1,231 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync } from "node:fs"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh"); + +async function writeExecutable(filePath: string, body: string): Promise { + await writeFile(filePath, body, "utf8"); + chmodSync(filePath, 0o755); +} + +function runScript( + homeDir: string, + extraEnv: Record = {}, +): { + ok: boolean; + stdout: string; + stderr: string; +} { + const binDir = path.join(homeDir, "bin"); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + ...extraEnv, + }; + try { + const stdout = execFileSync("bash", [SCRIPT], { + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, stdout: stdout.trim(), stderr: "" }; + } catch (error) { + const e = error as { + stdout?: string | Buffer; + stderr?: string | Buffer; + }; + const stdout = typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString("utf8") ?? ""); + const stderr = typeof e.stderr === "string" ? e.stderr : (e.stderr?.toString("utf8") ?? ""); + return { ok: false, stdout: stdout.trim(), stderr: stderr.trim() }; + } +} + +describe("scripts/ios-team-id.sh", () => { + it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, + }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub", + ); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +if [[ "$1" == "cms" && "$2" == "-D" ]]; then + cat <<'PLIST' + + + + + TeamIdentifier + + ABCDE12345 + + + +PLIST + exit 0 +fi +exit 0`, + ); + + const result = runScript(homeDir); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("ABCDE12345"); + }); + + it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +echo "Domain/default pair of (com.apple.dt.Xcode, $3) does not exist" >&2 +exit 1`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +exit 1`, + ); + + const result = runScript(homeDir); + expect(result.ok).toBe(false); + expect(result.stderr).toContain("An Apple account is signed in to Xcode"); + expect(result.stderr).toContain("IOS_DEVELOPMENT_TEAM"); + }); + + it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, + }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub1", + ); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "two.mobileprovision"), + "stub2", + ); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "security"), + `#!/usr/bin/env bash +if [[ "$1" == "cms" && "$2" == "-D" ]]; then + if [[ "$4" == *"one.mobileprovision" ]]; then + cat <<'PLIST' + + +TeamIdentifierAAAAA11111 +PLIST + exit 0 + fi + cat <<'PLIST' + + +TeamIdentifierBBBBB22222 +PLIST + exit 0 +fi +exit 0`, + ); + + const result = runScript(homeDir, { IOS_PREFERRED_TEAM_ID: "BBBBB22222" }); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("BBBBB22222"); + }); + + it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { + const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + + await writeExecutable( + path.join(binDir, "plutil"), + `#!/usr/bin/env bash +echo '{}'`, + ); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); + await writeExecutable( + path.join(binDir, "fake-python"), + `#!/usr/bin/env bash +printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n' +printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`, + ); + + const result = runScript(homeDir, { + IOS_PYTHON_BIN: path.join(binDir, "fake-python"), + IOS_PREFERRED_TEAM_ID: "BBBBB22222", + }); + expect(result.ok).toBe(true); + expect(result.stdout).toBe("BBBBB22222"); + }); +}); diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index 3b1cfa0978a3..0a03226ff420 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -18,20 +18,30 @@ class I18nManager { this.loadLocale(); } - private loadLocale() { + private resolveInitialLocale(): Locale { const saved = localStorage.getItem("openclaw.i18n.locale"); if (isSupportedLocale(saved)) { - this.locale = saved; - } else { - const navLang = navigator.language; - if (navLang.startsWith("zh")) { - this.locale = navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; - } else if (navLang.startsWith("pt")) { - this.locale = "pt-BR"; - } else { - this.locale = "en"; - } + return saved; + } + const navLang = navigator.language; + if (navLang.startsWith("zh")) { + return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; + } + if (navLang.startsWith("pt")) { + return "pt-BR"; + } + return "en"; + } + + private loadLocale() { + const initialLocale = this.resolveInitialLocale(); + if (initialLocale === "en") { + this.locale = "en"; + return; } + // Use the normal locale setter so startup locale loading follows the same + // translation-loading + notify path as manual locale changes. + void this.setLocale(initialLocale); } public getLocale(): Locale { @@ -39,12 +49,13 @@ class I18nManager { } public async setLocale(locale: Locale) { - if (this.locale === locale) { + const needsTranslationLoad = !this.translations[locale]; + if (this.locale === locale && !needsTranslationLoad) { return; } // Lazy load translations if needed - if (!this.translations[locale]) { + if (needsTranslationLoad) { try { let module: Record; if (locale === "zh-CN") { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 8d6f32ef2d67..178fd12b1e3b 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { i18n, t } from "../lib/translate.ts"; describe("i18n", () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear(); // Reset to English - void i18n.setLocale("en"); + await i18n.setLocale("en"); }); it("should return the key if translation is missing", () => { @@ -28,4 +28,29 @@ describe("i18n", () => { // but let's assume it falls back to English for now. expect(t("common.health")).toBeDefined(); }); + + it("loads translations even when setting the same locale again", async () => { + const internal = i18n as unknown as { + locale: string; + translations: Record; + }; + internal.locale = "zh-CN"; + delete internal.translations["zh-CN"]; + + await i18n.setLocale("zh-CN"); + expect(t("common.health")).toBe("健康状况"); + }); + + it("loads saved non-English locale on startup", async () => { + localStorage.setItem("openclaw.i18n.locale", "zh-CN"); + vi.resetModules(); + const fresh = await import("../lib/translate.ts"); + + for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) { + await Promise.resolve(); + } + + expect(fresh.i18n.getLocale()).toBe("zh-CN"); + expect(fresh.t("common.health")).toBe("健康状况"); + }); }); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 487ba0bbc538..55eeaedd7e0b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -62,6 +62,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; @@ -289,8 +290,8 @@ export function renderApp(state: AppViewState) { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7c36713c3c0f..df4689b0fa4a 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { AssistantIdentity } from "../assistant-identity.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import { openExternalUrlSafe } from "../open-external-url.ts"; import { detectTextDirection } from "../text-direction.ts"; import type { MessageGroup } from "../types/chat-types.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; @@ -200,6 +201,10 @@ function renderMessageImages(images: ImageBlock[]) { return nothing; } + const openImage = (url: string) => { + openExternalUrlSafe(url, { allowDataImage: true }); + }; + return html`
${images.map( @@ -208,7 +213,7 @@ function renderMessageImages(images: ImageBlock[]) { src=${img.url} alt=${img.alt ?? "Attached image"} class="chat-message-image" - @click=${() => window.open(img.url, "_blank")} + @click=${() => openImage(img.url)} /> `, )} diff --git a/ui/src/ui/external-link.test.ts b/ui/src/ui/external-link.test.ts new file mode 100644 index 000000000000..3c46c7faa30d --- /dev/null +++ b/ui/src/ui/external-link.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildExternalLinkRel } from "./external-link.ts"; + +describe("buildExternalLinkRel", () => { + it("always includes required security tokens", () => { + expect(buildExternalLinkRel()).toBe("noopener noreferrer"); + }); + + it("preserves extra rel tokens while deduping required ones", () => { + expect(buildExternalLinkRel("noreferrer nofollow NOOPENER")).toBe( + "noopener noreferrer nofollow", + ); + }); + + it("ignores whitespace-only rel input", () => { + expect(buildExternalLinkRel(" ")).toBe("noopener noreferrer"); + }); +}); diff --git a/ui/src/ui/external-link.ts b/ui/src/ui/external-link.ts new file mode 100644 index 000000000000..0922da638d02 --- /dev/null +++ b/ui/src/ui/external-link.ts @@ -0,0 +1,19 @@ +const REQUIRED_EXTERNAL_REL_TOKENS = ["noopener", "noreferrer"] as const; + +export const EXTERNAL_LINK_TARGET = "_blank"; + +export function buildExternalLinkRel(currentRel?: string): string { + const extraTokens: string[] = []; + const seen = new Set(REQUIRED_EXTERNAL_REL_TOKENS); + + for (const rawToken of (currentRel ?? "").split(/\s+/)) { + const token = rawToken.trim().toLowerCase(); + if (!token || seen.has(token)) { + continue; + } + seen.add(token); + extraTokens.push(token); + } + + return [...REQUIRED_EXTERNAL_REL_TOKENS, ...extraTokens].join(" "); +} diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts new file mode 100644 index 000000000000..d79ef099bd44 --- /dev/null +++ b/ui/src/ui/open-external-url.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openExternalUrlSafe, resolveSafeExternalUrl } from "./open-external-url.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("resolveSafeExternalUrl", () => { + const baseHref = "https://openclaw.ai/chat"; + + it("allows absolute https URLs", () => { + expect(resolveSafeExternalUrl("https://example.com/a.png?x=1#y", baseHref)).toBe( + "https://example.com/a.png?x=1#y", + ); + }); + + it("allows relative URLs resolved against the current origin", () => { + expect(resolveSafeExternalUrl("/assets/pic.png", baseHref)).toBe( + "https://openclaw.ai/assets/pic.png", + ); + }); + + it("allows blob URLs", () => { + expect(resolveSafeExternalUrl("blob:https://openclaw.ai/abc-123", baseHref)).toBe( + "blob:https://openclaw.ai/abc-123", + ); + }); + + it("allows data image URLs when enabled", () => { + expect( + resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref, { + allowDataImage: true, + }), + ).toBe("data:image/png;base64,iVBORw0KGgo="); + }); + + it("rejects non-image data URLs", () => { + expect( + resolveSafeExternalUrl("data:text/html,", baseHref, { + allowDataImage: true, + }), + ).toBeNull(); + }); + + it("rejects SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml,", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + + it("rejects base64-encoded SVG data image URLs", () => { + expect( + resolveSafeExternalUrl( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIC8+", + baseHref, + { + allowDataImage: true, + }, + ), + ).toBeNull(); + }); + + it("rejects data image URLs unless explicitly enabled", () => { + expect(resolveSafeExternalUrl("data:image/png;base64,iVBORw0KGgo=", baseHref)).toBeNull(); + }); + + it("rejects javascript URLs", () => { + expect(resolveSafeExternalUrl("javascript:alert(1)", baseHref)).toBeNull(); + }); + + it("rejects file URLs", () => { + expect(resolveSafeExternalUrl("file:///tmp/x.png", baseHref)).toBeNull(); + }); + + it("rejects empty values", () => { + expect(resolveSafeExternalUrl(" ", baseHref)).toBeNull(); + }); +}); + +describe("openExternalUrlSafe", () => { + it("nulls opener when window.open returns a proxy-like object", () => { + const openedLikeProxy = { + opener: { postMessage: () => void 0 }, + } as unknown as WindowProxy; + const openMock = vi.fn(() => openedLikeProxy); + vi.stubGlobal("window", { + location: { href: "https://openclaw.ai/chat" }, + open: openMock, + } as unknown as Window & typeof globalThis); + + const opened = openExternalUrlSafe("https://example.com/safe.png"); + + expect(openMock).toHaveBeenCalledWith( + "https://example.com/safe.png", + "_blank", + "noopener,noreferrer", + ); + expect(opened).toBe(openedLikeProxy); + expect(openedLikeProxy.opener).toBeNull(); + }); +}); diff --git a/ui/src/ui/open-external-url.ts b/ui/src/ui/open-external-url.ts new file mode 100644 index 000000000000..ed5a99c86786 --- /dev/null +++ b/ui/src/ui/open-external-url.ts @@ -0,0 +1,73 @@ +const DATA_URL_PREFIX = "data:"; +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "blob:"]); +const BLOCKED_DATA_IMAGE_MIME_TYPES = new Set(["image/svg+xml"]); + +function isAllowedDataImageUrl(url: string): boolean { + if (!url.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return false; + } + + const commaIndex = url.indexOf(","); + if (commaIndex < DATA_URL_PREFIX.length) { + return false; + } + + const metadata = url.slice(DATA_URL_PREFIX.length, commaIndex); + const mimeType = metadata.split(";")[0]?.trim().toLowerCase() ?? ""; + if (!mimeType.startsWith("image/")) { + return false; + } + + return !BLOCKED_DATA_IMAGE_MIME_TYPES.has(mimeType); +} + +export type ResolveSafeExternalUrlOptions = { + allowDataImage?: boolean; +}; + +export function resolveSafeExternalUrl( + rawUrl: string, + baseHref: string, + opts: ResolveSafeExternalUrlOptions = {}, +): string | null { + const candidate = rawUrl.trim(); + if (!candidate) { + return null; + } + + if (opts.allowDataImage === true && isAllowedDataImageUrl(candidate)) { + return candidate; + } + + if (candidate.toLowerCase().startsWith(DATA_URL_PREFIX)) { + return null; + } + + try { + const parsed = new URL(candidate, baseHref); + return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol.toLowerCase()) ? parsed.toString() : null; + } catch { + return null; + } +} + +export type OpenExternalUrlSafeOptions = ResolveSafeExternalUrlOptions & { + baseHref?: string; +}; + +export function openExternalUrlSafe( + rawUrl: string, + opts: OpenExternalUrlSafeOptions = {}, +): WindowProxy | null { + const baseHref = opts.baseHref ?? window.location.href; + const safeUrl = resolveSafeExternalUrl(rawUrl, baseHref, opts); + if (!safeUrl) { + return null; + } + + const opened = window.open(safeUrl, "_blank", "noopener,noreferrer"); + if (opened) { + opened.opener = null; + } + return opened; +} diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index f64c9da6dd6e..d6fda9475c42 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach } from "vitest"; -import { OpenClawApp } from "../app.ts"; +import "../app.ts"; +import type { OpenClawApp } from "../app.ts"; export function mountApp(pathname: string) { window.history.replaceState({}, "", pathname); diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts new file mode 100644 index 000000000000..f63fbcab5b82 --- /dev/null +++ b/ui/src/ui/views/agents-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { resolveEffectiveModelFallbacks } from "./agents-utils.ts"; + +describe("resolveEffectiveModelFallbacks", () => { + it("inherits defaults when no entry fallbacks are configured", () => { + const entryModel = undefined; + const defaultModel = { + primary: "openai/gpt-5-nano", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual([ + "google/gemini-2.0-flash", + ]); + }); + + it("prefers entry fallbacks over defaults", () => { + const entryModel = { + primary: "openai/gpt-5-mini", + fallbacks: ["openai/gpt-5-nano"], + }; + const defaultModel = { + primary: "openai/gpt-5", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual(["openai/gpt-5-nano"]); + }); + + it("keeps explicit empty entry fallback lists", () => { + const entryModel = { + primary: "openai/gpt-5-mini", + fallbacks: [], + }; + const defaultModel = { + primary: "openai/gpt-5", + fallbacks: ["google/gemini-2.0-flash"], + }; + + expect(resolveEffectiveModelFallbacks(entryModel, defaultModel)).toEqual([]); + }); +}); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index c09e4a58ad30..3b72f5e36fb2 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -244,6 +244,13 @@ export function resolveModelFallbacks(model?: unknown): string[] | null { return null; } +export function resolveEffectiveModelFallbacks( + entryModel?: unknown, + defaultModel?: unknown, +): string[] | null { + return resolveModelFallbacks(entryModel) ?? resolveModelFallbacks(defaultModel); +} + export function parseFallbackList(value: string): string[] { return value .split(",") diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 72a0b88a92cf..891190d9abb2 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -24,7 +24,7 @@ import { parseFallbackList, resolveAgentConfig, resolveAgentEmoji, - resolveModelFallbacks, + resolveEffectiveModelFallbacks, resolveModelLabel, resolveModelPrimary, } from "./agents-utils.ts"; @@ -390,7 +390,10 @@ function renderAgentOverview(params: { resolveModelPrimary(config.defaults?.model) || (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveModelFallbacks(config.entry?.model); + const modelFallbacks = resolveEffectiveModelFallbacks( + config.entry?.model, + config.defaults?.model, + ); const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; const identityName = agentIdentity?.name?.trim() || diff --git a/ui/src/ui/views/chat-image-open.browser.test.ts b/ui/src/ui/views/chat-image-open.browser.test.ts new file mode 100644 index 000000000000..9f2090a139b9 --- /dev/null +++ b/ui/src/ui/views/chat-image-open.browser.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mountApp, registerAppMountHooks } from "../test-helpers/app-mount.ts"; + +registerAppMountHooks(); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function renderAssistantImage(url: string) { + return { + role: "assistant", + content: [{ type: "image_url", image_url: { url } }], + timestamp: Date.now(), + }; +} + +describe("chat image open safety", () => { + it("opens safe image URLs in a hardened new tab", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [renderAssistantImage("https://example.com/cat.png")]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/cat.png", + "_blank", + "noopener,noreferrer", + ); + }); + + it("does not open unsafe image URLs", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [renderAssistantImage("javascript:alert(1)")]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("does not open SVG data image URLs", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + app.chatMessages = [ + renderAssistantImage("data:image/svg+xml,"), + ]; + await app.updateComplete; + + const image = app.querySelector(".chat-message-image"); + expect(image).not.toBeNull(); + image?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(openSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 56889c0f6d59..3c341df473b4 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,6 +1,7 @@ import { html } from "lit"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; import { t, i18n, type Locale } from "../../i18n/index.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; import { formatNextRun } from "../presenter.ts"; @@ -59,8 +60,8 @@ export function renderOverview(props: OverviewProps) { Docs: Device pairing @@ -116,8 +117,8 @@ export function renderOverview(props: OverviewProps) { Docs: Control UI auth @@ -132,8 +133,8 @@ export function renderOverview(props: OverviewProps) { Docs: Control UI auth @@ -171,8 +172,8 @@ export function renderOverview(props: OverviewProps) { Docs: Tailscale Serve @@ -180,8 +181,8 @@ export function renderOverview(props: OverviewProps) { Docs: Insecure HTTP diff --git a/vitest.config.ts b/vitest.config.ts index 522e3d2b3145..8b1588489307 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ "src/**/*.test.ts", "extensions/**/*.test.ts", "test/**/*.test.ts", + "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts", ],