diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abb5b50a5ce..8de4f3882c8 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. @@ -317,7 +320,9 @@ jobs: - name: Check docs run: pnpm check:docs - secrets: + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout @@ -330,10 +335,39 @@ jobs: with: python-version: "3.12" - - name: Install detect-secrets + - name: Install Python tooling run: | python -m pip install --upgrade pip - python -m pip install detect-secrets==1.5.0 + python -m pip install pytest ruff pyyaml + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + + secrets: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: | + python -m pip install --upgrade pip + python -m pip install pre-commit detect-secrets==1.5.0 - name: Detect secrets run: | @@ -342,6 +376,30 @@ jobs: exit 1 fi + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + checks-windows: needs: [docs-scope, changed-scope, build-artifacts, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') diff --git a/.gitignore b/.gitignore index 69d89b2c4cd..b5d3257e7e6 100644 --- a/.gitignore +++ b/.gitignore @@ -98,9 +98,25 @@ package-lock.json .agents/ .agents .agent/ +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/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..9190f88b6e0 --- /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/.pre-commit-config.yaml b/.pre-commit-config.yaml index e946d18c112..30b6363a34d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,8 @@ repos: - id: check-added-large-files args: [--maxkb=500] - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' # Secret detection (same as CI) - repo: https://github.com/Yelp/detect-secrets @@ -45,7 +47,6 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' - # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 @@ -69,9 +70,34 @@ repos: args: [--persona=regular, --min-severity=medium, --min-confidence=medium] exclude: "^(vendor/|Swabble/)" + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + # Project checks (same commands as CI) - repo: local hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + # oxlint --type-aware src test - id: oxlint name: oxlint diff --git a/AGENTS.md b/AGENTS.md index 0b3cf42b4dd..09ed6423ac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ - Repo: https://github.com/openclaw/openclaw - GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". +- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. +- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). +- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. ## Project Structure & Module Organization @@ -83,6 +86,7 @@ - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. - beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. - dev: moving head on `main` (no tag; git checkout main). ## Testing Guidelines @@ -91,6 +95,7 @@ - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. - Do not set test workers above 16; tried already. +- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Full kit + what’s covered: `docs/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). @@ -202,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 9ee24be3329..6efa7d35cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,214 @@ Docs: https://docs.openclaw.ai -## Unreleased - -## 2026.2.22 (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. +- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. + +### 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. +- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. +- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r. +- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman. +- 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:** 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 + +- 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 + +- Providers/Kilo Gateway: add first-class `kilocode` provider support (auth, onboarding, implicit provider detection, model defaults, transcript/cache-ttl handling, and docs), with default model `kilocode/anthropic/claude-opus-4.6`. (#20212) Thanks @jrf0110 and @markijbema. +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. +- 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 + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + +### Fixes + +- 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. +- 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. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- 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/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. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Auto-reply/Inbound metadata: move dynamic inbound `flags` (reply/forward/thread/history) from system metadata to user-context conversation info, preventing turn-by-turn prompt-cache invalidation from flag toggles. (#21785) Thanks @aidiffuser. +- Auto-reply/Sessions: remove auth-key labels from `/new` and `/reset` confirmation messages so session reset notices never expose API key prefixes or env-key labels in chat output. (#24384, #24409) Thanks @Clawborn. +- Slack/Group policy: move Slack account `groupPolicy` defaulting to provider-level schema defaults so multi-account configs inherit top-level `channels.slack.groupPolicy` instead of silently overriding inheritance with per-account `allowlist`. (#17579) Thanks @ZetiMente. +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. +- Providers/DashScope: mark DashScope-compatible `openai-completions` endpoints as `supportsDeveloperRole=false` so OpenClaw sends `system` instead of unsupported `developer` role on Qwen/DashScope APIs. (#19130) Thanks @Putzhuawa and @vincentkoc. +- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope. +- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. +- 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. 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. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. + +## 2026.2.22 + +### Changes + +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - 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. @@ -27,6 +229,7 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. - **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. @@ -34,7 +237,14 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. +- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) - 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) @@ -77,6 +287,9 @@ Docs: https://docs.openclaw.ai - 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/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. +- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. - 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) @@ -84,15 +297,16 @@ 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) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. - 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) @@ -132,6 +346,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. @@ -151,16 +366,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. @@ -194,6 +409,7 @@ Docs: https://docs.openclaw.ai - 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: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. - 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. @@ -208,28 +424,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 2beaeeba290..1386bc4881a 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) @@ -51,7 +54,7 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 00000000000..1887eca27d9 --- /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 72f362418d7..1dcad2b7e12 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,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)**. @@ -38,7 +38,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). @@ -146,13 +145,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - [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). +- [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/channels/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). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes @@ -171,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### 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). +- [Channel routing](https://docs.openclaw.ai/channels/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). @@ -503,78 +502,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 1a26e7541c0..eb42a335572 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives: - **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) - **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. +For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it. For full reporting instructions see our [Trust page](https://trust.openclaw.ai). @@ -30,6 +30,41 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai). Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). +- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +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. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + ## Security & Trust **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. @@ -43,13 +78,43 @@ The best way to help the project right now is by sending PRs. When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. +## Operator Trust Model (Important) + +OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. + +- Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. +- If one operator can view data from another operator on the same gateway, that is expected in this trust model. +- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. +- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway. +- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user. +- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default. +- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`. +- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host. +- 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 - Using OpenClaw in ways that the docs recommend not to -- Deployments where mutually untrusted/adversarial operators share one gateway host and config -- Prompt injection attacks +- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) +- 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 +- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass. ## Deployment Assumptions @@ -59,6 +124,33 @@ OpenClaw security guidance assumes: - Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. - Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. +- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user). + +## One-User Trust Model (Personal Assistant) + +OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." + +- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. +- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. +- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. +- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. +- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk. + +## Agent and Model Assumptions + +- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. +- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals. +- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. + +## Gateway and Node trust concept + +OpenClaw separates routing from execution, but both remain inside the same operator trust boundary: + +- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway. +- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node. +- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary. +- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary. ## Workspace Memory Trust Boundary @@ -77,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: @@ -86,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 0f8acfe3a3a..902d60972fd 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 c2ae5a2179b..799109c0a0f 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 @@ -21,18 +34,71 @@ cd apps/android `gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. +## Run on a Real Android Phone (USB) + +1) On phone, enable **Developer options** + **USB debugging**. +2) Connect by USB and accept the debugging trust prompt on phone. +3) Verify ADB can see the device: + +```bash +adb devices -l +``` + +4) Install + launch debug build: + +```bash +pnpm android:install +pnpm android:run +``` + +If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again. + +### USB-only gateway testing (no LAN dependency) + +Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`. + +Terminal A (gateway): + +```bash +pnpm openclaw gateway --port 18789 --verbose +``` + +Terminal B (USB tunnel): + +```bash +adb reverse tcp:18789 tcp:18789 +``` + +Then in app **Connect → Manual**: + +- Host: `127.0.0.1` +- Port: `18789` +- TLS: off + +## Hot Reload / Fast Iteration + +This app is native Kotlin + Jetpack Compose. + +- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement). +- For many non-structural code/resource changes: use Android Studio **Apply Changes**. +- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`). +- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`). + ## 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 +115,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 00000000000..472064afc4b --- /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 b91b1e21537..dda17320625 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 = 202602210 - versionName = "2026.2.21" + 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") @@ -133,15 +137,16 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.5.2") implementation("androidx.camera:camera-video:1.5.2") implementation("androidx.camera:camera-view:1.5.2") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. implementation("dnsjava:dnsjava:3.6.4") 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/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index facdbf301b4..6b8dd7eedba 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ 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 2bbfd8712f9..21d0f15ff7a 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,20 +1,15 @@ 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 import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.core.view.WindowCompat 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 import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -29,11 +24,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 WebView.setWebContentsDebuggingEnabled(isDebuggable) - applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() NodeForegroundService.start(this) permissionRequester = PermissionRequester(this) screenCaptureRequester = ScreenCaptureRequester(this) @@ -64,18 +57,6 @@ class MainActivity : ComponentActivity() { } } - override fun onResume() { - super.onResume() - applyImmersiveMode() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - applyImmersiveMode() - } - } - override fun onStart() { super.onStart() viewModel.setForeground(true) @@ -85,46 +66,4 @@ class MainActivity : ComponentActivity() { viewModel.setForeground(false) super.onStop() } - - private fun applyImmersiveMode() { - WindowCompat.setDecorFitsSystemWindows(window, false) - val controller = WindowInsetsControllerCompat(window, window.decorView) - controller.systemBarsBehavior = - 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 d9123d10293..e0d68c77e69 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 @@ -8,12 +8,17 @@ import ai.openclaw.android.node.CameraCaptureManager import ai.openclaw.android.node.CanvasController import ai.openclaw.android.node.ScreenRecordManager import ai.openclaw.android.node.SmsManager +import ai.openclaw.android.voice.VoiceConversationEntry import kotlinx.coroutines.flow.StateFlow 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 +27,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 @@ -40,19 +46,20 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val locationMode: StateFlow = runtime.locationMode val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled val preventSleep: StateFlow = runtime.preventSleep - val wakeWords: StateFlow> = runtime.wakeWords - val voiceWakeMode: StateFlow = runtime.voiceWakeMode - val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText - val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening - val talkEnabled: StateFlow = runtime.talkEnabled - val talkStatusText: StateFlow = runtime.talkStatusText - val talkIsListening: StateFlow = runtime.talkIsListening - val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking + val micEnabled: StateFlow = runtime.micEnabled + val micStatusText: StateFlow = runtime.micStatusText + val micLiveTranscript: StateFlow = runtime.micLiveTranscript + val micIsListening: StateFlow = runtime.micIsListening + val micQueuedMessages: StateFlow> = runtime.micQueuedMessages + val micConversation: StateFlow> = runtime.micConversation + val micInputLevel: StateFlow = runtime.micInputLevel + val micIsSending: StateFlow = runtime.micIsSending val manualEnabled: StateFlow = runtime.manualEnabled val manualHost: StateFlow = runtime.manualHost 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,24 +117,20 @@ 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) } - fun setWakeWords(words: List) { - runtime.setWakeWords(words) - } - - fun resetWakeWordsDefaults() { - runtime.resetWakeWordsDefaults() - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - runtime.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(enabled: Boolean) { - runtime.setTalkEnabled(enabled) + fun setMicEnabled(enabled: Boolean) { + runtime.setMicEnabled(enabled) } fun refreshGatewayConnection() { @@ -158,6 +161,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/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt index ee7c8e00674..a6a79dc9c4a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt @@ -39,22 +39,22 @@ class NodeForegroundService : Service() { runtime.statusText, runtime.serverName, runtime.isConnected, - runtime.voiceWakeMode, - runtime.voiceWakeIsListening, - ) { status, server, connected, voiceMode, voiceListening -> - Quint(status, server, connected, voiceMode, voiceListening) - }.collect { (status, server, connected, voiceMode, voiceListening) -> + runtime.micEnabled, + runtime.micIsListening, + ) { status, server, connected, micEnabled, micListening -> + Quint(status, server, connected, micEnabled, micListening) + }.collect { (status, server, connected, micEnabled, micListening) -> val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node" - val voiceSuffix = - if (voiceMode == VoiceWakeMode.Always) { - if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" + val micSuffix = + if (micEnabled) { + if (micListening) " · Mic: Listening" else " · Mic: Pending" } else { "" } - val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix + val text = (server?.let { "$status · $it" } ?: status) + micSuffix val requiresMic = - voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() + micEnabled && hasRecordAudioPermission() startForegroundWithTypes( notification = buildNotification(title = title, text = text), requiresMic = requiresMic, 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 aec192c25bb..15d99ffb931 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 @@ -18,8 +19,8 @@ import ai.openclaw.android.gateway.GatewaySession import ai.openclaw.android.gateway.probeGatewayTlsFingerprint import ai.openclaw.android.node.* import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction -import ai.openclaw.android.voice.TalkModeManager -import ai.openclaw.android.voice.VoiceWakeManager +import ai.openclaw.android.voice.MicCaptureManager +import ai.openclaw.android.voice.VoiceConversationEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,6 +37,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import java.util.UUID import java.util.concurrent.atomic.AtomicLong class NodeRuntime(context: Context) { @@ -53,40 +55,6 @@ class NodeRuntime(context: Context) { private val externalAudioCaptureActive = MutableStateFlow(false) - private val voiceWake: VoiceWakeManager by lazy { - VoiceWakeManager( - context = appContext, - scope = scope, - onCommand = { command -> - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive(resolveMainSessionKey())) - put("thinking", JsonPrimitive(chatThinkingLevel.value)) - put("deliver", JsonPrimitive(false)) - }.toString(), - ) - }, - ) - } - - val voiceWakeIsListening: StateFlow - get() = voiceWake.isListening - - val voiceWakeStatusText: StateFlow - get() = voiceWake.statusText - - val talkStatusText: StateFlow - get() = talkMode.statusText - - val talkIsListening: StateFlow - get() = talkMode.isListening - - val talkIsSpeaking: StateFlow - get() = talkMode.isSpeaking - private val discovery = GatewayDiscovery(appContext, scope = scope) val gateways: StateFlow> = discovery.gateways val discoveryStatusText: StateFlow = discovery.statusText @@ -145,7 +113,7 @@ class NodeRuntime(context: Context) { prefs = prefs, cameraEnabled = { cameraEnabled.value }, locationMode = { locationMode.value }, - voiceWakeMode = { voiceWakeMode.value }, + voiceWakeMode = { VoiceWakeMode.Off }, smsAvailable = { sms.canSendSms() }, hasRecordAudioPermission = { hasRecordAudioPermission() }, manualTls = { manualTls.value }, @@ -163,10 +131,14 @@ 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 - data class GatewayTrustPrompt( val endpoint: GatewayEndpoint, val fingerprintSha256: String, @@ -174,6 +146,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 +168,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 +188,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" @@ -225,8 +207,8 @@ class NodeRuntime(context: Context) { _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB applyMainSessionKey(mainSessionKey) updateStatus() + micCapture.onGatewayConnectionChanged(true) scope.launch { refreshBrandingFromGateway() } - scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() } }, onDisconnected = { message -> operatorConnected = false @@ -237,11 +219,10 @@ class NodeRuntime(context: Context) { if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { _mainSessionKey.value = "main" } - val mainKey = resolveMainSessionKey() - talkMode.setMainSessionKey(mainKey) - chat.applyMainSessionKey(mainKey) + chat.applyMainSessionKey(resolveMainSessionKey()) chat.onDisconnected(message) updateStatus() + micCapture.onGatewayConnectionChanged(false) }, onEvent = { event, payloadJson -> handleGatewayEvent(event, payloadJson) @@ -254,14 +235,22 @@ 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() }, onDisconnected = { message -> - nodeConnected = false + _nodeConnected.value = false nodeStatusText = message + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null updateStatus() showLocalCanvasOnDisconnect() }, @@ -281,34 +270,74 @@ class NodeRuntime(context: Context) { json = json, supportsChatSubscribe = false, ) - private val talkMode: TalkModeManager by lazy { - TalkModeManager( + private val micCapture: MicCaptureManager by lazy { + MicCaptureManager( context = appContext, scope = scope, - session = operatorSession, - supportsChatSubscribe = false, - isConnected = { operatorConnected }, + sendToGateway = { message -> + val idempotencyKey = UUID.randomUUID().toString() + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + put("message", JsonPrimitive(message)) + put("thinking", JsonPrimitive(chatThinkingLevel.value)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(idempotencyKey)) + } + val response = operatorSession.request("chat.send", params.toString()) + parseChatSendRunId(response) ?: idempotencyKey + }, ) } + val micStatusText: StateFlow + get() = micCapture.statusText + + val micLiveTranscript: StateFlow + get() = micCapture.liveTranscript + + val micIsListening: StateFlow + get() = micCapture.isListening + + val micEnabled: StateFlow + get() = micCapture.micEnabled + + val micQueuedMessages: StateFlow> + get() = micCapture.queuedMessages + + val micConversation: StateFlow> + get() = micCapture.conversation + + val micInputLevel: StateFlow + get() = micCapture.inputLevel + + val micIsSending: StateFlow + get() = micCapture.isSending + private fun applyMainSessionKey(candidate: String?) { val trimmed = normalizeMainKey(candidate) ?: return if (isCanonicalMainSessionKey(_mainSessionKey.value)) return if (_mainSessionKey.value == trimmed) return _mainSessionKey.value = trimmed - talkMode.setMainSessionKey(trimmed) chat.applyMainSessionKey(trimmed) } private fun updateStatus() { _isConnected.value = operatorConnected + val operator = operatorStatusText.trim() + val node = nodeStatusText.trim() _statusText.value = when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" - operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText - else -> nodeStatusText + operatorConnected && _nodeConnected.value -> "Connected" + operatorConnected && !_nodeConnected.value -> "Connected (node offline)" + !operatorConnected && _nodeConnected.value -> + if (operator.isNotEmpty() && operator != "Offline") { + "Connected (operator: $operator)" + } else { + "Connected (operator offline)" + } + operator.isNotBlank() && operator != "Offline" -> operator + else -> node } } @@ -328,24 +357,78 @@ 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 val locationMode: StateFlow = prefs.locationMode val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled val preventSleep: StateFlow = prefs.preventSleep - val wakeWords: StateFlow> = prefs.wakeWords - val voiceWakeMode: StateFlow = prefs.voiceWakeMode - val talkEnabled: StateFlow = prefs.talkEnabled val manualEnabled: StateFlow = prefs.manualEnabled val manualHost: StateFlow = prefs.manualHost 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 @@ -363,50 +446,13 @@ class NodeRuntime(context: Context) { val pendingRunCount: StateFlow = chat.pendingRunCount init { - gatewayEventHandler = GatewayEventHandler( - scope = scope, - prefs = prefs, - json = json, - operatorSession = operatorSession, - isConnected = { _isConnected.value }, - ) - - scope.launch { - combine( - voiceWakeMode, - isForeground, - externalAudioCaptureActive, - wakeWords, - ) { mode, foreground, externalAudio, words -> - Quad(mode, foreground, externalAudio, words) - }.distinctUntilChanged() - .collect { (mode, foreground, externalAudio, words) -> - voiceWake.setTriggerWords(words) - - val shouldListen = - when (mode) { - VoiceWakeMode.Off -> false - VoiceWakeMode.Foreground -> foreground - VoiceWakeMode.Always -> true - } && !externalAudio - - if (!shouldListen) { - voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") - return@collect - } - - if (!hasRecordAudioPermission()) { - voiceWake.stop(statusText = "Microphone permission required") - return@collect - } - - voiceWake.start() - } + if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) { + prefs.setVoiceWakeMode(VoiceWakeMode.Off) } scope.launch { - talkEnabled.collect { enabled -> - talkMode.setEnabled(enabled) + prefs.talkEnabled.collect { enabled -> + micCapture.setMicEnabled(enabled) externalAudioCaptureActive.value = enabled } } @@ -514,25 +560,20 @@ class NodeRuntime(context: Context) { prefs.setCanvasDebugStatusEnabled(value) } - fun setWakeWords(words: List) { - prefs.setWakeWords(words) - gatewayEventHandler.scheduleWakeWordsSyncIfNeeded() - } - - fun resetWakeWordsDefaults() { - setWakeWords(SecurePrefs.defaultWakeWords) - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(value: Boolean) { + fun setMicEnabled(value: Boolean) { prefs.setTalkEnabled(value) + micCapture.setMicEnabled(value) + externalAudioCaptureActive.value = value } fun refreshGatewayConnection() { - val endpoint = connectedEndpoint ?: return + val endpoint = + connectedEndpoint ?: run { + _statusText.value = "Failed: no cached gateway endpoint" + return + } + operatorStatusText = "Connecting…" + updateStatus() val token = prefs.loadGatewayToken() val password = prefs.loadGatewayPassword() val tls = connectionManager.resolveTlsParams(endpoint) @@ -639,10 +680,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 +695,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" @@ -705,15 +746,19 @@ class NodeRuntime(context: Context) { } private fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event == "voicewake.changed") { - gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson) - return - } - - talkMode.handleGatewayEvent(event, payloadJson) + micCapture.handleGatewayEvent(event, payloadJson) chat.handleGatewayEvent(event, payloadJson) } + private fun parseChatSendRunId(response: String): String? { + return try { + val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null + root["runId"].asStringOrNull() + } catch (_: Throwable) { + null + } + } + private suspend fun refreshBrandingFromGateway() { if (!_isConnected.value) return try { 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 29ef4a3eaae..f03e2b56e0b 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 3ed69ee5b24..335f3b0d70b 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 0f49541daff..92acf968954 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 @@ -62,6 +62,11 @@ class GatewaySession( private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, ) { + private companion object { + // Keep connect timeout above observed gateway unauthorized close on lower-end devices. + private const val CONNECT_RPC_TIMEOUT_MS = 12_000L + } + data class InvokeRequest( val id: String, val nodeId: String, @@ -131,8 +136,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 +152,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 } } @@ -300,17 +307,19 @@ class GatewaySession( val identity = identityStore.loadOrCreate() 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() + // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. + val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) + val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) if (!res.ok) { val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { - deviceAuthStore.clearToken(identity.deviceId, options.role) - } 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 +327,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 +619,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" } + val suffix = buildUrlSuffix(parsed) - // 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" + // 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 +653,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 c46770a6367..d0747ee32b0 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 d15d928e0a4..9b449fc85f3 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 e44896db0fa..91e9da8add1 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 00000000000..f733d154ed9 --- /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 00000000000..875b82796d3 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -0,0 +1,493 @@ +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 + } + if (statusText.contains("operator offline", ignoreCase = true)) { + validationText = null + viewModel.refreshGatewayConnection() + 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) + + 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 00000000000..4421a82be4b --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -0,0 +1,142 @@ +package ai.openclaw.android.ui + +import androidx.core.net.toUri +import java.util.Base64 +import java.util.Locale +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.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, +) + +private val gatewaySetupJson = Json { ignoreUnknownKeys = true } + +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.getDecoder().decode(padded), Charsets.UTF_8) + val obj = parseJsonObject(decoded) ?: return null + val url = jsonField(obj, "url").orEmpty() + if (url.isEmpty()) return null + val token = jsonField(obj, "token") + val password = jsonField(obj, "password") + GatewaySetupCode(url = url, token = token, password = password) + } catch (_: IllegalArgumentException) { + null + } +} + +internal fun resolveScannedSetupCode(rawInput: String): String? { + val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null + return setupCode.takeIf { decodeGatewaySetupCode(it) != 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" +} + +private fun parseJsonObject(input: String): JsonObject? { + return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull() +} + +private fun resolveSetupCodeCandidate(rawInput: String): String? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") } + return qrSetupCode ?: trimmed +} + +private fun jsonField(obj: JsonObject, key: String): String? { + val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty() + return value.ifEmpty { null } +} 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 00000000000..eb4f95775e7 --- /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 00000000000..4c9e064e6af --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -0,0 +1,1209 @@ +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.animation.AnimatedVisibility +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.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +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 +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +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 gatewayAdvancedOpen by rememberSaveable { mutableStateOf(false) } + 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 + } + + val qrScanLauncher = + rememberLauncherForActivityResult(ScanContract()) { result -> + val contents = result.contents?.trim().orEmpty() + if (contents.isEmpty()) { + return@rememberLauncherForActivityResult + } + val scannedSetupCode = resolveScannedSetupCode(contents) + if (scannedSetupCode == null) { + gatewayError = "QR code did not contain a valid setup code." + return@rememberLauncherForActivityResult + } + setupCode = scannedSetupCode + gatewayInputMode = GatewayInputMode.SetupCode + gatewayError = null + attemptedConnect = false + } + + 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, + advancedOpen = gatewayAdvancedOpen, + setupCode = setupCode, + manualHost = manualHost, + manualPort = manualPort, + manualTls = manualTls, + gatewayToken = persistedGatewayToken, + gatewayPassword = gatewayPassword, + gatewayError = gatewayError, + onScanQrClick = { + gatewayError = null + qrScanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan OpenClaw onboarding QR") + setBeepEnabled(false) + setOrientationLocked(false) + }, + ) + }, + onAdvancedOpenChange = { gatewayAdvancedOpen = it }, + 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) "QR / 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 = "Scan QR code first, or use Advanced setup." + 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, + advancedOpen: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + gatewayToken: String, + gatewayPassword: String, + gatewayError: String?, + onScanQrClick: () -> Unit, + onAdvancedOpenChange: (Boolean) -> Unit, + 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 = "Scan onboarding QR") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr") + Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + Button( + onClick = onScanQrClick, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + ), + ) { + Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + if (!resolvedEndpoint.isNullOrBlank()) { + Text("QR captured. Review endpoint below.", style = onboardingCalloutStyle, color = onboardingSuccess) + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorderStrong), + onClick = { onAdvancedOpenChange(!advancedOpen) }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText) + Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced setup" else "Expand advanced setup", + tint = onboardingTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GuideBlock(title = "Manual setup commands") { + 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 = "Voice tab transcription", + 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 00000000000..1345d8e3cb9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,320 @@ +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.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.ime +import androidx.compose.foundation.layout.navigationBars +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.layout.consumeWindowInsets +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.platform.LocalDensity +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 +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + + 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 + } + } + + val density = LocalDensity.current + val imeVisible = WindowInsets.ime.getBottom(density) > 0 + val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + if (!hideBottomTabBar) { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + } + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) + 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(), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + 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() + .windowInsetsPadding(safeInsets) + .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), + ) + } + } + } + } + } + } +} 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 af0cfe628ac..e50a03cc5bf 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()) + if (!onboardingCompleted) { + OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + return } - // 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) - } - } - } -} - -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 bb04c30108c..6de3151a7f1 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 @@ -9,10 +9,12 @@ import android.os.Build 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,29 +25,27 @@ 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 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.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,51 +53,33 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color +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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner 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 @Composable fun SettingsSheet(viewModel: MainViewModel) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val instanceId by viewModel.instanceId.collectAsState() val displayName by viewModel.displayName.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState() val locationMode by viewModel.locationMode.collectAsState() val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val preventSleep by viewModel.preventSleep.collectAsState() - val wakeWords by viewModel.wakeWords.collectAsState() - 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 = remember { listOfNotNull(Build.MANUFACTURER, Build.MODEL) @@ -114,39 +96,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 = { - val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) - if (parsed != null) { - viewModel.setWakeWords(parsed) - } - } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> @@ -189,9 +146,16 @@ fun SettingsSheet(viewModel: MainViewModel) { } } + var micPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED, + ) + } val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> - // Status text is handled by NodeRuntime. + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + micPermissionGranted = granted } val smsPermissionAvailable = @@ -211,6 +175,22 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.refreshGatewayConnection() } + DisposableEffect(lifecycleOwner, context) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + micPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + smsPermissionGranted = + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + fun setCameraEnabledChecked(checked: Boolean) { if (!checked) { viewModel.setCameraEnabled(false) @@ -268,324 +248,152 @@ 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) } + 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 { + 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( + "NODE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } item { OutlinedTextField( value = displayName, onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, modifier = Modifier.fillMaxWidth(), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), ) } - 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 { 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) } - item { HorizontalDivider() } + item { HorizontalDivider(color = mobileBorder) } - // 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()) { + // Voice item { Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, ) } - 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") - } - }, - ) - } - } item { - Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - 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) - }, - ) - } - 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) }, - ) - - 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() } - - // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } - item { - val enabled = voiceWakeMode != VoiceWakeMode.Off - ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, - trailingContent = { - Switch( - checked = enabled, - onCheckedChange = { on -> - if (on) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Microphone permission", style = mobileHeadline) }, + supportingContent = { + Text( + if (micPermissionGranted) { + "Granted. Use the Voice tab mic button to capture transcript." } else { - viewModel.setVoiceWakeMode(VoiceWakeMode.Off) - } - }, - ) - }, - ) - } - item { - 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.") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Foreground, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - }, + "Required for Voice tab transcription." + }, + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (micPermissionGranted) { + openAppSettings(context) + } else { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (micPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Always, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Always) - }, - ) - }, - ) - } - } - } - item { - OutlinedTextField( - value = wakeWordsText, - onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, - modifier = - Modifier.fillMaxWidth().onFocusChanged { focusState -> - if (focusState.isFocused) { - wakeWordsHadFocus = true - } else if (wakeWordsHadFocus) { - wakeWordsHadFocus = false - commitWakeWords() } }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - commitWakeWords() - focusManager.clearFocus() - }, - ), - ) - } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } - item { - Text( - if (isConnected) { - "Any node can edit wake words. Changes sync via the gateway." - } else { - "Connect to a gateway to sync wake words globally." - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ) + } + item { + Text( + "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.", + 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 +402,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 +412,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } else { "SMS requires a device with telephony hardware." }, + style = mobileCallout, ) }, trailingContent = { @@ -615,91 +426,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 +554,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 d608fc38a7b..00000000000 --- 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/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt new file mode 100644 index 00000000000..9149a0f0886 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt @@ -0,0 +1,451 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.draw.alpha +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.voice.VoiceConversationEntry +import ai.openclaw.android.voice.VoiceConversationRole +import kotlin.math.PI +import kotlin.math.max +import kotlin.math.sin + +@Composable +fun VoiceTabScreen(viewModel: MainViewModel) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val activity = remember(context) { context.findActivity() } + val listState = rememberLazyListState() + + val isConnected by viewModel.isConnected.collectAsState() + val gatewayStatus by viewModel.statusText.collectAsState() + val micEnabled by viewModel.micEnabled.collectAsState() + val micStatusText by viewModel.micStatusText.collectAsState() + val micLiveTranscript by viewModel.micLiveTranscript.collectAsState() + val micQueuedMessages by viewModel.micQueuedMessages.collectAsState() + val micConversation by viewModel.micConversation.collectAsState() + val micInputLevel by viewModel.micInputLevel.collectAsState() + val micIsSending by viewModel.micIsSending.collectAsState() + + val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming } + val showThinkingBubble = micIsSending && !hasStreamingAssistant + + var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) } + var pendingMicEnable by remember { mutableStateOf(false) } + + DisposableEffect(lifecycleOwner, context) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasMicPermission = context.hasRecordAudioPermission() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val requestMicPermission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + hasMicPermission = granted + if (granted && pendingMicEnable) { + viewModel.setMicEnabled(true) + } + pendingMicEnable = false + } + + LaunchedEffect(micConversation.size, showThinkingBubble) { + val total = micConversation.size + if (showThinkingBubble) 1 else 0 + if (total > 0) { + listState.animateScrollToItem(total - 1) + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(mobileBackgroundGradient) + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Voice mode", style = mobileTitle2, color = mobileText) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong, + border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong), + ) { + Text( + if (isConnected) "Connected" else "Offline", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = mobileCaption1, + color = if (isConnected) mobileAccent else mobileTextSecondary, + ) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (micConversation.isEmpty() && !showThinkingBubble) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Tap the mic and speak. Each pause sends a turn automatically.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } + } + + items(items = micConversation, key = { it.id }) { entry -> + VoiceTurnBubble(entry = entry) + } + + if (showThinkingBubble) { + item { + VoiceThinkingBubble() + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + val queueCount = micQueuedMessages.size + val stateText = + when { + queueCount > 0 -> "$queueCount queued" + micIsSending -> "Sending" + micEnabled -> "Listening" + else -> "Mic off" + } + Text( + "$gatewayStatus · $stateText", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + style = mobileCaption1, + color = mobileTextSecondary, + ) + } + + if (!micLiveTranscript.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)), + ) { + Text( + micLiveTranscript!!.trim(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout, + color = mobileText, + ) + } + } + + MicWaveform(level = micInputLevel, active = micEnabled) + + Button( + onClick = { + if (micEnabled) { + viewModel.setMicEnabled(false) + return@Button + } + if (hasMicPermission) { + viewModel.setMicEnabled(true) + } else { + pendingMicEnable = true + requestMicPermission.launch(Manifest.permission.RECORD_AUDIO) + } + }, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(86.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (micEnabled) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on", + modifier = Modifier.size(30.dp), + ) + } + + Text( + if (micEnabled) "Tap to stop" else "Tap to speak", + style = mobileCallout, + color = mobileTextSecondary, + ) + + if (!hasMicPermission) { + val showRationale = + if (activity == null) { + false + } else { + ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO) + } + Text( + if (showRationale) { + "Microphone permission is required for voice mode." + } else { + "Microphone blocked. Open app settings to enable it." + }, + style = mobileCaption1, + color = mobileWarning, + textAlign = TextAlign.Center, + ) + Button( + onClick = { openAppSettings(context) }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText), + ) { + Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold)) + } + } + + Text( + micStatusText, + style = mobileCaption1, + color = mobileTextTertiary, + ) + } + } + } +} + +@Composable +private fun VoiceTurnBubble(entry: VoiceConversationEntry) { + val isUser = entry.role == VoiceConversationRole.User + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.90f), + shape = RoundedCornerShape(14.dp), + color = if (isUser) mobileAccentSoft else mobileSurface, + border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + if (isUser) "You" else "OpenClaw", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Text( + if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text, + style = mobileCallout, + color = mobileText, + ) + } + } + } +} + +@Composable +private fun VoiceThinkingBubble() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + modifier = Modifier.fillMaxWidth(0.68f), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ThinkingDots(color = mobileTextSecondary) + Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary) + } + } + } +} + +@Composable +private fun ThinkingDots(color: Color) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + ThinkingDot(alpha = 0.38f, color = color) + ThinkingDot(alpha = 0.62f, color = color) + ThinkingDot(alpha = 0.90f, color = color) + } +} + +@Composable +private fun ThinkingDot(alpha: Float, color: Color) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = color, + ) {} +} + +@Composable +private fun MicWaveform(level: Float, active: Boolean) { + val transition = rememberInfiniteTransition(label = "voiceWave") + val phase by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart), + label = "voiceWavePhase", + ) + + val effective = if (active) level.coerceIn(0f, 1f) else 0f + val base = max(effective, if (active) 0.05f else 0f) + + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(16) { index -> + val pulse = + if (!active) { + 0f + } else { + ((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat() + } + val barHeight = 6.dp + (24.dp * (base * pulse)) + Box( + modifier = + Modifier + .width(5.dp) + .height(barHeight) + .background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)), + ) + } + } +} + +private fun Context.hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) +} + +private fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) +} 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 07ba769697d..22099500ebf 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,36 @@ 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.PaddingValues 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,149 +42,168 @@ 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) - } - - 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)) - } - }, - ) - } + Text( + text = "Thinking: ${thinkingLabel(thinkingLevel)}", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) } } - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1) - } - - 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 } - } - } - - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") + 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) - } - - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message OpenClaw…") }, - minLines = 2, - maxLines = 6, + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + onClick = onPickImages, ) + } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } - 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") - } - } + HorizontalDivider(color = mobileBorder) + + Text( + text = "MESSAGE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), + color = mobileTextSecondary, + ) + + 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(), + ) + + 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, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + compact = true, + onClick = onRefresh, + ) + + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + compact = true, + onClick = onAbort, + ) } - if (!errorText.isNullOrBlank()) { + 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( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, + text = "Send", + style = mobileHeadline.copy(fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -187,26 +211,35 @@ fun ChatComposer( } @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, + compact: Boolean = false, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = if (compact) Modifier.size(44.dp) else 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 = if (compact) PaddingValues(0.dp) else 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) + Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp)) + if (!compact) { + Spacer(modifier = Modifier.width(5.dp)) Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = label, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = if (enabled) mobileTextSecondary else mobileTextTertiary, ) } } @@ -220,14 +253,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 +299,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 77dba2275a4..e121212529a 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 + 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 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 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 = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = textColor, ) } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) - } - } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + } + node = current.next + } +} + +@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 + } + + val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) } + if (annotated.text.trimEnd().isEmpty()) { + return + } + + Text( + text = annotated, + style = mobileCallout, + color = textColor, + ) +} + +@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 + } + } +} + +@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 + } + } +} + +@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 + } + + 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, + ) + } + } +} + +@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), + ) } } } } } -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 -} - -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() - - 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 +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)) } - - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) - } - - 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 - } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) - - idx = fenceEnd + 3 + child = child.next } - - return out + return rows } -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() - - 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))) - - 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 } - - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out + return rows } -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") +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) +} - 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 buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString { + return buildAnnotatedString { + appendInlineNode( + node = start, + inlineCodeBg = inlineStyles.inlineCodeBg, + inlineCodeColor = inlineStyles.inlineCodeColor, + ) + } +} + +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) } } - - append(text[i]) - i += 1 + 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 } - return out } +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) + } +} + +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 bcec19a5fa2..889de006cb4 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) - } - } - - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } - } - - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) + } } + + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() + } + } + + 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 bf294327551..3f4250c3dbb 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 56b5cfb1faf..00000000000 --- 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 effee6708e0..12e13ab365a 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,19 @@ 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.imePadding 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 +26,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,52 +99,160 @@ 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) - }, - onAbort = { viewModel.abortChat() }, - onSend = { text -> - val outgoing = - attachments.map { att -> - OutgoingAttachment( - type = "image", - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ) - } - viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) - attachments.clear() - }, + Row(modifier = Modifier.fillMaxWidth().imePadding()) { + ChatComposer( + healthOk = healthOk, + thinkingLevel = thinkingLevel, + pendingRunCount = pendingRunCount, + attachments = attachments, + onPickImages = { pickImages.launch("image/*") }, + onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, + onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, + onRefresh = { + viewModel.refreshChat() + viewModel.refreshChatSessions(limit = 200) + }, + onAbort = { viewModel.abortChat() }, + onSend = { text -> + val outgoing = + attachments.map { att -> + OutgoingAttachment( + type = "image", + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ) + } + viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) + attachments.clear() + }, + ) + } + } +} + +@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/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt new file mode 100644 index 00000000000..c28e523a182 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt @@ -0,0 +1,523 @@ +package ai.openclaw.android.voice + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.core.content.ContextCompat +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +enum class VoiceConversationRole { + User, + Assistant, +} + +data class VoiceConversationEntry( + val id: String, + val role: VoiceConversationRole, + val text: String, + val isStreaming: Boolean = false, +) + +class MicCaptureManager( + private val context: Context, + private val scope: CoroutineScope, + private val sendToGateway: suspend (String) -> String?, +) { + companion object { + private const val speechMinSessionMs = 30_000L + private const val speechCompleteSilenceMs = 1_500L + private const val speechPossibleSilenceMs = 900L + private const val maxConversationEntries = 40 + private const val pendingRunTimeoutMs = 45_000L + } + + private data class QueuedUtterance( + val text: String, + ) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val json = Json { ignoreUnknownKeys = true } + + private val _micEnabled = MutableStateFlow(false) + val micEnabled: StateFlow = _micEnabled + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _statusText = MutableStateFlow("Mic off") + val statusText: StateFlow = _statusText + + private val _liveTranscript = MutableStateFlow(null) + val liveTranscript: StateFlow = _liveTranscript + + private val _queuedMessages = MutableStateFlow>(emptyList()) + val queuedMessages: StateFlow> = _queuedMessages + + private val _conversation = MutableStateFlow>(emptyList()) + val conversation: StateFlow> = _conversation + + private val _inputLevel = MutableStateFlow(0f) + val inputLevel: StateFlow = _inputLevel + + private val _isSending = MutableStateFlow(false) + val isSending: StateFlow = _isSending + + private val messageQueue = ArrayDeque() + private val sessionSegments = mutableListOf() + private var lastFinalSegment: String? = null + private var pendingRunId: String? = null + private var pendingAssistantEntryId: String? = null + private var gatewayConnected = false + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var pendingRunTimeoutJob: Job? = null + private var stopRequested = false + + fun setMicEnabled(enabled: Boolean) { + if (_micEnabled.value == enabled) return + _micEnabled.value = enabled + if (enabled) { + start() + sendQueuedIfIdle() + } else { + stop() + flushSessionToQueue() + sendQueuedIfIdle() + } + } + + fun onGatewayConnectionChanged(connected: Boolean) { + gatewayConnected = connected + if (connected) { + sendQueuedIfIdle() + return + } + if (messageQueue.isNotEmpty()) { + _statusText.value = queuedWaitingStatus() + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event != "chat") return + if (payloadJson.isNullOrBlank()) return + val payload = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + + val runId = pendingRunId ?: return + val eventRunId = payload["runId"].asStringOrNull() ?: return + if (eventRunId != runId) return + + when (payload["state"].asStringOrNull()) { + "delta" -> { + val deltaText = parseAssistantText(payload) + if (!deltaText.isNullOrBlank()) { + upsertPendingAssistant(text = deltaText.trim(), isStreaming = true) + } + } + "final" -> { + val finalText = parseAssistantText(payload)?.trim().orEmpty() + if (finalText.isNotEmpty()) { + upsertPendingAssistant(text = finalText, isStreaming = false) + } else if (pendingAssistantEntryId != null) { + updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false) + } + completePendingTurn() + } + "error" -> { + val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" } + upsertPendingAssistant(text = errorMessage, isStreaming = false) + completePendingTurn() + } + "aborted" -> { + upsertPendingAssistant(text = "Response aborted", isStreaming = false) + completePendingTurn() + } + } + } + + private fun start() { + stopRequested = false + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _statusText.value = "Speech recognizer unavailable" + _micEnabled.value = false + return + } + if (!hasMicPermission()) { + _statusText.value = "Microphone permission required" + _micEnabled.value = false + return + } + + mainHandler.post { + try { + if (recognizer == null) { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + } + startListeningSession() + } catch (err: Throwable) { + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + _micEnabled.value = false + } + } + } + + private fun stop() { + stopRequested = true + restartJob?.cancel() + restartJob = null + _isListening.value = false + _statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off" + _inputLevel.value = 0f + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun startListeningSession() { + val recognizerInstance = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs) + putExtra( + RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, + speechPossibleSilenceMs, + ) + } + _statusText.value = + when { + _isSending.value -> "Listening · sending queued voice" + messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued" + else -> "Listening" + } + _isListening.value = true + recognizerInstance.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 300L) { + if (stopRequested) return + if (!_micEnabled.value) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested || !_micEnabled.value) return@post + try { + startListeningSession() + } catch (_: Throwable) { + // retry through onError + } + } + } + } + + private fun flushSessionToQueue() { + val message = sessionSegments.joinToString(" ").trim() + sessionSegments.clear() + _liveTranscript.value = null + lastFinalSegment = null + if (message.isEmpty()) return + + appendConversation( + role = VoiceConversationRole.User, + text = message, + ) + messageQueue.addLast(QueuedUtterance(text = message)) + publishQueue() + } + + private fun publishQueue() { + _queuedMessages.value = messageQueue.map { it.text } + } + + private fun sendQueuedIfIdle() { + if (_isSending.value) return + if (messageQueue.isEmpty()) { + if (_micEnabled.value) { + _statusText.value = "Listening" + } else { + _statusText.value = "Mic off" + } + return + } + if (!gatewayConnected) { + _statusText.value = queuedWaitingStatus() + return + } + + val next = messageQueue.first() + _isSending.value = true + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + _statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice" + + scope.launch { + try { + val runId = sendToGateway(next.text) + pendingRunId = runId + if (runId == null) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + messageQueue.removeFirst() + publishQueue() + _isSending.value = false + pendingAssistantEntryId = null + sendQueuedIfIdle() + } else { + armPendingRunTimeout(runId) + } + } catch (err: Throwable) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + _isSending.value = false + pendingRunId = null + pendingAssistantEntryId = null + _statusText.value = + if (!gatewayConnected) { + queuedWaitingStatus() + } else { + "Send failed: ${err.message ?: err::class.simpleName}" + } + } + } + } + + private fun armPendingRunTimeout(runId: String) { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = + scope.launch { + delay(pendingRunTimeoutMs) + if (pendingRunId != runId) return@launch + pendingRunId = null + pendingAssistantEntryId = null + _isSending.value = false + _statusText.value = + if (gatewayConnected) { + "Voice reply timed out; retrying queued turn" + } else { + queuedWaitingStatus() + } + sendQueuedIfIdle() + } + } + + private fun completePendingTurn() { + pendingRunTimeoutJob?.cancel() + pendingRunTimeoutJob = null + if (messageQueue.isNotEmpty()) { + messageQueue.removeFirst() + publishQueue() + } + pendingRunId = null + pendingAssistantEntryId = null + _isSending.value = false + sendQueuedIfIdle() + } + + private fun queuedWaitingStatus(): String { + return "${messageQueue.size} queued · waiting for gateway" + } + + private fun appendConversation( + role: VoiceConversationRole, + text: String, + isStreaming: Boolean = false, + ): String { + val id = UUID.randomUUID().toString() + _conversation.value = + (_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming)) + .takeLast(maxConversationEntries) + return id + } + + private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) { + val current = _conversation.value + _conversation.value = + current.map { entry -> + if (entry.id == id) { + val updatedText = text ?: entry.text + entry.copy(text = updatedText, isStreaming = isStreaming) + } else { + entry + } + } + } + + private fun upsertPendingAssistant(text: String, isStreaming: Boolean) { + val currentId = pendingAssistantEntryId + if (currentId == null) { + pendingAssistantEntryId = + appendConversation( + role = VoiceConversationRole.Assistant, + text = text, + isStreaming = isStreaming, + ) + return + } + updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming) + } + + private fun onFinalTranscript(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + _liveTranscript.value = trimmed + if (lastFinalSegment == trimmed) return + lastFinalSegment = trimmed + sessionSegments.add(trimmed) + } + + private fun disableMic(status: String) { + stopRequested = true + restartJob?.cancel() + restartJob = null + _micEnabled.value = false + _isListening.value = false + _inputLevel.value = 0f + _statusText.value = status + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun hasMicPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun parseAssistantText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"] as? JsonArray ?: return null + + val parts = + content.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + if (obj["type"].asStringOrNull() != "text") return@mapNotNull null + obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + } + if (parts.isEmpty()) return null + return parts.joinToString("\n") + } + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _isListening.value = true + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) { + val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f) + _inputLevel.value = level + } + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + _inputLevel.value = 0f + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + _inputLevel.value = 0f + val status = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required" + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device" + SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device" + SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected" + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying" + else -> "Speech error ($error)" + } + _statusText.value = status + + if ( + error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS || + error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED || + error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE + ) { + disableMic(status) + return + } + + val restartDelayMs = + when (error) { + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, + -> 1_200L + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L + else -> 600L + } + scheduleRestart(delayMs = restartDelayMs) + } + + override fun onResults(results: Bundle?) { + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull() + if (!text.isNullOrBlank()) { + onFinalTranscript(text) + flushSessionToQueue() + sendQueuedIfIdle() + } + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull() + if (!text.isNullOrBlank()) { + _liveTranscript.value = text.trim() + } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} + +private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = + this as? JsonObject + +private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content 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 04d18b62260..f00481982a3 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 00000000000..9a108f1cee9 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 00000000000..c6d28def6d5 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 00000000000..46a13d61989 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 00000000000..62a61839390 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/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt new file mode 100644 index 00000000000..7dc2dd1a239 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.ui + +import java.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GatewayConfigResolverTest { + @Test + fun resolveScannedSetupCodeAcceptsRawSetupCode() { + val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""") + + val resolved = resolveScannedSetupCode(setupCode) + + assertEquals(setupCode, resolved) + } + + @Test + fun resolveScannedSetupCodeAcceptsQrJsonPayload() { + val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""") + val qrJson = + """ + { + "setupCode": "$setupCode", + "gatewayUrl": "wss://gateway.example:18789", + "auth": "password", + "urlSource": "gateway.remote.url" + } + """.trimIndent() + + val resolved = resolveScannedSetupCode(qrJson) + + assertEquals(setupCode, resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsInvalidInput() { + val resolved = resolveScannedSetupCode("not-a-valid-setup-code") + assertNull(resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() { + val qrJson = """{"setupCode":"invalid"}""" + val resolved = resolveScannedSetupCode(qrJson) + assertNull(resolved) + } + + @Test + fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() { + val qrJson = """{"setupCode":{"nested":"value"}}""" + val resolved = resolveScannedSetupCode(qrJson) + assertNull(resolved) + } + + private fun encodeSetupCode(payloadJson: String): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) + } +} 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 00000000000..5daa62080d7 --- /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 f79902d5615..bea7b46b2c2 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 5f84d966ee8..4134274afdd 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 00000000000..6c1139ec06a --- /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 00000000000..f2b892ac6ff --- /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/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0656afbf2d7..aedea62a5e1 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 00000000000..eeed54c4652 --- /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 fed2716b5b8..a80a98101fa 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 7d92d1cc1ca..bc27eb19791 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/DeepLinkAgentPromptAlert.swift b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 00000000000..0624e976b51 --- /dev/null +++ b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 2b7f94ba453..a770fcb2c6f 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 ce1ba4bf2cb..04bb220d5f3 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 3ff57ad2e67..49db9bb1bfc 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 c3b469e7092..bcb8c251a02 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.25 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260220 + 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 00000000000..08ef81e0cce --- /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 5bd98e6f492..d763a3b908f 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -3,6 +3,7 @@ import OpenClawKit import OpenClawProtocol import Observation import os +import Security import SwiftUI import UIKit import UserNotifications @@ -37,9 +38,22 @@ private final class NotificationInvokeLatch: @unchecked Sendable { cont?.resume(returning: response) } } + +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + @MainActor @Observable final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") @@ -74,6 +88,8 @@ final class NodeAppModel { var gatewayAgents: [AgentSummary] = [] var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -485,21 +501,14 @@ final class NodeAppModel { } } - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static var apnsEnvironment: String { #if DEBUG "sandbox" @@ -508,17 +517,6 @@ final class NodeAppModel { #endif } - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - private func refreshBrandingFromGateway() async { do { let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) @@ -699,117 +697,6 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - self.deepLinkLogger.info( - "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" - ) - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - self.recordShareEvent("Rejected: message too large (\(message.count) chars).") - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - self.recordShareEvent("Failed: gateway not connected.") - self.deepLinkLogger.error("agent deep link rejected: gateway not connected") - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - self.recordShareEvent("Sent to gateway (\(message.count) chars).") - self.deepLinkLogger.info("agent deep link forwarded to gateway") - self.openChatRequestID &+= 1 - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - self.recordShareEvent("Failed: \(error.localizedDescription)") - self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command @@ -1603,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, @@ -1616,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) } } @@ -2560,6 +2448,229 @@ extension NodeAppModel { } } +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + extension NodeAppModel { func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { await self.handleWatchQuickReply(event) @@ -2607,5 +2718,13 @@ extension NodeAppModel { func _test_queuedWatchReplyCount() -> Int { self.queuedWatchReplies.count } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } } #endif diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 335e09fd986..0dc0c4cac26 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))) + 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)) } - return actions } private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index da893d3c943..dd0f389ed4d 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -88,6 +88,7 @@ struct RootCanvas: View { } } .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index 11052f23543..c353d86f22d 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 024a4cbf42b..3ff2ed465c3 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 8f208c66d50..0f8a7e6461b 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 5b1ba7d70e6..514ca732673 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 b82ae716168..3c1b25bce07 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 7e67ab84a97..0bac4015236 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 7fc8d827044..c273b1923d1 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.21 + 2026.2.25 CFBundleVersion - 20260220 + 20260225 diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift index 827be250ed7..e56f4aa35b5 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 3d015afae84..dbeee118a4a 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,8 +29,35 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + @MainActor -private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { var currentStatus = WatchMessagingStatus( supported: true, paired: true, @@ -275,6 +302,79 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #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( @@ -327,6 +427,58 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift new file mode 100644 index 00000000000..fd6b535f8a3 --- /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/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index cc5dbf6cdda..4e309b031a6 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 2d6b7baa7b8..1b5f28dfc43 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index adaa3fc29fb..8dbb75a8c26 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 613322f3e8e..a4d5928d820 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,8 +92,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -146,8 +146,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -176,8 +176,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -200,8 +200,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -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 @@ -228,5 +234,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b..89bbefc5b02 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 57164ebb892..6340dee2ca5 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 e9ca6c35359..ef4917e7768 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 abbddb24588..6c01628144b 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 c17f64e30e7..cacfac2f068 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 2dd720741bb..ad40d2c3803 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 08567cd0b09..73aa3899d82 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 362a7da01d8..1417589ae4a 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 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: command) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } + let context = await self.buildContext( + request: request, + command: validatedRequest.command, + rawCommand: validatedRequest.displayCommand) - 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 + 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,32 +386,34 @@ 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 + } + + 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: 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") - } + self.persistAllowlistEntry(decision: request.approvalDecision, context: context) if context.allowlistSatisfied { var seenPatterns = Set() @@ -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 00000000000..fe38d7ea18f --- /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 ca6a934adb5..06851a7d065 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 00000000000..707a46322d8 --- /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 60cfdfb1d73..4dae858771c 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 00e2a9be0a6..d7ab72ce86f 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 37fd6ca2505..eb6271d0a8c 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 e35057d28cf..81e06abda2d 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 5b05ab164c2..ed40bd2ed58 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() - } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote - } - } - .buttonStyle(.link) + self.advancedConnectionSection() + } + } + } + } - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 + 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." + } - 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) - } - } - } + @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) + } - 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))) + 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) + } + .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) + } + } + } + + 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 1895b2af94f..7538f846b89 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 e7ca1ad5487..5abb959dc8e 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.21 + 2026.2.25 CFBundleVersion - 202602210 + 202602250 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index a6d81f50bca..7c047e01d03 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 47b041a5873..a8d8008c653 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 e535ebd6616..6eaa45e0675 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 ee634a628ed..0c6ea54c90e 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 8e88c86d45d..bbbed72926b 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 61f913b9da8..b7e2d329b82 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 b3d0c58d90c..063fea826ab 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 5b866304b09..46e5d80a01e 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 2909418d0c3..4e766514def 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2582,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? @@ -2595,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2607,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 @@ -2621,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 6d5e4a37efd..8794a3f22fc 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 00000000000..a175e5e1a0a --- /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 7a71bc08b6e..d8470679183 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 00000000000..64ef6a21eda --- /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 00000000000..ed3773a44ed --- /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 8395ed145ce..ff63673b9e0 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 00000000000..5ee30af273d --- /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 46971ac314c..6640d526a74 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 145e17f3b7b..62714838177 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 2909418d0c3..4e766514def 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2582,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? @@ -2595,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2607,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 @@ -2621,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 a9cb659876a..530287ca21d 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-utils.js b/assets/chrome-extension/background-utils.js index 183e35f9c4a..fe32d2c0616 100644 --- a/assets/chrome-extension/background-utils.js +++ b/assets/chrome-extension/background-utils.js @@ -11,14 +11,32 @@ export function reconnectDelayMs( return backoff + Math.max(0, jitterMs) * random(); } -export function buildRelayWsUrl(port, gatewayToken) { +export 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(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { const token = String(gatewayToken || "").trim(); if (!token) { throw new Error( "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", ); } - return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`; + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; } export function isRetryableReconnectError(err) { diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 5de9027bfcd..60f50d6551e 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -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 @@ -128,7 +132,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = buildRelayWsUrl(port, gatewayToken) + const wsUrl = await buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -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…', - }) - - 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) + // 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 } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + // 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 + } + + 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) @@ -798,3 +873,27 @@ async function whenReady(fn) { await initPromise return fn() } + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .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 00000000000..53e2cd55014 --- /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 e4252ccae4c..aa6fcc4901f 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,12 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -function relayHeaders(token) { - const t = String(token || '').trim() - if (!t) return {} - return { 'x-openclaw-relay-token': t } -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -33,27 +30,21 @@ async function checkRelayReachable(port, token) { setStatus('error', 'Gateway token required. Save your gateway token to connect.') return } - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { - method: 'GET', - headers: relayHeaders(trimmedToken), - signal: ctrl.signal, + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, }) - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - 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.`, - ) - } finally { - clearTimeout(t) + 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-jobs.md b/docs/automation/cron-jobs.md index aae5f58fdf2..8d140192607 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -349,7 +349,8 @@ Notes: ## Storage & history - Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). -- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned). +- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned by size and line count). +- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable). - Override store path: `cron.store` in config. ## Configuration @@ -362,10 +363,21 @@ Notes: maxConcurrentRuns: 1, // default 1 webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode + sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` +Run-log pruning behavior: + +- `cron.runLog.maxBytes`: max run-log file size before pruning. +- `cron.runLog.keepLines`: when pruning, keep only the newest N lines. +- Both apply to `cron/runs/.jsonl` files. + Webhook behavior: - Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. @@ -380,6 +392,85 @@ Disable cron entirely: - `cron.enabled: false` (config) - `OPENCLAW_SKIP_CRON=1` (env) +## Maintenance + +Cron has two built-in maintenance paths: isolated run-session retention and run-log pruning. + +### Defaults + +- `cron.sessionRetention`: `24h` (set `false` to disable run-session pruning) +- `cron.runLog.maxBytes`: `2_000_000` bytes +- `cron.runLog.keepLines`: `2000` + +### How it works + +- Isolated runs create session entries (`...:cron::run:`) and transcript files. +- The reaper removes expired run-session entries older than `cron.sessionRetention`. +- For removed run sessions no longer referenced by the session store, OpenClaw archives transcript files and purges old deleted archives on the same retention window. +- After each run append, `cron/runs/.jsonl` is size-checked: + - if file size exceeds `runLog.maxBytes`, it is trimmed to the newest `runLog.keepLines` lines. + +### Performance caveat for high volume schedulers + +High-frequency cron setups can generate large run-session and run-log footprints. Maintenance is built in, but loose limits can still create avoidable IO and cleanup work. + +What to watch: + +- long `cron.sessionRetention` windows with many isolated runs +- high `cron.runLog.keepLines` combined with large `runLog.maxBytes` +- many noisy recurring jobs writing to the same `cron/runs/.jsonl` + +What to do: + +- keep `cron.sessionRetention` as short as your debugging/audit needs allow +- keep run logs bounded with moderate `runLog.maxBytes` and `runLog.keepLines` +- move noisy background jobs to isolated mode with delivery rules that avoid unnecessary chatter +- review growth periodically with `openclaw cron runs` and adjust retention before logs become large + +### Customize examples + +Keep run sessions for a week and allow bigger run logs: + +```json5 +{ + cron: { + sessionRetention: "7d", + runLog: { + maxBytes: "10mb", + keepLines: 5000, + }, + }, +} +``` + +Disable isolated run-session pruning but keep run-log pruning: + +```json5 +{ + cron: { + sessionRetention: false, + runLog: { + maxBytes: "5mb", + keepLines: 3000, + }, + }, +} +``` + +Tune for high-volume cron usage (example): + +```json5 +{ + cron: { + sessionRetention: "12h", + runLog: { + maxBytes: "3mb", + keepLines: 1500, + }, + }, +} +``` + ## CLI quickstart One-shot reminder (UTC ISO, auto-delete after success): diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index c25cbcb80db..9676d960d23 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 334c6d78ee5..31913842e4d 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -397,7 +397,8 @@ Example: `allowlist` behavior: - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - - optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - if a guild has `channels` configured, non-listed channels are denied - if a guild has no `channels` block, all channels in that allowlisted guild are allowed @@ -768,7 +769,7 @@ Default slash command settings: Notes: - allowlists can use `pk:` - - member display names are matched by name/slug + - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true` - lookups use original message ID and are time-window constrained - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` @@ -918,6 +919,8 @@ Auto-join example: channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -932,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 @@ -1007,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/googlechat.md b/docs/channels/googlechat.md index 818a8288f5d..13729257fe7 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path: Use these identifiers for delivery and allowlists: -- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal). +- Direct messages: `users/` (recommended). +- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`. - Deprecated: `users/` is treated as a user id, not an email allowlist. - Spaces: `spaces/`. @@ -171,7 +172,7 @@ Use these identifiers for delivery and allowlists: botUser: "users/1234567890", // optional; helps mention detection dm: { policy: "pairing", - allowFrom: ["users/1234567890", "name@example.com"], + allowFrom: ["users/1234567890"], }, groupPolicy: "allowlist", groups: { @@ -194,6 +195,7 @@ Notes: - Service account credentials can also be passed inline with `serviceAccount` (JSON string). - Default webhook path is `/googlechat` if `webhookPath` isn’t set. +- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). diff --git a/docs/channels/groups.md b/docs/channels/groups.md index de848243c9c..8b8af64b94c 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/irc.md b/docs/channels/irc.md index 7496f574c4e..00403b6f92d 100644 --- a/docs/channels/irc.md +++ b/docs/channels/irc.md @@ -57,7 +57,8 @@ Config keys: - Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` - `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) -Allowlist entries can use nick or `nick!user@host` forms. +Allowlist entries should use stable sender identities (`nick!user@host`). +Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`. ### Common gotcha: `allowFrom` is for DMs, not channels diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 350fa8429c4..702f72cc01f 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -101,7 +101,8 @@ Notes: ## Channels (groups) - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). -- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). - Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index d8b9f0af865..9c4a583e1b5 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -87,7 +87,9 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. +- `channels.msteams.allowFrom` should use stable AAD object IDs. +- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`. +- The wizard can resolve names to IDs via Microsoft Graph when credentials allow. **Group access** @@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) -- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index beb79a511fc..869df30ad99 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -171,6 +171,7 @@ For actions/directory reads, user token can be preferred when configured. For wr - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - unresolved entries are kept as configured + - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -513,6 +514,7 @@ Primary reference: High-signal Slack fields: - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` + - compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed) - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 78beff43bc4..89e96b318a3 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 cda126f5649..8e5d8ab0382 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/acp.md b/docs/cli/acp.md index 9535509016d..1b1981395e4 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -49,6 +49,13 @@ openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/ openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 ``` +Permission model (client debug mode): + +- Auto-approval is allowlist-based and only applies to trusted core tool IDs. +- `read` auto-approval is scoped to the current working directory (`--cwd` when set). +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval. +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source). + ## How to use this Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 1590a055050..0055abec7b4 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/cron.md b/docs/cli/cron.md index 3e56db9717a..9c129518e21 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -23,6 +23,11 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after- Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. +Note: retention/pruning is controlled in config: + +- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/cli/devices.md b/docs/cli/devices.md index edacf9a2876..be01e3cc0d5 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 7dc1f6fc1b8..d53d86452f3 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -27,6 +27,9 @@ 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 49017c3735d..32eb31b5eb3 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 bc6d05c12e3..11b9926c56a 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 319ddc29a0f..13ad8a59948 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 e8b76c8e3e7..fe8af41ec25 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,15 +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 Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs. +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/cli/sessions.md b/docs/cli/sessions.md index 0709bc1f0df..4ed5ace54ee 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -11,6 +11,94 @@ List stored conversation sessions. ```bash openclaw sessions +openclaw sessions --agent work +openclaw sessions --all-agents openclaw sessions --active 120 openclaw sessions --json ``` + +Scope selection: + +- default: configured default agent store +- `--agent `: one configured agent store +- `--all-agents`: aggregate all configured agent stores +- `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) + +JSON examples: + +`openclaw sessions --all-agents --json`: + +```json +{ + "path": null, + "stores": [ + { "agentId": "main", "path": "/home/user/.openclaw/agents/main/sessions/sessions.json" }, + { "agentId": "work", "path": "/home/user/.openclaw/agents/work/sessions/sessions.json" } + ], + "allAgents": true, + "count": 2, + "activeMinutes": null, + "sessions": [ + { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, + { "agentId": "work", "key": "agent:work:main", "model": "claude-opus-4-5" } + ] +} +``` + +## Cleanup maintenance + +Run maintenance now (instead of waiting for the next write cycle): + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --agent work --dry-run +openclaw sessions cleanup --all-agents --dry-run +openclaw sessions cleanup --enforce +openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123" +openclaw sessions cleanup --json +``` + +`openclaw sessions cleanup` uses `session.maintenance` settings from config: + +- Scope note: `openclaw sessions cleanup` maintains session stores/transcripts only. It does not prune cron run logs (`cron/runs/.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance). + +- `--dry-run`: preview how many entries would be pruned/capped without writing. + - In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed. +- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`. +- `--active-key `: protect a specific active key from disk-budget eviction. +- `--agent `: run cleanup for one configured agent store. +- `--all-agents`: run cleanup for all configured agent stores. +- `--store `: run against a specific `sessions.json` file. +- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store. + +`openclaw sessions cleanup --all-agents --dry-run --json`: + +```json +{ + "allAgents": true, + "mode": "warn", + "dryRun": true, + "stores": [ + { + "agentId": "main", + "storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json", + "beforeCount": 120, + "afterCount": 80, + "pruned": 40, + "capped": 0 + }, + { + "agentId": "work", + "storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json", + "beforeCount": 18, + "afterCount": 18, + "pruned": 0, + "capped": 0 + } + ] +} +``` + +Related: + +- Session config: [Configuration reference](/gateway/configuration-reference#session) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 1d6e6a0eb96..6210f592482 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -126,10 +126,23 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6` - CLI: `openclaw onboard --auth-choice ai-gateway-api-key` +### Kilo Gateway + +- Provider: `kilocode` +- Auth: `KILOCODE_API_KEY` +- Example model: `kilocode/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --kilocode-api-key ` +- Base URL: `https://api.kilo.ai/api/gateway/` +- Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5. + +See [/providers/kilocode](/providers/kilocode) for setup details. + ### Other built-in providers - OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) - Example model: `openrouter/anthropic/claude-sonnet-4-5` +- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) +- Example model: `kilocode/anthropic/claude-opus-4.6` - xAI: `xai` (`XAI_API_KEY`) - Mistral: `mistral` (`MISTRAL_API_KEY`) - Example model: `mistral/mistral-large-latest` diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index 0fcb2b78d0a..ba9f39f37f1 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -15,13 +15,13 @@ Session pruning trims **old tool results** from the in-memory context right befo - When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`. - Only affects the messages sent to the model for that request. - Only active for Anthropic API calls (and OpenRouter Anthropic models). -- For best results, match `ttl` to your model `cacheControlTtl`. +- For best results, match `ttl` to your model `cacheRetention` policy (`short` = 5m, `long` = 1h). - After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. ## Smart defaults (Anthropic) - **OAuth or setup-token** profiles: enable `cache-ttl` pruning and set heartbeat to `1h`. -- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheControlTtl` to `1h` on Anthropic models. +- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheRetention: "short"` on Anthropic models. - If you set any of these values explicitly, OpenClaw does **not** override them. ## What this improves (cost + cache behavior) @@ -91,9 +91,7 @@ Default (off): ```json5 { - agent: { - contextPruning: { mode: "off" }, - }, + agents: { defaults: { contextPruning: { mode: "off" } } }, } ``` @@ -101,9 +99,7 @@ Enable TTL-aware pruning: ```json5 { - agent: { - contextPruning: { mode: "cache-ttl", ttl: "5m" }, - }, + agents: { defaults: { contextPruning: { mode: "cache-ttl", ttl: "5m" } } }, } ``` @@ -111,10 +107,12 @@ Restrict pruning to specific tools: ```json5 { - agent: { - contextPruning: { - mode: "cache-ttl", - tools: { allow: ["exec", "read"], deny: ["*image*"] }, + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + tools: { allow: ["exec", "read"], deny: ["*image*"] }, + }, }, }, } diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe55..bbd58d599ce 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 3d1503ab80e..6c9010d2c11 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -71,6 +71,109 @@ All session state is **owned by the gateway** (the “master” OpenClaw). UI cl - Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from. - OpenClaw does **not** read legacy Pi/Tau session folders. +## Maintenance + +OpenClaw applies session-store maintenance to keep `sessions.json` and transcript artifacts bounded over time. + +### Defaults + +- `session.maintenance.mode`: `warn` +- `session.maintenance.pruneAfter`: `30d` +- `session.maintenance.maxEntries`: `500` +- `session.maintenance.rotateBytes`: `10mb` +- `session.maintenance.resetArchiveRetention`: defaults to `pruneAfter` (`30d`) +- `session.maintenance.maxDiskBytes`: unset (disabled) +- `session.maintenance.highWaterBytes`: defaults to `80%` of `maxDiskBytes` when budgeting is enabled + +### How it works + +Maintenance runs during session-store writes, and you can trigger it on demand with `openclaw sessions cleanup`. + +- `mode: "warn"`: reports what would be evicted but does not mutate entries/transcripts. +- `mode: "enforce"`: applies cleanup in this order: + 1. prune stale entries older than `pruneAfter` + 2. cap entry count to `maxEntries` (oldest first) + 3. archive transcript files for removed entries that are no longer referenced + 4. purge old `*.deleted.` and `*.reset.` archives by retention policy + 5. rotate `sessions.json` when it exceeds `rotateBytes` + 6. if `maxDiskBytes` is set, enforce disk budget toward `highWaterBytes` (oldest artifacts first, then oldest sessions) + +### Performance caveat for large stores + +Large session stores are common in high-volume setups. Maintenance work is write-path work, so very large stores can increase write latency. + +What increases cost most: + +- very high `session.maintenance.maxEntries` values +- long `pruneAfter` windows that keep stale entries around +- many transcript/archive artifacts in `~/.openclaw/agents//sessions/` +- enabling disk budgets (`maxDiskBytes`) without reasonable pruning/cap limits + +What to do: + +- use `mode: "enforce"` in production so growth is bounded automatically +- set both time and count limits (`pruneAfter` + `maxEntries`), not just one +- set `maxDiskBytes` + `highWaterBytes` for hard upper bounds in large deployments +- keep `highWaterBytes` meaningfully below `maxDiskBytes` (default is 80%) +- run `openclaw sessions cleanup --dry-run --json` after config changes to verify projected impact before enforcing +- for frequent active sessions, pass `--active-key` when running manual cleanup + +### Customize examples + +Use a conservative enforce policy: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "45d", + maxEntries: 800, + rotateBytes: "20mb", + resetArchiveRetention: "14d", + }, + }, +} +``` + +Enable a hard disk budget for the sessions directory: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + maxDiskBytes: "1gb", + highWaterBytes: "800mb", + }, + }, +} +``` + +Tune for larger installs (example): + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "14d", + maxEntries: 2000, + rotateBytes: "25mb", + maxDiskBytes: "2gb", + highWaterBytes: "1.6gb", + }, + }, +} +``` + +Preview or force maintenance from CLI: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + ## Session pruning OpenClaw trims **old tool results** from the in-memory context right before LLM calls by default. @@ -180,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/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md new file mode 100644 index 00000000000..596a77f1385 --- /dev/null +++ b/docs/design/kilo-gateway-integration.md @@ -0,0 +1,534 @@ +# Kilo Gateway Provider Integration Design + +## Overview + +This document outlines the design for integrating "Kilo Gateway" as a first-class provider in OpenClaw, modeled after the existing OpenRouter implementation. Kilo Gateway uses an OpenAI-compatible completions API with a different base URL. + +## Design Decisions + +### 1. Provider Naming + +**Recommendation: `kilocode`** + +Rationale: + +- Matches the user config example provided (`kilocode` provider key) +- Consistent with existing provider naming patterns (e.g., `openrouter`, `opencode`, `moonshot`) +- Short and memorable +- Avoids confusion with generic "kilo" or "gateway" terms + +Alternative considered: `kilo-gateway` - rejected because hyphenated names are less common in the codebase and `kilocode` is more concise. + +### 2. Default Model Reference + +**Recommendation: `kilocode/anthropic/claude-opus-4.6`** + +Rationale: + +- Based on user config example +- Claude Opus 4.5 is a capable default model +- Explicit model selection avoids reliance on auto-routing + +### 3. Base URL Configuration + +**Recommendation: Hardcoded default with config override** + +- **Default Base URL:** `https://api.kilo.ai/api/gateway/` +- **Configurable:** Yes, via `models.providers.kilocode.baseUrl` + +This matches the pattern used by other providers like Moonshot, Venice, and Synthetic. + +### 4. Model Scanning + +**Recommendation: No dedicated model scanning endpoint initially** + +Rationale: + +- Kilo Gateway proxies to OpenRouter, so models are dynamic +- Users can manually configure models in their config +- If Kilo Gateway exposes a `/models` endpoint in the future, scanning can be added + +### 5. Special Handling + +**Recommendation: Inherit OpenRouter behavior for Anthropic models** + +Since Kilo Gateway proxies to OpenRouter, the same special handling should apply: + +- Cache TTL eligibility for `anthropic/*` models +- Extra params (cacheControlTtl) for `anthropic/*` models +- Transcript policy follows OpenRouter patterns + +## Files to Modify + +### Core Credential Management + +#### 1. `src/commands/onboard-auth.credentials.ts` + +Add: + +```typescript +export const KILOCODE_DEFAULT_MODEL_REF = "kilocode/anthropic/claude-opus-4.6"; + +export async function setKilocodeApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "kilocode:default", + credential: { + type: "api_key", + provider: "kilocode", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} +``` + +#### 2. `src/agents/model-auth.ts` + +Add to `envMap` in `resolveEnvApiKey()`: + +```typescript +const envMap: Record = { + // ... existing entries + kilocode: "KILOCODE_API_KEY", +}; +``` + +#### 3. `src/config/io.ts` + +Add to `SHELL_ENV_EXPECTED_KEYS`: + +```typescript +const SHELL_ENV_EXPECTED_KEYS = [ + // ... existing entries + "KILOCODE_API_KEY", +]; +``` + +### Config Application + +#### 4. `src/commands/onboard-auth.config-core.ts` + +Add new functions: + +```typescript +export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; + +export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KILOCODE_DEFAULT_MODEL_REF] = { + ...models[KILOCODE_DEFAULT_MODEL_REF], + alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.kilocode; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + providers.kilocode = { + ...existingProviderRest, + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyKilocodeProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: KILOCODE_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} +``` + +### Auth Choice System + +#### 5. `src/commands/onboard-types.ts` + +Add to `AuthChoice` type: + +```typescript +export type AuthChoice = + // ... existing choices + "kilocode-api-key"; +// ... +``` + +Add to `OnboardOptions`: + +```typescript +export type OnboardOptions = { + // ... existing options + kilocodeApiKey?: string; + // ... +}; +``` + +#### 6. `src/commands/auth-choice-options.ts` + +Add to `AuthChoiceGroupId`: + +```typescript +export type AuthChoiceGroupId = + // ... existing groups + "kilocode"; +// ... +``` + +Add to `AUTH_CHOICE_GROUP_DEFS`: + +```typescript +{ + value: "kilocode", + label: "Kilo Gateway", + hint: "API key (OpenRouter-compatible)", + choices: ["kilocode-api-key"], +}, +``` + +Add to `buildAuthChoiceOptions()`: + +```typescript +options.push({ + value: "kilocode-api-key", + label: "Kilo Gateway API key", + hint: "OpenRouter-compatible gateway", +}); +``` + +#### 7. `src/commands/auth-choice.preferred-provider.ts` + +Add mapping: + +```typescript +const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { + // ... existing mappings + "kilocode-api-key": "kilocode", +}; +``` + +### Auth Choice Application + +#### 8. `src/commands/auth-choice.apply.api-providers.ts` + +Add import: + +```typescript +import { + // ... existing imports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.js"; +``` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "kilocode", + }); + const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); + const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + let profileId = "kilocode:default"; + let mode: "api_key" | "oauth" | "token" = "api_key"; + let hasCredential = false; + + if (existingProfileId && existingCred?.type) { + profileId = existingProfileId; + mode = + existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; + hasCredential = true; + } + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kilocode") { + await setKilocodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("kilocode"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing KILOCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setKilocodeApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Kilo Gateway API key", + validate: validateApiKeyInput, + }); + await setKilocodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "kilocode", + mode, + }); + } + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: KILOCODE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyKilocodeConfig, + applyProviderConfig: applyKilocodeProviderConfig, + noteDefault: KILOCODE_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; +} +``` + +Also add tokenProvider mapping at the top of the function: + +```typescript +if (params.opts.tokenProvider === "kilocode") { + authChoice = "kilocode-api-key"; +} +``` + +### CLI Registration + +#### 9. `src/cli/program/register.onboard.ts` + +Add CLI option: + +```typescript +.option("--kilocode-api-key ", "Kilo Gateway API key") +``` + +Add to action handler: + +```typescript +kilocodeApiKey: opts.kilocodeApiKey as string | undefined, +``` + +Update auth-choice help text: + +```typescript +.option( + "--auth-choice ", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|kilocode-api-key|ai-gateway-api-key|...", +) +``` + +### Non-Interactive Onboarding + +#### 10. `src/commands/onboard-non-interactive/local/auth-choice.ts` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "kilocode", + cfg: baseConfig, + flagValue: opts.kilocodeApiKey, + flagName: "--kilocode-api-key", + envVar: "KILOCODE_API_KEY", + }); + await setKilocodeApiKey(resolved.apiKey, agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "kilocode:default", + provider: "kilocode", + mode: "api_key", + }); + // ... apply default model +} +``` + +### Export Updates + +#### 11. `src/commands/onboard-auth.ts` + +Add exports: + +```typescript +export { + // ... existing exports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_BASE_URL, +} from "./onboard-auth.config-core.js"; + +export { + // ... existing exports + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.credentials.js"; +``` + +### Special Handling (Optional) + +#### 12. `src/agents/pi-embedded-runner/cache-ttl.ts` + +Add Kilo Gateway support for Anthropic models: + +```typescript +export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { + const normalizedProvider = provider.toLowerCase(); + const normalizedModelId = modelId.toLowerCase(); + if (normalizedProvider === "anthropic") return true; + if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) + return true; + if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) return true; + return false; +} +``` + +#### 13. `src/agents/transcript-policy.ts` + +Add Kilo Gateway handling (similar to OpenRouter): + +```typescript +const isKilocodeGemini = provider === "kilocode" && modelId.toLowerCase().includes("gemini"); + +// Include in needsNonImageSanitize check +const needsNonImageSanitize = + isGoogle || isAnthropic || isMistral || isOpenRouterGemini || isKilocodeGemini; +``` + +## Configuration Structure + +### User Config Example + +```json +{ + "models": { + "mode": "merge", + "providers": { + "kilocode": { + "baseUrl": "https://api.kilo.ai/api/gateway/", + "apiKey": "xxxxx", + "api": "openai-completions", + "models": [ + { + "id": "anthropic/claude-opus-4.6", + "name": "Anthropic: Claude Opus 4.6" + }, + { "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" } + ] + } + } + } +} +``` + +### Auth Profile Structure + +```json +{ + "profiles": { + "kilocode:default": { + "type": "api_key", + "provider": "kilocode", + "key": "xxxxx" + } + } +} +``` + +## Testing Considerations + +1. **Unit Tests:** + - Test `setKilocodeApiKey()` writes correct profile + - Test `applyKilocodeConfig()` sets correct defaults + - Test `resolveEnvApiKey("kilocode")` returns correct env var + +2. **Integration Tests:** + - Test onboarding flow with `--auth-choice kilocode-api-key` + - Test non-interactive onboarding with `--kilocode-api-key` + - Test model selection with `kilocode/` prefix + +3. **E2E Tests:** + - Test actual API calls through Kilo Gateway (live tests) + +## Migration Notes + +- No migration needed for existing users +- New users can immediately use `kilocode-api-key` auth choice +- Existing manual config with `kilocode` provider will continue to work + +## Future Considerations + +1. **Model Catalog:** If Kilo Gateway exposes a `/models` endpoint, add scanning support similar to `scanOpenRouterModels()` + +2. **OAuth Support:** If Kilo Gateway adds OAuth, extend the auth system accordingly + +3. **Rate Limiting:** Consider adding rate limit handling specific to Kilo Gateway if needed + +4. **Documentation:** Add docs at `docs/providers/kilocode.md` explaining setup and usage + +## Summary of Changes + +| File | Change Type | Description | +| ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | +| `src/commands/onboard-auth.credentials.ts` | Add | `KILOCODE_DEFAULT_MODEL_REF`, `setKilocodeApiKey()` | +| `src/agents/model-auth.ts` | Modify | Add `kilocode` to `envMap` | +| `src/config/io.ts` | Modify | Add `KILOCODE_API_KEY` to shell env keys | +| `src/commands/onboard-auth.config-core.ts` | Add | `applyKilocodeProviderConfig()`, `applyKilocodeConfig()` | +| `src/commands/onboard-types.ts` | Modify | Add `kilocode-api-key` to `AuthChoice`, add `kilocodeApiKey` to options | +| `src/commands/auth-choice-options.ts` | Modify | Add `kilocode` group and option | +| `src/commands/auth-choice.preferred-provider.ts` | Modify | Add `kilocode-api-key` mapping | +| `src/commands/auth-choice.apply.api-providers.ts` | Modify | Add `kilocode-api-key` handling | +| `src/cli/program/register.onboard.ts` | Modify | Add `--kilocode-api-key` option | +| `src/commands/onboard-non-interactive/local/auth-choice.ts` | Modify | Add non-interactive handling | +| `src/commands/onboard-auth.ts` | Modify | Export new functions | +| `src/agents/pi-embedded-runner/cache-ttl.ts` | Modify | Add kilocode support | +| `src/agents/transcript-policy.ts` | Modify | Add kilocode Gemini handling | diff --git a/docs/docs.json b/docs/docs.json index d2ba02e66ff..8f6f5a72b29 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1264,7 +1264,12 @@ }, { "group": "Technical reference", - "pages": ["reference/wizard", "reference/token-use", "channels/grammy"] + "pages": [ + "reference/wizard", + "reference/token-use", + "reference/prompt-caching", + "channels/grammy" + ] }, { "group": "Concept internals", diff --git a/docs/experiments/.DS_Store b/docs/experiments/.DS_Store deleted file mode 100644 index b13221a744b..00000000000 Binary files a/docs/experiments/.DS_Store and /dev/null differ diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 960f37c005b..d3838bbdae6 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -169,6 +169,9 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional + highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes) }, typingIntervalSeconds: 5, sendPolicy: { @@ -199,7 +202,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["steipete"] }, + dm: { enabled: true, allowFrom: ["123456789012345678"] }, guilds: { "123456789012345678": { slug: "friends-of-openclaw", @@ -314,7 +317,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. allowFrom: { whatsapp: ["+15555550123"], telegram: ["123456789"], - discord: ["steipete"], + discord: ["123456789012345678"], slack: ["U123"], signal: ["+15555550123"], imessage: ["user@example.com"], @@ -355,6 +358,10 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. store: "~/.openclaw/cron/cron.json", maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, // Webhooks @@ -454,7 +461,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_TOKEN", - dm: { allowFrom: ["yourname"] }, + dm: { allowFrom: ["123456789012345678"] }, }, }, } @@ -480,12 +487,15 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["alice", "bob"] }, + dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] }, }, }, } ``` +For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default. +Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk. + ### OAuth with API key failover ```json5 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 50f40998ca1..01ad82b6098 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -212,7 +212,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat }, replyToMode: "off", // off | first | all dmPolicy: "pairing", - allowFrom: ["1234567890", "steipete"], + allowFrom: ["1234567890", "123456789012345678"], dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] }, guilds: { "123456789012345678": { @@ -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,7 +284,10 @@ 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). **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages). @@ -317,7 +322,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`). - Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. -- Use `spaces/` or `users/` for delivery targets. +- Use `spaces/` or `users/` for delivery targets. +- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode). ### Slack @@ -718,9 +724,16 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` +- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - String form sets only the primary model. + - Object form sets primary plus ordered failover models. +- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - Used by the `image` tool path as its vision-model config. + - Also used as fallback routing when the selected/default model cannot accept image input. - `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated). -- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific: `temperature`, `maxTokens`). -- `imageModel`: only used if the primary model lacks image input. +- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`). +- `params` merge precedence (config): `agents.defaults.models["provider/model"].params` is the base, then `agents.list[].params` (matching agent id) overrides by key. +- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): @@ -787,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, @@ -799,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. @@ -1008,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. @@ -1044,6 +1060,7 @@ scripts/sandbox-browser-setup.sh # optional browser image workspace: "~/.openclaw/workspace", agentDir: "~/.openclaw/agents/main/agent", model: "anthropic/claude-opus-4-6", // or { primary, fallbacks } + params: { cacheRetention: "none" }, // overrides matching defaults.models params by key identity: { name: "Samantha", theme: "helpful sloth", @@ -1068,6 +1085,7 @@ scripts/sandbox-browser-setup.sh # optional browser image - `id`: stable agent id (required). - `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default. - `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. +- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. - `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only). @@ -1237,6 +1255,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional hard budget + highWaterBytes: "400mb", // optional cleanup target }, threadBindings: { enabled: true, @@ -1264,7 +1285,14 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. -- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation. +- **`maintenance`**: session-store cleanup + retention controls. + - `mode`: `warn` emits warnings only; `enforce` applies cleanup. + - `pruneAfter`: age cutoff for stale entries (default `30d`). + - `maxEntries`: maximum number of entries in `sessions.json` (default `500`). + - `rotateBytes`: rotate `sessions.json` when it exceeds this size (default `10mb`). + - `resetArchiveRetention`: retention for `*.reset.` transcript archives. Defaults to `pruneAfter`; set `false` to disable. + - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. + - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. - **`threadBindings`**: global defaults for thread-bound session features. - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) - `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override) @@ -1471,7 +1499,7 @@ Controls elevated (host) exec access: enabled: true, allowFrom: { whatsapp: ["+15555550123"], - discord: ["steipete", "1234567890123"], + discord: ["1234567890123", "987654321098765432"], }, }, }, @@ -1662,6 +1690,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1670,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`. --- @@ -1997,6 +2027,12 @@ See [Plugins](/tools/plugin). enabled: true, evaluateEnabled: true, defaultProfile: "chrome", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, @@ -2012,6 +2048,10 @@ See [Plugins](/tools/plugin). ``` - `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`. +- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model). +- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation. +- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. +- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). @@ -2066,6 +2106,8 @@ See [Plugins](/tools/plugin). enabled: true, basePath: "/openclaw", // root: "dist/control-ui", + // allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI + // dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode // allowInsecureAuth: false, // dangerouslyDisableDeviceAuth: false, }, @@ -2100,6 +2142,8 @@ See [Plugins](/tools/plugin). - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds. +- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. @@ -2117,6 +2161,8 @@ See [Plugins](/tools/plugin). - `gateway.http.endpoints.responses.maxUrlParts` - `gateway.http.endpoints.responses.files.urlAllowlist` - `gateway.http.endpoints.responses.images.urlAllowlist` +- Optional response hardening header: + - `gateway.http.securityHeaders.strictTransportSecurity` (set only for HTTPS origins you control; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts)) ### Multi-instance isolation @@ -2448,11 +2494,17 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` -- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable. +- `runLog.maxBytes`: max size per run log file (`cron/runs/.jsonl`) before pruning. Default: `2_000_000` bytes. +- `runLog.keepLines`: newest lines retained when run-log pruning is triggered. Default: `2000`. - `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. - `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e367b4caf0d..3f7403d4647 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. @@ -251,11 +251,17 @@ When validation fails: enabled: true, maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, } ``` - See [Cron jobs](/automation/cron-jobs) for the feature overview and CLI examples. + - `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable). + - `runLog`: prune `cron/runs/.jsonl` by size and retained lines. + - See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index f048435483a..4647cb8b411 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -125,6 +125,7 @@ Current migrations: - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` +- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` ### 2b) OpenCode Zen provider overrides diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b682da0f814..cf7ea489c40 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/protocol.md b/docs/gateway/protocol.md index 8bcedbe0631..85a69aca679 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -170,6 +170,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. +### Operator helper methods + +- Operators may call `tools.catalog` (`operator.read`) to fetch the runtime tool catalog for an + agent. The response includes grouped tools and provenance metadata: + - `source`: `core` or `plugin` + - `pluginId`: plugin owner when `source="plugin"` + - `optional`: whether a plugin tool is optional + ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. diff --git a/docs/gateway/remote-gateway-readme.md b/docs/gateway/remote-gateway-readme.md index 27fbfb6d2a9..cb069629070 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 6d51f573990..8be57bd1064 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 7abbea866d4..3824d1d283e 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/) @@ -37,6 +53,94 @@ OpenClaw assumes the host and config boundary are trusted: - If someone can modify Gateway host state/config (`~/.openclaw`, including `openclaw.json`), treat them as a trusted operator. - Running one Gateway for multiple mutually untrusted/adversarial operators is **not a recommended setup**. - For mixed-trust teams, split trust boundaries with separate gateways (or at minimum separate OS users/hosts). +- OpenClaw can run multiple gateway instances on one machine, but recommended operations favor clean trust-boundary separation. +- Recommended default: one user per machine/host (or VPS), one gateway for that user, and one or more agents in that gateway. +- If multiple users want OpenClaw, use one VPS/host per user. + +### Practical consequence (operator trust boundary) + +Inside one Gateway instance, authenticated operator access is a trusted control-plane role, not a per-user tenant role. + +- Operators with read/control-plane access can inspect gateway session metadata/history by design. +- Session identifiers (`sessionKey`, session IDs, labels) are routing selectors, not authorization tokens. +- Example: expecting per-operator isolation for methods like `sessions.list`, `sessions.preview`, or `chat.history` is outside this model. +- If you need adversarial-user isolation, run separate gateways per trust boundary. +- Multiple gateways on one machine are technically possible, but not the recommended baseline for multi-user isolation. + +## Personal assistant model (not a multi-tenant bus) + +OpenClaw is designed as a personal assistant security model: one trusted operator boundary, potentially many agents. + +- If several people can message one tool-enabled agent, each of them can steer that same permission set. +- Per-user session/memory isolation helps privacy, but does not convert a shared agent into per-user host authorization. +- If users may be adversarial to each other, run separate gateways (or separate OS users/hosts) per trust boundary. + +### Shared Slack workspace: real risk + +If "everyone in Slack can message the bot," the core risk is delegated tool authority: + +- any allowed sender can induce tool calls (`exec`, browser, network/file tools) within the agent's policy; +- prompt/content injection from one sender can cause actions that affect shared state, devices, or outputs; +- if one shared agent has sensitive credentials/files, any allowed sender can potentially drive exfiltration via tool usage. + +Use separate agents/gateways with minimal tools for team workflows; keep personal-data agents private. + +### Company-shared agent: acceptable pattern + +This is acceptable when everyone using that agent is in the same trust boundary (for example one company team) and the agent is strictly business-scoped. + +- run it on a dedicated machine/VM/container; +- use a dedicated OS user + dedicated browser/profile/accounts for that runtime; +- do not sign that runtime into personal Apple/Google accounts or personal password-manager/browser profiles. + +If you mix personal and company identities on the same runtime, you collapse the separation and increase personal-data exposure risk. + +## Gateway and node trust concept + +Treat Gateway and node as one operator trust domain, with different roles: + +- **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing). +- **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities). +- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node. +- `sessionKey` is routing/context selection, not per-user auth. +- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation. + +If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways. + +## Trust boundary matrix + +Use this as the quick model when triaging risk: + +| Boundary or control | What it means | Common misread | +| ------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| `gateway.auth` (token/password/device auth) | Authenticates callers to gateway APIs | "Needs per-message signatures on every frame to be secure" | +| `sessionKey` | Routing key for context/session selection | "Session key is a user auth boundary" | +| Prompt/content guardrails | Reduce model abuse risk | "Prompt injection alone proves auth bypass" | +| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" | +| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | +| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | + +## Not vulnerabilities by design + +These patterns are commonly reported and are usually closed as no-action unless a real boundary bypass is shown: + +- Prompt-injection-only chains without a policy/auth/sandbox bypass. +- Claims that assume hostile multi-tenant operation on one shared host/config. +- Claims that classify normal operator read-path access (for example `sessions.list`/`sessions.preview`/`chat.history`) as IDOR in a shared-gateway setup. +- Localhost-only deployment findings (for example HSTS on loopback-only gateway). +- Discord inbound webhook signature findings for inbound paths that do not exist in this repo. +- "Missing per-user authorization" findings that treat `sessionKey` as an auth token. + +## Researcher preflight checklist + +Before opening a GHSA, verify all of these: + +1. Repro still works on latest `main` or latest release. +2. Report includes exact code path (`file`, function, line range) and tested version/commit. +3. Impact crosses a documented trust boundary (not just prompt injection). +4. Claim is not listed in [Out of Scope](https://github.com/openclaw/openclaw/blob/main/SECURITY.md#out-of-scope). +5. Existing advisories were checked for duplicates (reuse canonical GHSA when applicable). +6. Deployment assumptions are explicit (loopback/local vs exposed, trusted vs untrusted operators). ## Hardened baseline in 60 seconds @@ -128,6 +232,8 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | | `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | | `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | +| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | | `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | | `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | @@ -138,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 | @@ -161,13 +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.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 @@ -202,6 +338,15 @@ Bad reverse proxy behavior (append/preserve untrusted forwarding headers): proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` +## HSTS and origin notes + +- OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there. +- If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. +- Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). +- For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. +- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. + ## Local session logs live on disk OpenClaw stores session transcripts on disk under `~/.openclaw/agents//sessions/*.jsonl`. @@ -691,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) @@ -756,6 +902,30 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +### Browser SSRF policy (trusted-network default) + +OpenClaw’s browser network policy defaults to the trusted-operator model: private/internal destinations are allowed unless you explicitly disable them. + +- Default: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: true` (implicit when unset). +- Legacy alias: `browser.ssrfPolicy.allowPrivateNetwork` is still accepted for compatibility. +- Strict mode: set `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: false` to block private/internal/special-use destinations by default. +- In strict mode, use `hostnameAllowlist` (patterns like `*.example.com`) and `allowedHostnames` (exact host exceptions, including blocked names like `localhost`) for explicit exceptions. +- Navigation is checked before request and best-effort re-checked on the final `http(s)` URL after navigation to reduce redirect-based pivots. + +Example strict policy: + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], + }, + }, +} +``` + ## Per-agent access profiles (multi-agent) With multi-agent routing, each agent can have its own sandbox + tool policy: diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index d3bb0ad9e41..23483076102 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 f9debcfaef0..7144452b2e6 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -4,6 +4,7 @@ read_when: - Running OpenClaw behind an identity-aware proxy - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw - Fixing WebSocket 1008 unauthorized errors with reverse proxy setups + - Deciding where to set HSTS and other HTTP hardening headers --- # Trusted Proxy Auth @@ -34,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 @@ -75,6 +88,52 @@ If `gateway.bind` is `loopback`, include a loopback proxy address in | `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | | `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | +## TLS termination and HSTS + +Use one TLS termination point and apply HSTS there. + +### Recommended pattern: proxy TLS termination + +When your reverse proxy handles HTTPS for `https://control.example.com`, set +`Strict-Transport-Security` at the proxy for that domain. + +- Good fit for internet-facing deployments. +- Keeps certificate + HTTP hardening policy in one place. +- OpenClaw can stay on loopback HTTP behind the proxy. + +Example header value: + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +### Gateway TLS termination + +If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: + +```json5 +{ + gateway: { + tls: { enabled: true }, + http: { + securityHeaders: { + strictTransportSecurity: "max-age=31536000; includeSubDomains", + }, + }, + }, +} +``` + +`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + +### Rollout guidance + +- Start with a short max age first (for example `max-age=300`) while validating traffic. +- Increase to long-lived values (for example `max-age=31536000`) only after confidence is high. +- Add `includeSubDomains` only if every subdomain is HTTPS-ready. +- Use preload only if you intentionally meet preload requirements for your full domain set. +- Loopback-only local development does not benefit from HSTS. + ## Proxy Setup Examples ### Pomerium diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205..b5c5fa8f24a 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 83cad80ba32..c4754da1867 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/development-channels.md b/docs/install/development-channels.md index c31ec7c0618..a585ce9f2a9 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -60,7 +60,9 @@ When you switch channels with `openclaw update`, OpenClaw also syncs plugin sour ## Tagging best practices -- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-`). +- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta). +- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`. +- Legacy `vYYYY.M.D-` tags are still recognized as stable (non-beta). - Keep tags immutable: never move or reuse a tag. - npm dist-tags remain the source of truth for npm installs: - `latest` → stable diff --git a/docs/install/docker.md b/docs/install/docker.md index 8826192c1c1..decd1d779ee 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/hetzner.md b/docs/install/hetzner.md index 7ca46ff7cd9..9baf90278b8 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -17,6 +17,14 @@ Run a persistent OpenClaw Gateway on a Hetzner VPS using Docker, with durable st If you want “OpenClaw 24/7 for ~$5”, this is the simplest reliable setup. Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS and scale up if you hit OOMs. +Security model reminder: + +- Company-shared agents are fine when everyone is in the same trust boundary and the runtime is business-only. +- Keep strict separation: dedicated VPS/runtime + dedicated accounts; no personal Apple/Google/browser/password-manager profiles on that host. +- If users are adversarial to each other, split by gateway/host/OS user. + +See [Security](/gateway/security) and [VPS hosting](/vps). + ## What are we doing (simple terms)? - Rent a small Linux server (Hetzner VPS) diff --git a/docs/install/nix.md b/docs/install/nix.md index a17e46589a7..784ca24707a 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 f5543ce1c45..09c5587579b 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 6606a933b7d..f94c2600776 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 0f37c275cd3..ec2663aefe4 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 54064656dca..6cb878015fb 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 e009a58257c..b65ca5f0d9d 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 8aff5134886..e50a850086a 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 c1abf717cc9..5e1af460e3c 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 12f75eb9f51..e749ecf9d77 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 7d3a8d0190b..978e79ff480 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.21 \ +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.21.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.21.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.21 \ +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.21.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.21.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.21.zip` (and `OpenClaw-2026.2.21.dSYM.zip`) to the GitHub release for tag `v2026.2.21`. +- 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 9c42601b186..86f02d9ed24 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 ea6791ff50e..11b500a8596 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 a9327970261..04c61df266a 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 c135381676c..94c6ddbe00d 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/plugins/voice-call.md b/docs/plugins/voice-call.md index 8637685bbe9..17263ca0509 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -177,6 +177,12 @@ headers are trusted. `webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request remote IP matches the list. +Webhook replay protection is enabled for Twilio and Plivo. Replayed valid webhook +requests are acknowledged but skipped for side effects. + +Twilio conversation turns include a per-turn token in `` callbacks, so +stale/replayed speech callbacks cannot satisfy a newer pending transcript turn. + Example with a stable public host: ```json5 diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 6f9759b3b2f..40f86630dba 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -67,6 +67,42 @@ Use the `cacheRetention` parameter in your model config: When using Anthropic API Key authentication, OpenClaw automatically applies `cacheRetention: "short"` (5-minute cache) for all Anthropic models. You can override this by explicitly setting `cacheRetention` in your config. +### Per-agent cacheRetention overrides + +Use model-level params as your baseline, then override specific agents via `agents.list[].params`. + +```json5 +{ + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { + "anthropic/claude-opus-4-6": { + params: { cacheRetention: "long" }, // baseline for most agents + }, + }, + }, + list: [ + { id: "research", default: true }, + { id: "alerts", params: { cacheRetention: "none" } }, // override for this agent only + ], + }, +} +``` + +Config merge order for cache-related params: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching `id`, overrides by key) + +This lets one agent keep a long-lived cache while another agent on the same model disables caching to avoid write costs on bursty/low-reuse traffic. + +### Bedrock Claude notes + +- Anthropic Claude models on Bedrock (`amazon-bedrock/*anthropic.claude*`) accept `cacheRetention` pass-through when configured. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. +- Anthropic API-key smart defaults also seed `cacheRetention: "short"` for Claude-on-Bedrock model refs when no explicit value is set. + ### Legacy parameter The older `cacheControlTtl` parameter is still supported for backwards compatibility: @@ -101,6 +137,10 @@ with `params.context1m: true` for supported Opus/Sonnet models. OpenClaw maps this to `anthropic-beta: context-1m-2025-08-07` on Anthropic requests. +Note: Anthropic currently rejects `context-1m-*` beta requests when using +OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the +context1m beta header for OAuth auth and keeps the required OAuth betas. + ## Option B: Claude setup-token **Best for:** using your Claude subscription. diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md new file mode 100644 index 00000000000..146e22932c4 --- /dev/null +++ b/docs/providers/kilocode.md @@ -0,0 +1,64 @@ +--- +summary: "Use Kilo Gateway's unified API to access many models in OpenClaw" +read_when: + - You want a single API key for many LLMs + - You want to run models via Kilo Gateway in OpenClaw +--- + +# Kilo Gateway + +Kilo Gateway provides a **unified API** that routes requests to many models behind a single +endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL. + +## Getting an API key + +1. Go to [app.kilo.ai](https://app.kilo.ai) +2. Sign in or create an account +3. Navigate to API Keys and generate a new key + +## CLI setup + +```bash +openclaw onboard --kilocode-api-key +``` + +Or set the environment variable: + +```bash +export KILOCODE_API_KEY="your-api-key" +``` + +## Config snippet + +```json5 +{ + env: { KILOCODE_API_KEY: "sk-..." }, + agents: { + defaults: { + model: { primary: "kilocode/anthropic/claude-opus-4.6" }, + }, + }, +} +``` + +## Surfaced model refs + +The built-in Kilo Gateway catalog currently surfaces these model refs: + +- `kilocode/anthropic/claude-opus-4.6` (default) +- `kilocode/z-ai/glm-5:free` +- `kilocode/minimax/minimax-m2.5:free` +- `kilocode/anthropic/claude-sonnet-4.5` +- `kilocode/openai/gpt-5.2` +- `kilocode/google/gemini-3-pro-preview` +- `kilocode/google/gemini-3-flash-preview` +- `kilocode/x-ai/grok-code-fast-1` +- `kilocode/moonshotai/kimi-k2.5` + +## Notes + +- Model refs are `kilocode//` (e.g., `kilocode/anthropic/claude-opus-4.6`). +- Default model: `kilocode/anthropic/claude-opus-4.6` +- Base URL: `https://api.kilo.ai/api/gateway/` +- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers). +- Kilo Gateway uses a Bearer token with your API key under the hood. diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 726a6040fcc..3b5053fbac7 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -48,3 +48,11 @@ openclaw onboard --non-interactive \ If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY` is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`). + +## Model ID shorthand + +OpenClaw accepts Vercel Claude shorthand model refs and normalizes them at +runtime: + +- `vercel-ai-gateway/claude-opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4.6` +- `vercel-ai-gateway/opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4-6` diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md new file mode 100644 index 00000000000..67561e4a21b --- /dev/null +++ b/docs/reference/prompt-caching.md @@ -0,0 +1,185 @@ +--- +title: "Prompt Caching" +summary: "Prompt caching knobs, merge order, provider behavior, and tuning patterns" +read_when: + - You want to reduce prompt token costs with cache retention + - You need per-agent cache behavior in multi-agent setups + - You are tuning heartbeat and cache-ttl pruning together +--- + +# 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: +[https://docs.anthropic.com/docs/build-with-claude/prompt-caching](https://docs.anthropic.com/docs/build-with-claude/prompt-caching) + +## Primary knobs + +### `cacheRetention` (model and per-agent) + +Set cache retention on model params: + +```yaml +agents: + defaults: + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "short" # none | short | long +``` + +Per-agent override: + +```yaml +agents: + list: + - id: "alerts" + params: + cacheRetention: "none" +``` + +Config merge order: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching agent id; overrides by key) + +### Legacy `cacheControlTtl` + +Legacy values are still accepted and mapped: + +- `5m` -> `short` +- `1h` -> `long` + +Prefer `cacheRetention` for new config. + +### `contextPruning.mode: "cache-ttl"` + +Prunes old tool-result context after cache TTL windows so post-idle requests do not re-cache oversized history. + +```yaml +agents: + defaults: + contextPruning: + mode: "cache-ttl" + ttl: "1h" +``` + +See [Session Pruning](/concepts/session-pruning) for full behavior. + +### Heartbeat keep-warm + +Heartbeat can keep cache windows warm and reduce repeated cache writes after idle gaps. + +```yaml +agents: + defaults: + heartbeat: + every: "55m" +``` + +Per-agent heartbeat is supported at `agents.list[].heartbeat`. + +## Provider behavior + +### Anthropic (direct API) + +- `cacheRetention` is supported. +- With Anthropic API-key auth profiles, OpenClaw seeds `cacheRetention: "short"` for Anthropic model refs when unset. + +### Amazon Bedrock + +- Anthropic Claude model refs (`amazon-bedrock/*anthropic.claude*`) support explicit `cacheRetention` pass-through. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. + +### OpenRouter Anthropic models + +For `openrouter/anthropic/*` model refs, OpenClaw injects Anthropic `cache_control` on system/developer prompt blocks to improve prompt-cache reuse. + +### Other providers + +If the provider does not support this cache mode, `cacheRetention` has no effect. + +## Tuning patterns + +### Mixed traffic (recommended default) + +Keep a long-lived baseline on your main agent, disable caching on bursty notifier agents: + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" + list: + - id: "research" + default: true + heartbeat: + every: "55m" + - id: "alerts" + params: + cacheRetention: "none" +``` + +### Cost-first baseline + +- Set baseline `cacheRetention: "short"`. +- Enable `contextPruning.mode: "cache-ttl"`. +- Keep heartbeat below your TTL only for agents that benefit from warm caches. + +## Cache diagnostics + +OpenClaw exposes dedicated cache-trace diagnostics for embedded agent runs. + +### `diagnostics.cacheTrace` config + +```yaml +diagnostics: + cacheTrace: + enabled: true + filePath: "~/.openclaw/logs/cache-trace.jsonl" # optional + includeMessages: false # default true + includePrompt: false # default true + includeSystem: false # default true +``` + +Defaults: + +- `filePath`: `$OPENCLAW_STATE_DIR/logs/cache-trace.jsonl` +- `includeMessages`: `true` +- `includePrompt`: `true` +- `includeSystem`: `true` + +### Env toggles (one-off debugging) + +- `OPENCLAW_CACHE_TRACE=1` enables cache tracing. +- `OPENCLAW_CACHE_TRACE_FILE=/path/to/cache-trace.jsonl` overrides output path. +- `OPENCLAW_CACHE_TRACE_MESSAGES=0|1` toggles full message payload capture. +- `OPENCLAW_CACHE_TRACE_PROMPT=0|1` toggles prompt text capture. +- `OPENCLAW_CACHE_TRACE_SYSTEM=0|1` toggles system prompt capture. + +### What to inspect + +- Cache trace events are JSONL and include staged snapshots like `session:loaded`, `prompt:before`, `stream:context`, and `session:after`. +- Per-turn cache token impact is visible in normal usage surfaces via `cacheRead` and `cacheWrite` (for example `/usage full` and session usage summaries). + +## Quick troubleshooting + +- High `cacheWrite` on most turns: check for volatile system-prompt inputs and verify model/provider supports your cache settings. +- No effect from `cacheRetention`: confirm model key matches `agents.defaults.models["provider/model"]`. +- Bedrock Nova/Mistral requests with cache settings: expected runtime force to `none`. + +Related docs: + +- [Anthropic](/providers/anthropic) +- [Token Use and Costs](/reference/token-use) +- [Session Pruning](/concepts/session-pruning) +- [Gateway Configuration Reference](/gateway/configuration-reference) diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 3a08575454e..aff09a303e8 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -65,6 +65,44 @@ OpenClaw resolves these via `src/config/sessions.ts`. --- +## Store maintenance and disk controls + +Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json` and transcript artifacts: + +- `mode`: `warn` (default) or `enforce` +- `pruneAfter`: stale-entry age cutoff (default `30d`) +- `maxEntries`: cap entries in `sessions.json` (default `500`) +- `rotateBytes`: rotate `sessions.json` when oversized (default `10mb`) +- `resetArchiveRetention`: retention for `*.reset.` transcript archives (default: same as `pruneAfter`; `false` disables cleanup) +- `maxDiskBytes`: optional sessions-directory budget +- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) + +Enforcement order for disk budget cleanup (`mode: "enforce"`): + +1. Remove oldest archived or orphan transcript artifacts first. +2. If still above the target, evict oldest session entries and their transcript files. +3. Keep going until usage is at or below `highWaterBytes`. + +In `mode: "warn"`, OpenClaw reports potential evictions but does not mutate the store/files. + +Run maintenance on demand: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + +--- + +## Cron sessions and run logs + +Isolated cron runs also create session entries/transcripts, and they have dedicated retention controls: + +- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables). +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl` files (defaults: `2_000_000` bytes and `2000` lines). + +--- + ## Session keys (`sessionKey`) A `sessionKey` identifies _which conversation bucket_ you’re in (routing + isolation). diff --git a/docs/reference/test.md b/docs/reference/test.md index 91db2244bd0..e369b4da7ad 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/reference/token-use.md b/docs/reference/token-use.md index 7f04e19650f..9127e2477e0 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -88,6 +88,11 @@ Heartbeat can keep the cache **warm** across idle gaps. If your model cache TTL is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid re-caching the full prompt, reducing cache write costs. +In multi-agent setups, you can keep one shared model config and tune cache behavior +per agent with `agents.list[].params.cacheRetention`. + +For a full knob-by-knob guide, see [Prompt Caching](/reference/prompt-caching). + For Anthropic API pricing, cache reads are significantly cheaper than input tokens, while cache writes are billed at a higher multiplier. See Anthropic’s prompt caching pricing for the latest rates and TTL multipliers: @@ -108,6 +113,30 @@ agents: every: "55m" ``` +### Example: mixed traffic with per-agent cache strategy + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" # default baseline for most agents + list: + - id: "research" + default: true + heartbeat: + every: "55m" # keep long cache warm for deep sessions + - id: "alerts" + params: + cacheRetention: "none" # avoid cache writes for bursty notifications +``` + +`agents.list[].params` merges on top of the selected model's `params`, so you can +override only `cacheRetention` and inherit other model defaults unchanged. + ### Example: enable Anthropic 1M context beta header Anthropic's 1M context window is currently beta-gated. OpenClaw can inject the @@ -125,6 +154,10 @@ agents: This maps to Anthropic's `context-1m-2025-08-07` beta header. +If you authenticate Anthropic with OAuth/subscription tokens (`sk-ant-oat-*`), +OpenClaw skips the `context-1m-*` beta header because Anthropic currently +rejects that combination with HTTP 401. + ## Tips for reducing token pressure - Use `/compact` to summarize long sessions. diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index fec776bb8f6..058f2fa67fe 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/browser.md b/docs/tools/browser.md index 4d8492f2151..13eaf3203f8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -59,6 +59,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. { browser: { enabled: true, // default: true + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) @@ -86,6 +92,9 @@ Notes: - `cdpUrl` defaults to the relay port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. +- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing. +- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. - Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser. @@ -561,6 +570,20 @@ These are useful for “make the site behave like X” workflows: - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. +Strict-mode example (block private/internal destinations by default): + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], // optional exact allow + }, + }, +} +``` + ## Troubleshooting For Linux-specific issues (especially snap Chromium), see diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a7..964eb40f37b 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 cec00599e2a..619f5cdb38e 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -25,6 +25,12 @@ Exec approvals are enforced locally on the execution host: - **gateway host** → `openclaw` process on the gateway machine - **node host** → node runner (macOS companion app or headless node host) +Trust model note: + +- Gateway-authenticated callers are trusted operators for that Gateway. +- Paired nodes extend that trusted operator capability onto the node host. +- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary. + macOS split: - **node host service** forwards `system.run` to the **macOS app** over local IPC. @@ -119,6 +125,12 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill are treated as allowlisted on nodes (macOS node or headless node host). This uses `skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists. +Important trust notes: + +- This is an **implicit convenience allowlist**, separate from manual path allowlist entries. +- It is intended for trusted operator environments where Gateway and node are in the same trust boundary. +- If you require strict explicit trust, keep `autoAllowSkills: false` and use manual path allowlist entries only. + ## Safe bins (stdin-only) `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) @@ -131,17 +143,20 @@ Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles. Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, -`sort --files0-from`, `sort --compress-program`, `wc --files0-from`, `jq -f/--from-file`, +`sort --files0-from`, `sort --compress-program`, `sort --random-source`, +`sort --temporary-directory`/`-T`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`). Safe bins also enforce explicit per-binary flag policy for options that break stdin-only behavior (for example `sort -o/--output/--compress-program` and grep recursive flags). +Long options are validated fail-closed in safe-bin mode: unknown flags and ambiguous +abbreviations are rejected. Denied flags by safe-bin profile: - `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r` - `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f` -- `sort`: `--compress-program`, `--files0-from`, `--output`, `-o` +- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o` - `wc`: `--files0-from` @@ -150,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 @@ -163,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 1123d3068d2..822717fcf38 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: @@ -122,12 +124,15 @@ running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` noti ## Allowlist + safe bins -Allowlist enforcement matches **resolved binary paths only** (no basename matches). When +Manual allowlist enforcement matches **resolved binary paths only** (no basename matches). When `security=allowlist`, shell commands are auto-allowed only if every pipeline segment is allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in allowlist mode unless every top-level segment satisfies the allowlist (including safe bins). Redirections remain unsupported. +`autoAllowSkills` is a separate convenience path in exec approvals. It is not the same as +manual path allowlist entries. For strict explicit trust, keep `autoAllowSkills` disabled. + Use the two controls for different jobs: - `tools.exec.safeBins`: small, stdin-only stream filters. diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccd..269b6856d03 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 7334da1ec40..9542858c840 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/tools/web.md b/docs/tools/web.md index b0e295cd22a..0d48d746b5e 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,9 +1,10 @@ --- -summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" +summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)" read_when: - You want to enable web_search or web_fetch - You need Brave Search API key setup - You want to use Perplexity Sonar for web search + - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +12,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). +- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -22,6 +23,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_search` calls your configured provider and returns results. - **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity**: returns AI-synthesized answers with citations from real-time web search. + - **Gemini**: returns AI-synthesized answers grounded in Google Search with citations. - Results are cached by query for 15 minutes (configurable). - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. @@ -33,9 +35,23 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. +### Auto-detection + +If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: + +1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config +2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config +3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config +4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config + +If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). + +### Explicit provider + Set the provider in config: ```json5 @@ -43,7 +59,7 @@ Set the provider in config: tools: { web: { search: { - provider: "brave", // or "perplexity" + provider: "brave", // or "perplexity" or "gemini" }, }, }, @@ -139,6 +155,49 @@ If no base URL is set, OpenClaw chooses a default based on the API key source: | `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions | | `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research | +## Using Gemini (Google Search grounding) + +Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), +which returns AI-synthesized answers backed by live Google Search results with citations. + +### Getting a Gemini API key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create an API key +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` + +### Setting up Gemini search + +```json5 +{ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, +} +``` + +**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +### Notes + +- Citation URLs from Gemini grounding are automatically resolved from Google's + redirect URLs to direct URLs. +- Redirect resolution uses the SSRF guard path (HEAD + redirect checks + http/https validation) before returning the final citation URL. +- This redirect resolver follows the trusted-network model (private/internal networks allowed by default) to match Gateway operator trust assumptions. +- The default model (`gemini-2.5-flash`) is fast and cost-effective. + Any Gemini model that supports grounding can be used. + ## web_search Search the web using your configured provider. diff --git a/docs/vps.md b/docs/vps.md index f0b1f7d7777..adb88403890 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -34,6 +34,16 @@ deployments work at a high level. Remote access: [Gateway remote](/gateway/remote) Platforms hub: [Platforms](/platforms) +## Shared company agent on a VPS + +This is a valid setup when the users are in one trust boundary (for example one company team), and the agent is business-only. + +- Keep it on a dedicated runtime (VPS/VM/container + dedicated OS user/accounts). +- Do not sign that runtime into personal Apple/Google accounts or personal browser/password-manager profiles. +- If users are adversarial to each other, split by gateway/host/OS user. + +Security model details: [Security](/gateway/security) + ## Using nodes with a VPS You can keep the Gateway in the cloud and pair **nodes** on your local devices diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0..ad6d2393523 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. @@ -96,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 @@ -230,8 +233,10 @@ Notes: Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. -- For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI - origin to `gateway.controlUi.allowedOrigins`. +- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` + explicitly (full origins). This includes remote dev setups. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but it is a dangerous security mode. Example: diff --git a/docs/web/index.md b/docs/web/index.md index 42baffe8027..3fc48dd993c 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -99,8 +99,10 @@ Open: - Non-loopback binds still **require** a shared token/password (`gateway.auth` or env). - The wizard generates a gateway token by default (even on loopback). - The UI sends `connect.params.auth.token` or `connect.params.auth.password`. -- The Control UI sends anti-clickjacking headers and only accepts same-origin browser - websocket connections unless `gateway.controlUi.allowedOrigins` is set. +- For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` + explicitly (full origins). Without it, gateway startup is refused by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but is a dangerous security downgrade. - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth when `gateway.auth.allowTailscale` is `true` (no token/password required). HTTP API endpoints still require token/password. Set diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9853e372159..307a69a8dcf 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -31,6 +31,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. +## Control UI agents tools panel + +- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each + tool as `core` or `plugin:` (plus `optional` for optional plugin tools). +- If `tools.catalog` is unavailable, the panel falls back to a built-in static list. +- The panel edits profile and override config, but effective runtime access still follows policy + precedence (`allow`/`deny`, per-agent and provider/channel overrides). + ## Remote use - Remote mode tunnels the gateway WebSocket over SSH/Tailscale. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index da6b3ad9afb..8f752f59350 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fe..904d21d4d3f 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/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index aabc5adf8fe..5db42331207 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => { const handleAction = bluebubblesMessageActions.handleAction!; const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); + const blueBubblesConfig = (): OpenClawConfig => ({ + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }); + const runReactAction = async (params: Record) => { + return await callHandleAction({ + action: "react", + params, + cfg: blueBubblesConfig(), + accountId: null, + }); + }; beforeEach(() => { vi.clearAllMocks(); @@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => { it("sends reaction successfully with chatGuid", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( @@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => { it("sends reaction removal successfully", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 22c5d3e42e8..e774ef6c85e 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -2,13 +2,13 @@ import { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS, createActionGate, + extractToolSend, jsonResult, readNumberParam, readReactionParams, readStringParam, type ChannelMessageActionAdapter, type ChannelMessageActionName, - type ChannelToolSend, } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { const account = resolveBlueBubblesAccount({ cfg: cfg, diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 17060229930..d6b12d311f8 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => { setBlueBubblesRuntime(runtimeStub); }); + async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { + const largeBuffer = new Uint8Array(params.bufferBytes); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), + }), + ).rejects.toThrow("too large"); + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => { }); it("throws when attachment exceeds max bytes", async () => { - const largeBuffer = new Uint8Array(10 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + await expectAttachmentTooLarge({ + bufferBytes: 10 * 1024 * 1024, + maxBytes: 5 * 1024 * 1024, }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - maxBytes: 5 * 1024 * 1024, - }), - ).rejects.toThrow("too large"); }); it("uses default max bytes when not specified", async () => { - const largeBuffer = new Uint8Array(9 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("too large"); + await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); }); it("uses attachment mimeType as fallback when response has no content-type", async () => { @@ -274,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 3b8850f2154..6ccb043845f 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/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index d22ded63613..cc37829bc9d 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({ }); describe("chat", () => { + function mockOkTextResponse() { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + } + + async function expectCalledUrlIncludesPassword(params: { + password: string; + invoke: () => Promise; + }) { + mockOkTextResponse(); + await params.invoke(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(`password=${params.password}`); + } + + async function expectCalledUrlUsesConfigCredentials(params: { + serverHost: string; + password: string; + invoke: (cfg: { + channels: { bluebubbles: { serverUrl: string; password: string } }; + }) => Promise; + }) { + mockOkTextResponse(); + await params.invoke({ + channels: { + bluebubbles: { + serverUrl: `http://${params.serverHost}`, + password: params.password, + }, + }, + }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(params.serverHost); + expect(calledUrl).toContain(`password=${params.password}`); + } + describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty or whitespace", async () => { for (const chatGuid of ["", " "]) { @@ -73,18 +111,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -119,25 +153,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + markBlueBubblesChatRead("chat-123", { + cfg, + }), }); - - await markBlueBubblesChatRead("chat-123", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); }); @@ -536,18 +559,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -582,25 +601,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg, + }), }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe1..e4bef3fd73b 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/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts index 88e84039417..c768385e03a 100644 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ b/extensions/bluebubbles/src/monitor-shared.ts @@ -1,8 +1,10 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import type { BlueBubblesAccountConfig } from "./types.js"; +export { normalizeWebhookPath }; + export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; @@ -30,18 +32,6 @@ export type WebhookTarget = { export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; -export function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { const raw = config?.webhookPath?.trim(); if (raw) { diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index ca6b42ab5df..78b2876b5e0 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { let next = cfg; const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); + const validateServerUrlInput = (value: unknown): string | undefined => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }; + const promptServerUrl = async (initialValue?: string): Promise => { + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + initialValue, + validate: validateServerUrlInput, + }); + return String(entered).trim(); + }; // Prompt for server URL let serverUrl = resolvedAccount.config.serverUrl?.trim(); @@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { ].join("\n"), "BlueBubbles server URL", ); - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(); } else { const keepUrl = await prompter.confirm({ message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, initialValue: true, }); if (!keepUrl) { - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - initialValue: serverUrl, - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(serverUrl); } } diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 0ea99f911f6..419ccc81e45 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,6 +19,27 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { + async function expectRemovedReaction(emoji: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji, + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + } + it("throws when chatGuid is empty", async () => { await expect( sendBlueBubblesReaction({ @@ -208,45 +229,11 @@ describe("reactions", () => { }); it("sends reaction removal with dash prefix", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("love"); }); it("strips leading dash from emoji when remove flag is set", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "-love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("-love"); }); it("uses custom partIndex when provided", async () => { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 9872372641e..6b2e5fe051f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) { }); } +function mockNewChatSendResponse(guid: string) { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid }, + }), + ), + }); +} + describe("send", () => { describe("resolveChatGuidForTarget", () => { const resolveHandleTargetGuid = async (data: Array>) => { @@ -453,20 +470,7 @@ describe("send", () => { }); it("strips markdown when creating a new chat", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-stripped" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-stripped"); const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { serverUrl: "http://localhost:1234", @@ -483,20 +487,7 @@ describe("send", () => { }); it("creates a new chat when handle target is missing", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-guid" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-guid"); const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { serverUrl: "http://localhost:1234", diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a..72ccd991857 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 155e611f6a8..8dd561f27f3 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7890659fef1..f3a32e4542f 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,7 +1,12 @@ -import { spawn } from "node:child_process"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; +import { + approveDevicePairing, + listDevicePairing, + resolveGatewayBindUrl, + runPluginCommandWithTimeout, + resolveTailnetHostWithRunner, +} from "openclaw/plugin-sdk"; import qrcode from "qrcode-terminal"; function renderQrAscii(data: string): Promise { @@ -37,77 +42,6 @@ type ResolveAuthResult = { error?: string; }; -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout( - argv: string[], - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const [command, ...args] = argv; - if (!command) { - resolve({ code: 1, stdout: "", stderr: "command is required" }); - return; - } - const proc = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${timeoutMs}ms`, - }); - }, timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -239,48 +173,12 @@ function pickTailnetIPv4(): string | null { } async function resolveTailnetHost(): Promise { - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000); - if (result.code !== 0) { - continue; - } - const raw = result.stdout.trim(); - if (!raw) { - continue; - } - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; - if (dns && dns.length > 0) { - return dns.replace(/\.$/, ""); - } - const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : []; - if (ips.length > 0) { - return ips[0] ?? null; - } - } catch { - continue; - } - } - return null; -} - -function parsePossiblyNoisyJsonObject(raw: string): Record { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; - } + return await resolveTailnetHostWithRunner((argv, opts) => + runPluginCommandWithTimeout({ + argv, + timeoutMs: opts.timeoutMs, + }), + ); } function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { @@ -365,29 +263,16 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise ({ }, })); -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 {}, })); @@ -110,6 +110,10 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; import { emitDiagnosticEvent } from "openclaw/plugin-sdk"; import { createDiagnosticsOtelService } from "./service.js"; +const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test"; +const OTEL_TEST_ENDPOINT = "http://otel-collector:4318"; +const OTEL_TEST_PROTOCOL = "http/protobuf"; + function createLogger() { return { info: vi.fn(), @@ -119,7 +123,15 @@ function createLogger() { }; } -function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { +type OtelContextFlags = { + traces?: boolean; + metrics?: boolean; + logs?: boolean; +}; +function createOtelContext( + endpoint: string, + { traces = false, metrics = false, logs = false }: OtelContextFlags = {}, +): OpenClawPluginServiceContext { return { config: { diagnostics: { @@ -127,17 +139,46 @@ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext otel: { enabled: true, endpoint, - protocol: "http/protobuf", - traces: true, - metrics: false, - logs: false, + protocol: OTEL_TEST_PROTOCOL, + traces, + metrics, + logs, }, }, }, logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", + stateDir: OTEL_TEST_STATE_DIR, }; } + +function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { + return createOtelContext(endpoint, { traces: true }); +} + +type RegisteredLogTransport = (logObj: Record) => void; +function setupRegisteredTransports() { + const registeredTransports: RegisteredLogTransport[] = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + return { registeredTransports, stopTransport }; +} + +async function emitAndCaptureLog(logObj: Record) { + const { registeredTransports } = setupRegisteredTransports(); + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true }); + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.(logObj); + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + await service.stop?.(ctx); + return emitCall; +} + describe("diagnostics-otel service", () => { beforeEach(() => { telemetryState.counters.clear(); @@ -154,31 +195,10 @@ describe("diagnostics-otel service", () => { }); test("records message-flow metrics and spans", async () => { - const registeredTransports: Array<(logObj: Record) => void> = []; - const stopTransport = vi.fn(); - registerLogTransportMock.mockImplementation((transport) => { - registeredTransports.push(transport); - return stopTransport; - }); + const { registeredTransports } = setupRegisteredTransports(); const service = createDiagnosticsOtelService(); - const ctx: OpenClawPluginServiceContext = { - config: { - diagnostics: { - enabled: true, - otel: { - enabled: true, - endpoint: "http://otel-collector:4318", - protocol: "http/protobuf", - traces: true, - metrics: true, - logs: true, - }, - }, - }, - logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", - }; + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true }); await service.start(ctx); emitDiagnosticEvent({ @@ -293,4 +313,55 @@ describe("diagnostics-otel service", () => { expect(options?.url).toBe("https://collector.example.com/v1/Traces"); await service.stop?.(ctx); }); + + test("redacts sensitive data from log messages before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: "Using API key sk-1234567890abcdef1234567890abcdef", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + + expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef"); + expect(emitCall?.body).toContain("sk-123"); + expect(emitCall?.body).toContain("…"); + }); + + test("redacts sensitive data from log attributes before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 1: "auth configured", + _meta: { logLevelName: "DEBUG", date: new Date() }, + }); + + const tokenAttr = emitCall?.attributes?.["openclaw.token"]; + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + if (typeof tokenAttr === "string") { + expect(tokenAttr).toContain("…"); + } + }); + + test("redacts sensitive reason in session.state metric attributes", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "session.state", + state: "waiting", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + }); + + const sessionCounter = telemetryState.counters.get("openclaw.session.state"); + expect(sessionCounter?.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + "openclaw.reason": expect.stringContaining("…"), + }), + ); + const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; + expect(typeof attrs?.["openclaw.reason"]).toBe("string"); + expect(String(attrs?.["openclaw.reason"])).not.toContain( + "ghp_abcdefghijklmnopqrstuvwxyz123456", + ); + await service.stop?.(ctx); + }); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 78975eb36e2..be9a547963f 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"; @@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; -import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk"; const DEFAULT_SERVICE_NAME = "openclaw"; @@ -54,6 +54,14 @@ function formatError(err: unknown): string { } } +function redactOtelAttributes(attributes: Record) { + const redactedAttributes: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value; + } + return redactedAttributes; +} + export function createDiagnosticsOtelService(): OpenClawPluginService { let sdk: NodeSDK | null = null; let logProvider: LoggerProvider | null = null; @@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes["openclaw.code.location"] = meta.path.filePathWithLine; } + // OTLP can leave the host boundary, so redact string fields before export. otelLogger.emit({ - body: message, + body: redactSensitiveText(message), severityText: logLevelName, severityNumber, - attributes, + attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }); } catch (err) { @@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const redactedError = redactSensitiveText(evt.error); const spanAttrs: Record = { ...attrs, - "openclaw.error": evt.error, + "openclaw.error": redactedError, }; if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); @@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { const span = tracer.startSpan("openclaw.webhook.error", { attributes: spanAttrs, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -496,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { } }; + const addSessionIdentityAttrs = ( + spanAttrs: Record, + evt: { sessionKey?: string; sessionId?: string }, + ) => { + if (evt.sessionKey) { + spanAttrs["openclaw.sessionKey"] = evt.sessionKey; + } + if (evt.sessionId) { + spanAttrs["openclaw.sessionId"] = evt.sessionId; + } + }; + const recordMessageProcessed = ( evt: Extract, ) => { @@ -511,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); } @@ -524,11 +541,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { spanAttrs["openclaw.messageId"] = String(evt.messageId); } if (evt.reason) { - spanAttrs["openclaw.reason"] = evt.reason; + spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); - if (evt.outcome === "error") { - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + if (evt.outcome === "error" && evt.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } span.end(); }; @@ -557,7 +574,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ) => { const attrs: Record = { "openclaw.state": evt.state }; if (evt.reason) { - attrs["openclaw.reason"] = evt.reason; + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); } sessionStateCounter.add(1, attrs); }; @@ -574,12 +591,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; spanAttrs["openclaw.ageMs"] = evt.ageMs; const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); @@ -645,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 98ca5edb26e..2553b1c0814 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/discord", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Discord channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 446f8747b89..5ef3ab09cae 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, @@ -347,16 +348,8 @@ export const discordPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectDiscordStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1debb8f4ee0..afacb5432eb 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "@sinclair/typebox": "0.34.48", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 91d390ac04d..f18658e62b5 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 5851e849037..fc600481e85 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/feishu/src/media.ts b/extensions/feishu/src/media.ts index bbe56bbb02a..73c5ff2652c 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; export type DownloadImageResult = { buffer: Buffer; @@ -268,18 +268,11 @@ export async function sendImageFeishu(params: { accountId?: string; }): Promise { const { cfg, to, imageKey, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ image_key: imageKey }); if (replyToMessageId) { @@ -320,18 +313,11 @@ export async function sendFileFeishu(params: { }): Promise { const { cfg, to, fileKey, replyToMessageId, accountId } = params; const msgType = params.msgType ?? "file"; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ file_key: fileKey }); if (replyToMessageId) { diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts new file mode 100644 index 00000000000..7d0d28663cc --- /dev/null +++ b/extensions/feishu/src/send-target.ts @@ -0,0 +1,25 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; + +export function resolveFeishuSendTarget(params: { + cfg: ClawdbotConfig; + to: string; + accountId?: string; +}) { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + const client = createFeishuClient(account); + const receiveId = normalizeFeishuTarget(params.to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${params.to}`); + } + return { + client, + receiveId, + receiveIdType: resolveReceiveIdType(receiveId), + }; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index c97601ccccb..341ff3ed64d 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js"; import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; -import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; +import type { FeishuSendResult } from "./types.js"; export type FeishuMessageInfo = { messageId: string; @@ -128,18 +128,7 @@ export async function sendMessageFeishu( params: SendFeishuMessageParams, ): Promise { const { cfg, to, text, replyToMessageId, mentions, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu", @@ -188,18 +177,7 @@ export type SendFeishuCardParams = { export async function sendCardFeishu(params: SendFeishuCardParams): Promise { const { cfg, to, card, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const content = JSON.stringify(card); if (replyToMessageId) { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 93cf4166108..56f1fc36557 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -132,6 +132,26 @@ export class FeishuStreamingSession { this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); } + private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise { + if (!this.state) { + return; + } + const apiBase = resolveApiBase(this.creds.domain); + this.state.sequence += 1; + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { + method: "PUT", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: text, + sequence: this.state.sequence, + uuid: `s_${this.state.cardId}_${this.state.sequence}`, + }), + }).catch((error) => onError?.(error)); + } + async update(text: string): Promise { if (!this.state || this.closed) { return; @@ -150,20 +170,7 @@ export class FeishuStreamingSession { return; } this.state.currentText = text; - this.state.sequence += 1; - const apiBase = resolveApiBase(this.creds.domain); - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch((e) => this.log?.(`Update failed: ${String(e)}`)); + await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`)); }); await this.queue; } @@ -181,19 +188,7 @@ export class FeishuStreamingSession { // Only send final update if content differs from what's already displayed if (text && text !== this.state.currentText) { - this.state.sequence += 1; - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch(() => {}); + await this.updateCardContent(text); this.state.currentText = text; } diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md deleted file mode 100644 index 4e1dee975ea..00000000000 --- a/extensions/google-antigravity-auth/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Google Antigravity Auth (OpenClaw plugin) - -OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-antigravity-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-antigravity --set-default -``` - -## Notes - -- Antigravity uses Google Cloud project quotas. -- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts deleted file mode 100644 index 055cb15e00b..00000000000 --- a/extensions/google-antigravity-auth/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - isWSL2Sync, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync -const decode = (s: string) => Buffer.from(s, "base64").toString(); -const CLIENT_ID = decode( - "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", -); -const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); -const REDIRECT_URI = "http://localhost:51121/oauth-callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking"; - -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; - -const CODE_ASSIST_ENDPOINTS = [ - "https://cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", -]; - -const RESPONSE_PAGE = ` - - - - OpenClaw Antigravity OAuth - - -
-

Authentication complete

-

You can return to the terminal.

-
- -`; - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function buildAuthUrl(params: { challenge: string; state: string }): string { - const url = new URL(AUTH_URL); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("response_type", "code"); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPES.join(" ")); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - return url.toString(); -} - -function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter in URL" }; - } - return { code, state }; - } catch { - return { error: "Paste the full redirect URL (not just the code)." }; - } -} - -async function startCallbackServer(params: { timeoutMs: number }) { - const redirect = new URL(REDIRECT_URI); - const port = redirect.port ? Number(redirect.port) : 51121; - - let settled = false; - let resolveCallback: (url: URL) => void; - let rejectCallback: (err: Error) => void; - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = (url) => { - if (settled) { - return; - } - settled = true; - resolve(url); - }; - rejectCallback = (err) => { - if (settled) { - return; - } - settled = true; - reject(err); - }; - }); - - const timeout = setTimeout(() => { - rejectCallback(new Error("Timed out waiting for OAuth callback")); - }, params.timeoutMs); - timeout.unref?.(); - - const server = createServer((request, response) => { - if (!request.url) { - response.writeHead(400, { "Content-Type": "text/plain" }); - response.end("Missing URL"); - return; - } - - const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); - if (url.pathname !== redirect.pathname) { - response.writeHead(404, { "Content-Type": "text/plain" }); - response.end("Not found"); - return; - } - - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(RESPONSE_PAGE); - resolveCallback(url); - - setImmediate(() => { - server.close(); - }); - }); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - server.off("error", onError); - reject(err); - }; - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function exchangeCode(params: { - code: string; - verifier: string; -}): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: params.code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: params.verifier, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token exchange failed: ${text}`); - } - - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expiresIn = data.expires_in ?? 0; - - if (!access) { - throw new Error("Token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Token exchange returned no refresh_token"); - } - - const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; - return { access, refresh, expires }; -} - -async function fetchUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { email?: string }; - return data.email; - } catch { - return undefined; - } -} - -async function fetchProjectId(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }; - - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - cloudaicompanionProject?: string | { id?: string }; - }; - - if (typeof data.cloudaicompanionProject === "string") { - return data.cloudaicompanionProject; - } - if ( - data.cloudaicompanionProject && - typeof data.cloudaicompanionProject === "object" && - data.cloudaicompanionProject.id - ) { - return data.cloudaicompanionProject.id; - } - } catch { - // ignore - } - } - - return DEFAULT_PROJECT_ID; -} - -async function loginAntigravity(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}): Promise<{ - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}> { - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - const authUrl = buildAuthUrl({ challenge, state }); - - let callbackServer: Awaited> | null = null; - const needsManual = shouldUseManualOAuthFlow(params.isRemote); - if (!needsManual) { - try { - callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); - } catch { - callbackServer = null; - } - } - - if (!callbackServer) { - await params.note( - [ - "Open the URL in your local browser.", - "After signing in, copy the full redirect URL and paste it back here.", - "", - `Auth URL: ${authUrl}`, - `Redirect URI: ${REDIRECT_URI}`, - ].join("\n"), - "Google Antigravity OAuth", - ); - // Output raw URL below the box for easy copying (fixes #1772) - params.log(""); - params.log("Copy this URL:"); - params.log(authUrl); - params.log(""); - } - - if (!needsManual) { - params.progress.update("Opening Google sign-in…"); - try { - await params.openUrl(authUrl); - } catch { - // ignore - } - } - - let code = ""; - let returnedState = ""; - - if (callbackServer) { - params.progress.update("Waiting for OAuth callback…"); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code") ?? ""; - returnedState = callback.searchParams.get("state") ?? ""; - await callbackServer.close(); - } else { - params.progress.update("Waiting for redirect URL…"); - const input = await params.prompt("Paste the redirect URL: "); - const parsed = parseCallbackInput(input); - if ("error" in parsed) { - throw new Error(parsed.error); - } - code = parsed.code; - returnedState = parsed.state; - } - - if (!code) { - throw new Error("Missing OAuth code"); - } - if (returnedState !== state) { - throw new Error("OAuth state mismatch. Please try again."); - } - - params.progress.update("Exchanging code for tokens…"); - const tokens = await exchangeCode({ code, verifier }); - const email = await fetchUserEmail(tokens.access); - const projectId = await fetchProjectId(tokens.access); - - params.progress.stop("Antigravity OAuth complete"); - return { ...tokens, email, projectId }; -} - -const antigravityPlugin = { - id: "google-antigravity-auth", - name: "Google Antigravity Auth", - description: "OAuth flow for Google Antigravity (Cloud Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "google-antigravity", - label: "Google Antigravity", - docsPath: "/providers/models", - aliases: ["antigravity"], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); - try { - const result = await loginAntigravity({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (message) => String(await ctx.prompter.text({ message })), - note: ctx.prompter.note, - log: (message) => ctx.runtime.log(message), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "google-antigravity", - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: [ - "Antigravity uses Google Cloud project quotas.", - "Enable Gemini for Google Cloud on your project if requests fail.", - ], - }); - } catch (err) { - spin.stop("Antigravity OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json deleted file mode 100644 index 2ef207f0486..00000000000 --- a/extensions/google-antigravity-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-antigravity-auth", - "providers": ["google-antigravity"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json deleted file mode 100644 index e730f4dcbe4..00000000000 --- a/extensions/google-antigravity-auth/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.22", - "private": true, - "description": "OpenClaw Google Antigravity OAuth provider plugin", - "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index c9675901266..9ec1c1af360 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index bd166510c7a..fd43f2faa26 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { - "google-auth-library": "^10.5.0" - }, - "devDependencies": { - "openclaw": "workspace:*" + "google-auth-library": "^10.6.1" }, "peerDependencies": { "openclaw": ">=2026.1.26" diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 6eec88abbe4..2a4e9935e2c 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { isSenderAllowed } from "./monitor.js"; describe("isSenderAllowed", () => { - it("matches allowlist entries with raw email", () => { - expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true); + it("matches raw email entries only when dangerous name matching is enabled", () => { + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true); }); it("does not treat users/ entries as email allowlist (deprecated form)", () => { @@ -17,6 +18,8 @@ describe("isSenderAllowed", () => { }); it("rejects non-matching raw email entries", () => { - expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe( + false, + ); }); }); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 689f10341c2..c7529489695 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -6,6 +6,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSingleWebhookTargetAsync, @@ -287,6 +288,7 @@ export function isSenderAllowed( senderId: string, senderEmail: string | undefined, allowFrom: string[], + allowNameMatching = false, ) { if (allowFrom.includes("*")) { return true; @@ -305,8 +307,8 @@ export function isSenderAllowed( return normalizeUserId(withoutPrefix) === normalizedSenderId; } - // Raw email allowlist entries remain supported for usability. - if (normalizedEmail && isEmailLike(withoutPrefix)) { + // Raw email allowlist entries are a break-glass override. + if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { return withoutPrefix === normalizedEmail; } @@ -409,6 +411,7 @@ async function processMessageWithPipeline(params: { const senderId = sender?.name ?? ""; const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? undefined; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const allowBots = account.config.allowBots === true; if (!allowBots) { @@ -489,6 +492,7 @@ async function processMessageWithPipeline(params: { senderId, senderEmail, groupUsers.map((v) => String(v)), + allowNameMatching, ); if (!ok) { logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); @@ -508,7 +512,12 @@ async function processMessageWithPipeline(params: { warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); + const senderAllowedForCommands = isSenderAllowed( + senderId, + senderEmail, + commandAllowFrom, + allowNameMatching, + ); const commandAuthorized = shouldComputeAuth ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 926e012ddd1..7eeafd8b872 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/imessage", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 39e2d8485f8..e5937ee763b 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/irc", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw IRC channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 59121e7ff58..6993baa0ba7 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,13 +1,15 @@ import { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, - deleteAccountFromConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; import { @@ -319,37 +321,23 @@ export const ircPlugin: ChannelPlugin = { lastError: null, }, buildChannelSummary: ({ account, snapshot }) => ({ - configured: snapshot.configured ?? false, + ...buildBaseChannelStatusSummary(snapshot), host: account.host, port: snapshot.port, tls: account.tls, nick: account.nick, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ cfg, account, timeoutMs }) => probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 14ce51b39a4..74a7ac363af 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -45,6 +46,7 @@ export const IrcAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), host: z.string().optional(), port: z.number().int().min(1).max(65535).optional(), tls: z.boolean().optional(), @@ -62,15 +64,7 @@ export const IrcAccountSchemaBase = z channels: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/irc/src/inbound.policy.test.ts b/extensions/irc/src/inbound.policy.test.ts new file mode 100644 index 00000000000..c5b6cdfac89 --- /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 dd466f09507..efb0b781d4a 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,11 +1,16 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, + isDangerousNameMatchingEnabled, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -26,33 +31,35 @@ 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: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const text = params.payload.text ?? ""; - const mediaList = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - if (params.sendReply) { await params.sendReply(params.target, combined, params.payload.replyToId); } else { @@ -86,6 +93,7 @@ export async function handleIrcInbound(params: { const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = resolveDefaultGroupPolicy(config); @@ -129,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, @@ -140,6 +151,7 @@ export async function handleIrcInbound(params: { const senderAllowedForCommands = resolveIrcAllowlistMatch({ allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, message, + allowNameMatching, }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const commandGate = resolveControlCommandGate({ @@ -161,6 +173,7 @@ export async function handleIrcInbound(params: { message, outerAllowFrom: effectiveGroupAllowFrom, innerAllowFrom: groupAllowFrom, + allowNameMatching, }); if (!senderAllowed) { runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); @@ -175,6 +188,7 @@ export async function handleIrcInbound(params: { const dmAllowed = resolveIrcAllowlistMatch({ allowFrom: effectiveAllowFrom, message, + allowNameMatching, }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") { @@ -317,26 +331,22 @@ export async function handleIrcInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverIrcReply({ + payload, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverIrcReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, @@ -351,3 +361,7 @@ export async function handleIrcInbound(params: { }, }); } + +export const __testing = { + resolveIrcEffectiveAllowlists, +}; diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index d4dbec89db8..4e07fa28abd 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -39,13 +39,12 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.configured) { throw new Error( diff --git a/extensions/irc/src/normalize.test.ts b/extensions/irc/src/normalize.test.ts index a498ffaacd0..428f0015fd2 100644 --- a/extensions/irc/src/normalize.test.ts +++ b/extensions/irc/src/normalize.test.ts @@ -30,6 +30,8 @@ describe("irc normalize", () => { }; expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org"); + expect(buildIrcAllowlistCandidates(message)).not.toContain("alice"); + expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice"); expect( resolveIrcAllowlistMatch({ allowFrom: ["alice!ident@example.org"], @@ -38,9 +40,16 @@ describe("irc normalize", () => { ).toBe(true); expect( resolveIrcAllowlistMatch({ - allowFrom: ["bob"], + allowFrom: ["alice"], message, }).allowed, ).toBe(false); + expect( + resolveIrcAllowlistMatch({ + allowFrom: ["alice"], + message, + allowNameMatching: true, + }).allowed, + ).toBe(true); }); }); diff --git a/extensions/irc/src/normalize.ts b/extensions/irc/src/normalize.ts index 89d135dbfd7..90b731dcbbf 100644 --- a/extensions/irc/src/normalize.ts +++ b/extensions/irc/src/normalize.ts @@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string { return base; } -export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] { +export function buildIrcAllowlistCandidates( + message: IrcInboundMessage, + params?: { allowNameMatching?: boolean }, +): string[] { const nick = message.senderNick.trim().toLowerCase(); const user = message.senderUser?.trim().toLowerCase(); const host = message.senderHost?.trim().toLowerCase(); const candidates = new Set(); - if (nick) { + if (nick && params?.allowNameMatching === true) { candidates.add(nick); } if (nick && user) { @@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[ export function resolveIrcAllowlistMatch(params: { allowFrom: string[]; message: IrcInboundMessage; + allowNameMatching?: boolean; }): { allowed: boolean; source?: string } { const allowFrom = new Set( params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean), @@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: { if (allowFrom.has("*")) { return { allowed: true, source: "wildcard" }; } - const candidates = buildIrcAllowlistCandidates(params.message); + const candidates = buildIrcAllowlistCandidates(params.message, { + allowNameMatching: params.allowNameMatching, + }); for (const candidate of candidates) { if (allowFrom.has(candidate)) { return { allowed: true, source: candidate }; diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts index be3f65e617e..4136466ca79 100644 --- a/extensions/irc/src/policy.test.ts +++ b/extensions/irc/src/policy.test.ts @@ -50,6 +50,14 @@ describe("irc policy", () => { }), ).toBe(false); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice!ident@example.org"], + innerAllowFrom: [], + }), + ).toBe(true); expect( resolveIrcGroupSenderAllowed({ groupPolicy: "allowlist", @@ -57,6 +65,15 @@ describe("irc policy", () => { outerAllowFrom: ["alice"], innerAllowFrom: [], }), + ).toBe(false); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice"], + innerAllowFrom: [], + allowNameMatching: true, + }), ).toBe(true); }); diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts index 81828a5ac09..356f0fae7d8 100644 --- a/extensions/irc/src/policy.ts +++ b/extensions/irc/src/policy.ts @@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: { message: IrcInboundMessage; outerAllowFrom: string[]; innerAllowFrom: string[]; + allowNameMatching?: boolean; }): boolean { const policy = params.groupPolicy ?? "allowlist"; const inner = normalizeIrcAllowlist(params.innerAllowFrom); const outer = normalizeIrcAllowlist(params.outerAllowFrom); if (inner.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: inner, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } if (outer.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: outer, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } return policy === "open"; } diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index 2da3d31bafc..03e2d3f5eb3 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -32,6 +32,11 @@ export type IrcNickServConfig = { export type IrcAccountConfig = { name?: string; enabled?: boolean; + /** + * Break-glass override: allow nick-only allowlist matching. + * Default behavior requires host/user-qualified identities. + */ + dangerouslyAllowNameMatching?: boolean; host?: string; port?: number; tls?: boolean; diff --git a/extensions/line/package.json b/extensions/line/package.json index 69907bd5ef7..402952b084c 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/line", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index c2864ec70c0..b11bdc99870 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,10 +1,6 @@ -import type { - OpenClawConfig, - PluginRuntime, - ResolvedLineAccount, - RuntimeEnv, -} from "openclaw/plugin-sdk"; +import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function resolveAccount( resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], cfg: OpenClawConfig, diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index abd1aedf17c..e5b0ce333f5 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -33,20 +33,10 @@ function createRuntime() { return { runtime, probeLineBot, monitorLineProvider }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { token: string; secret: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ac49940d256..a260d96c961 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,6 @@ import { buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, @@ -595,17 +596,7 @@ export const linePlugin: ChannelPlugin = { } return issues; }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 7e9e24eade1..9e182b90134 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.22", + "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 f40d0351fec..6a58118618c 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). @@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; - const primary = api.config?.agents?.defaults?.model?.primary; + const defaultsModel = api.config?.agents?.defaults?.model; + const primary = + typeof defaultsModel === "string" + ? defaultsModel.trim() + : (defaultsModel?.primary?.trim() ?? undefined); const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; @@ -176,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 e6c7665735e..f60a1ff73a6 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.22", + "version": "2026.2.25", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 294e625ce2b..78de735f8ef 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("lobster plugin tool", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeAll(async () => { ({ createLobsterTool } = await import("./lobster-tool.js")); @@ -79,29 +74,7 @@ describe("lobster plugin tool", () => { }); afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); }); afterAll(async () => { @@ -156,17 +129,6 @@ describe("lobster plugin tool", () => { }); }; - const createWindowsShimFixture = async (params: { - shimPath: string; - scriptPath: string; - scriptToken: string; - }) => { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); - }; - it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -281,10 +243,10 @@ describe("lobster plugin tool", () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); - await createWindowsShimFixture({ + await createWindowsCmdShimFixture({ shimPath, scriptPath: shimScriptPath, - scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); process.env.PATHEXT = ".CMD;.EXE"; process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts new file mode 100644 index 00000000000..30f2dc81d1b --- /dev/null +++ b/extensions/lobster/src/test-helpers.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; + +const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; + +export type PlatformPathEnvSnapshot = { + platformDescriptor: PropertyDescriptor | undefined; + env: Record; +}; + +export function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot { + return { + platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + env: { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + Pathext: process.env.Pathext, + }, + }; +} + +export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void { + if (snapshot.platformDescriptor) { + Object.defineProperty(process, "platform", snapshot.platformDescriptor); + } + + for (const key of PATH_ENV_KEYS) { + const value = snapshot.env[key]; + if (value === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = value; + } +} + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index 75f49f34b05..e3d791e36e4 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -2,22 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); @@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => { }); afterEach(async () => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); @@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index fcbaf44e2d9..deffac4088a 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 89d5ce2b038..615cbc74855 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,19 +1,15 @@ { "name": "@openclaw/matrix", - "version": "2026.2.6-3", + "version": "2026.2.25", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", - "fake-indexeddb": "^6.2.5", - "markdown-it": "14.1.0", - "matrix-js-sdk": "^40.1.0", - "music-metadata": "^11.11.2", + "@vector-im/matrix-bot-sdk": "0.8.0-element.3", + "markdown-it": "14.1.1", + "music-metadata": "^11.12.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 9b4f3ac89ca..868d46632c9 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -39,9 +39,6 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { if (gate("channelInfo")) { actions.add("channel-info"); } - if (account.config.encryption === true && gate("verification")) { - actions.add("permissions"); - } return Array.from(actions); }, supportsAction: ({ action }) => action !== "poll", @@ -193,45 +190,6 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { ); } - if (action === "permissions") { - const operation = ( - readStringParam(params, "operation") ?? - readStringParam(params, "mode") ?? - "verification-list" - ) - .trim() - .toLowerCase(); - const operationToAction: Record = { - "encryption-status": "encryptionStatus", - "verification-list": "verificationList", - "verification-request": "verificationRequest", - "verification-accept": "verificationAccept", - "verification-cancel": "verificationCancel", - "verification-start": "verificationStart", - "verification-generate-qr": "verificationGenerateQr", - "verification-scan-qr": "verificationScanQr", - "verification-sas": "verificationSas", - "verification-confirm": "verificationConfirm", - "verification-mismatch": "verificationMismatch", - "verification-confirm-qr": "verificationConfirmQr", - }; - const resolvedAction = operationToAction[operation]; - if (!resolvedAction) { - throw new Error( - `Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys( - operationToAction, - ).join(", ")}`, - ); - } - return await handleMatrixAction( - { - ...params, - action: resolvedAction, - }, - cfg, - ); - } - throw new Error(`Action ${action} is not supported for provider matrix.`); }, }; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 30c691b8d9d..20dde4dc6ed 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -68,7 +68,6 @@ function buildMatrixConfigUpdate( userId?: string; accessToken?: string; password?: string; - register?: boolean; deviceName?: string; initialSyncLimit?: number; }, @@ -85,7 +84,6 @@ function buildMatrixConfigUpdate( ...(input.userId ? { userId: input.userId } : {}), ...(input.accessToken ? { accessToken: input.accessToken } : {}), ...(input.password ? { password: input.password } : {}), - ...(typeof input.register === "boolean" ? { register: input.register } : {}), ...(input.deviceName ? { deviceName: input.deviceName } : {}), ...(typeof input.initialSyncLimit === "number" ? { initialSyncLimit: input.initialSyncLimit } @@ -138,7 +136,6 @@ export const matrixPlugin: ChannelPlugin = { "userId", "accessToken", "password", - "register", "deviceName", "initialSyncLimit", ], diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index ea684060a70..4fa99e882f6 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -10,7 +10,6 @@ const matrixActionSchema = z pins: z.boolean().optional(), memberInfo: z.boolean().optional(), channelInfo: z.boolean().optional(), - verification: z.boolean().optional(), }) .optional(); @@ -43,8 +42,6 @@ export const MatrixConfigSchema = z.object({ userId: z.string().optional(), accessToken: z.string().optional(), password: z.string().optional(), - register: z.boolean().optional(), - deviceId: z.string().optional(), deviceName: z.string().optional(), initialSyncLimit: z.number().optional(), encryption: z.boolean().optional(), diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 5614ff92f9d..34d24b6dd39 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -12,18 +12,4 @@ export { export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; -export { - acceptMatrixVerification, - cancelMatrixVerification, - confirmMatrixVerificationReciprocateQr, - confirmMatrixVerificationSas, - generateMatrixVerificationQr, - getMatrixEncryptionStatus, - getMatrixVerificationSas, - listMatrixVerifications, - mismatchMatrixVerificationSas, - requestMatrixVerification, - scanMatrixVerificationQr, - startMatrixVerification, -} from "./actions/verification.js"; export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index c57fa78fde1..f422e09a964 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,13 +1,10 @@ -import type { CoreConfig } from "../types.js"; -import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; import { getActiveMatrixClient } from "../active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "../client.js"; +import { createPreparedMatrixClient } from "../client-bootstrap.js"; +import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js"; +import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; export function ensureNodeRuntime() { if (isBunRuntime()) { @@ -22,7 +19,9 @@ export async function resolveActionClient( if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(); + // Normalize accountId early to ensure consistent keying across all lookups + const accountId = normalizeAccountId(opts.accountId); + const active = getActiveMatrixClient(accountId); if (active) { return { client: active, stopOnDone: false }; } @@ -31,29 +30,18 @@ export async function resolveActionClient( const client = await resolveSharedMatrixClient({ cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, timeoutMs: opts.timeoutMs, + accountId, }); return { client, stopOnDone: false }; } const auth = await resolveMatrixAuth({ cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + accountId, }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - password: auth.password, - deviceId: auth.deviceId, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, + const client = await createPreparedMatrixClient({ + auth, + timeoutMs: opts.timeoutMs, + accountId, }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await client.crypto.prepare(joinedRooms); - } catch { - // Ignore crypto prep failures for one-off actions. - } - } - await client.start(); return { client, stopOnDone: true }; } diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index 3e4ddd39d3c..c32053a0e4f 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -99,7 +99,7 @@ export async function readMatrixMessages( const limit = resolveMatrixActionLimit(opts.limit, 20); const token = opts.before?.trim() || opts.after?.trim() || undefined; const dir = opts.after ? "f" : "b"; - // Room history is queried via the low-level endpoint for compatibility. + // @vector-im/matrix-bot-sdk uses doRequest for room messages const res = (await client.doRequest( "GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts index 5be6642169f..e3d22c3fe02 100644 --- a/extensions/matrix/src/matrix/actions/reactions.ts +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -1,5 +1,6 @@ import { resolveMatrixRoomId } from "../send.js"; import { resolveActionClient } from "./client.js"; +import { resolveMatrixActionLimit } from "./limits.js"; import { EventType, RelationType, @@ -9,6 +10,23 @@ import { type ReactionEventContent, } from "./types.js"; +function getReactionsPath(roomId: string, messageId: string): string { + return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`; +} + +async function listReactionEvents( + client: NonNullable, + roomId: string, + messageId: string, + limit: number, +): Promise { + const res = (await client.doRequest("GET", getReactionsPath(roomId, messageId), { + dir: "b", + limit, + })) as { chunk: MatrixRawEvent[] }; + return res.chunk; +} + export async function listMatrixReactions( roomId: string, messageId: string, @@ -17,18 +35,10 @@ export async function listMatrixReactions( const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const limit = - typeof opts.limit === "number" && Number.isFinite(opts.limit) - ? Math.max(1, Math.floor(opts.limit)) - : 100; - // Relations are queried via the low-level endpoint for compatibility. - const res = (await client.doRequest( - "GET", - `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, - { dir: "b", limit }, - )) as { chunk: MatrixRawEvent[] }; + const limit = resolveMatrixActionLimit(opts.limit, 100); + const chunk = await listReactionEvents(client, resolvedRoom, messageId, limit); const summaries = new Map(); - for (const event of res.chunk) { + for (const event of chunk) { const content = event.content as ReactionEventContent; const key = content["m.relates_to"]?.key; if (!key) { @@ -62,17 +72,13 @@ export async function removeMatrixReactions( const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const res = (await client.doRequest( - "GET", - `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, - { dir: "b", limit: 200 }, - )) as { chunk: MatrixRawEvent[] }; + const chunk = await listReactionEvents(client, resolvedRoom, messageId, 200); const userId = await client.getUserId(); if (!userId) { return { removed: 0 }; } const targetEmoji = opts.emoji?.trim(); - const toRemove = res.chunk + const toRemove = chunk .filter((event) => event.sender === userId) .filter((event) => { if (!targetEmoji) { diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts index 75e67b97383..e1770c7bc8d 100644 --- a/extensions/matrix/src/matrix/actions/room.ts +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -9,8 +9,10 @@ export async function getMatrixMemberInfo( const { client, stopOnDone } = await resolveActionClient(opts); try { const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; + // @vector-im/matrix-bot-sdk uses getUserProfile const profile = await client.getUserProfile(userId); - // Membership and power levels are not included in profile calls; fetch state separately if needed. + // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk + // We'd need to fetch room state separately if needed return { userId, profile: { @@ -33,6 +35,7 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); + // @vector-im/matrix-bot-sdk uses getRoomState for state events let name: string | null = null; let topic: string | null = null; let canonicalAlias: string | null = null; @@ -40,21 +43,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient try { const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", ""); - name = typeof nameState?.name === "string" ? nameState.name : null; + name = nameState?.name ?? null; } catch { // ignore } try { const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, ""); - topic = typeof topicState?.topic === "string" ? topicState.topic : null; + topic = topicState?.topic ?? null; } catch { // ignore } try { const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", ""); - canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null; + canonicalAlias = aliasState?.alias ?? null; } catch { // ignore } diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index 5fd81401183..061829b0de5 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { EventType, type MatrixMessageSummary, diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 8d49c060784..96694f4c743 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -1,4 +1,4 @@ -import type { MatrixClient, MatrixRawEvent, MessageEventContent } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; export const MsgType = { Text: "m.text", @@ -16,7 +16,7 @@ export const EventType = { Reaction: "m.reaction", } as const; -export type RoomMessageEventContent = MessageEventContent & { +export type RoomMessageEventContent = { msgtype: string; body: string; "m.new_content"?: RoomMessageEventContent; @@ -43,6 +43,17 @@ export type RoomTopicEventContent = { topic?: string; }; +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + redacted_because?: unknown; + }; +}; + export type MatrixActionClientOpts = { client?: MatrixClient; timeoutMs?: number; diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts deleted file mode 100644 index 1045f217ca3..00000000000 --- a/extensions/matrix/src/matrix/actions/verification.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { MatrixActionClientOpts } from "./types.js"; -import { resolveActionClient } from "./client.js"; - -function requireCrypto( - client: import("../sdk.js").MatrixClient, -): NonNullable { - if (!client.crypto) { - throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)"); - } - return client.crypto; -} - -function resolveVerificationId(input: string): string { - const normalized = input.trim(); - if (!normalized) { - throw new Error("Matrix verification request id is required"); - } - return normalized; -} - -export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.listVerifications(); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function requestMatrixVerification( - params: MatrixActionClientOpts & { - ownUser?: boolean; - userId?: string; - deviceId?: string; - roomId?: string; - } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(params); - try { - const crypto = requireCrypto(client); - const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); - return await crypto.requestVerification({ - ownUser, - userId: params.userId?.trim() || undefined, - deviceId: params.deviceId?.trim() || undefined, - roomId: params.roomId?.trim() || undefined, - }); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function acceptMatrixVerification( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.acceptVerification(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function cancelMatrixVerification( - requestId: string, - opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.cancelVerification(resolveVerificationId(requestId), { - reason: opts.reason?.trim() || undefined, - code: opts.code?.trim() || undefined, - }); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function startMatrixVerification( - requestId: string, - opts: MatrixActionClientOpts & { method?: "sas" } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function generateMatrixVerificationQr( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.generateVerificationQr(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function scanMatrixVerificationQr( - requestId: string, - qrDataBase64: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - const payload = qrDataBase64.trim(); - if (!payload) { - throw new Error("Matrix QR data is required"); - } - return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function getMatrixVerificationSas( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.getVerificationSas(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function confirmMatrixVerificationSas( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function mismatchMatrixVerificationSas( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function confirmMatrixVerificationReciprocateQr( - requestId: string, - opts: MatrixActionClientOpts = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function getMatrixEncryptionStatus( - opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - const recoveryKey = await crypto.getRecoveryKey(); - return { - encryptionEnabled: true, - recoveryKeyStored: Boolean(recoveryKey), - recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, - ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), - pendingVerifications: (await crypto.listVerifications()).length, - }; - } finally { - if (stopOnDone) { - client.stop(); - } - } -} diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts index dbb04ea347b..a38a419e670 100644 --- a/extensions/matrix/src/matrix/active-client.ts +++ b/extensions/matrix/src/matrix/active-client.ts @@ -1,11 +1,32 @@ -import type { MatrixClient } from "./sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -let activeClient: MatrixClient | null = null; +// Support multiple active clients for multi-account +const activeClients = new Map(); -export function setActiveMatrixClient(client: MatrixClient | null): void { - activeClient = client; +export function setActiveMatrixClient( + client: MatrixClient | null, + accountId?: string | null, +): void { + const key = normalizeAccountId(accountId); + if (client) { + activeClients.set(key, client); + } else { + activeClients.delete(key); + } } -export function getActiveMatrixClient(): MatrixClient | null { - return activeClient; +export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null { + const key = normalizeAccountId(accountId); + return activeClients.get(key) ?? null; +} + +export function getAnyActiveMatrixClient(): MatrixClient | null { + // Return any available client (for backward compatibility) + const first = activeClients.values().next(); + return first.done ? null : first.value; +} + +export function clearAllActiveMatrixClients(): void { + activeClients.clear(); } diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 82fc5ce6ac8..69de112dbd5 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -1,26 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { CoreConfig } from "../types.js"; -import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js"; -import * as credentialsModule from "./credentials.js"; -import * as sdkModule from "./sdk.js"; - -const saveMatrixCredentialsMock = vi.fn(); -const prepareMatrixRegisterModeMock = vi.fn(async () => null); -const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false); - -vi.mock("./credentials.js", () => ({ - loadMatrixCredentials: vi.fn(() => null), - saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args), - credentialsMatchConfig: vi.fn(() => false), - touchMatrixCredentials: vi.fn(), -})); - -vi.mock("./client/register-mode.js", () => ({ - prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args), - finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) => - finalizeMatrixRegisterConfigAfterSuccessMock(...args), - resetPreparedMatrixRegisterModesForTests: vi.fn(), -})); +import { resolveMatrixConfig } from "./client.js"; describe("resolveMatrixConfig", () => { it("prefers config over env", () => { @@ -49,8 +29,6 @@ describe("resolveMatrixConfig", () => { userId: "@cfg:example.org", accessToken: "cfg-token", password: "cfg-pass", - register: false, - deviceId: undefined, deviceName: "CfgDevice", initialSyncLimit: 5, encryption: false, @@ -64,7 +42,6 @@ describe("resolveMatrixConfig", () => { MATRIX_USER_ID: "@env:example.org", MATRIX_ACCESS_TOKEN: "env-token", MATRIX_PASSWORD: "env-pass", - MATRIX_DEVICE_ID: "ENVDEVICE", MATRIX_DEVICE_NAME: "EnvDevice", } as NodeJS.ProcessEnv; const resolved = resolveMatrixConfig(cfg, env); @@ -72,328 +49,8 @@ describe("resolveMatrixConfig", () => { expect(resolved.userId).toBe("@env:example.org"); expect(resolved.accessToken).toBe("env-token"); expect(resolved.password).toBe("env-pass"); - expect(resolved.register).toBe(false); - expect(resolved.deviceId).toBe("ENVDEVICE"); expect(resolved.deviceName).toBe("EnvDevice"); expect(resolved.initialSyncLimit).toBeUndefined(); expect(resolved.encryption).toBe(false); }); - - it("reads register flag from config and env", () => { - const cfg = { - channels: { - matrix: { - register: true, - }, - }, - } as CoreConfig; - const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv); - expect(resolvedFromCfg.register).toBe(true); - - const resolvedFromEnv = resolveMatrixConfig( - {} as CoreConfig, - { - MATRIX_REGISTER: "1", - } as NodeJS.ProcessEnv, - ); - expect(resolvedFromEnv.register).toBe(true); - }); -}); - -describe("resolveMatrixAuth", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - saveMatrixCredentialsMock.mockReset(); - prepareMatrixRegisterModeMock.mockReset(); - finalizeMatrixRegisterConfigAfterSuccessMock.mockReset(); - }); - - it("uses the hardened client request path for password login and persists deviceId", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ - access_token: "tok-123", - user_id: "@bot:example.org", - device_id: "DEVICE123", - }); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(doRequestSpy).toHaveBeenCalledWith( - "POST", - "/_matrix/client/v3/login", - undefined, - expect.objectContaining({ - type: "m.login.password", - }), - ); - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( - expect.objectContaining({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - }), - ); - }); - - it("can register account when password login fails and register mode is enabled", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest"); - doRequestSpy - .mockRejectedValueOnce(new Error("Invalid username or password")) - .mockResolvedValueOnce({ - access_token: "tok-registered", - user_id: "@newbot:example.org", - device_id: "REGDEVICE123", - }); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@newbot:example.org", - password: "secret", - register: true, - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(doRequestSpy).toHaveBeenNthCalledWith( - 1, - "POST", - "/_matrix/client/v3/login", - undefined, - expect.objectContaining({ - type: "m.login.password", - device_id: undefined, - }), - ); - expect(doRequestSpy).toHaveBeenNthCalledWith( - 2, - "POST", - "/_matrix/client/v3/register", - undefined, - expect.objectContaining({ - username: "newbot", - auth: { type: "m.login.dummy" }, - }), - ); - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@newbot:example.org", - accessToken: "tok-registered", - deviceId: "REGDEVICE123", - encryption: true, - }); - expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({ - cfg, - homeserver: "https://matrix.example.org", - userId: "@newbot:example.org", - env: {} as NodeJS.ProcessEnv, - }); - expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({ - homeserver: "https://matrix.example.org", - userId: "@newbot:example.org", - deviceId: "REGDEVICE123", - }); - }); - - it("ignores cached credentials when matrix.register=true", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "cached-token", - deviceId: "CACHEDDEVICE", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ - access_token: "tok-123", - user_id: "@bot:example.org", - device_id: "DEVICE123", - }); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - register: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(doRequestSpy).toHaveBeenCalledWith( - "POST", - "/_matrix/client/v3/login", - undefined, - expect.objectContaining({ - type: "m.login.password", - }), - ); - expect(auth.accessToken).toBe("tok-123"); - expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1); - }); - - it("requires matrix.password when matrix.register=true", async () => { - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - register: true, - }, - }, - } as CoreConfig; - - await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( - "Matrix password is required when matrix.register=true", - ); - expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); - expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); - }); - - it("requires matrix.userId when matrix.register=true", async () => { - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - password: "secret", - register: true, - }, - }, - } as CoreConfig; - - await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( - "Matrix userId is required when matrix.register=true", - ); - expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); - expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); - }); - - it("falls back to config deviceId when cached credentials are missing it", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(auth.deviceId).toBe("DEVICE123"); - expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( - expect.objectContaining({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - }), - ); - }); - - it("resolves missing whoami identity fields for token auth", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ - user_id: "@bot:example.org", - device_id: "DEVICE123", - }); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - accessToken: "tok-123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - }); - - it("uses config deviceId with cached credentials when token is loaded from cache", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const cfg = { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - deviceId: "DEVICE123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - }); }); diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 09f05534c1e..e29923d4cc9 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,113 +1,61 @@ -import type { CoreConfig } from "../types.js"; -import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; -import { MatrixClient } from "../sdk.js"; +import type { CoreConfig } from "../../types.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; -import { - finalizeMatrixRegisterConfigAfterSuccess, - prepareMatrixRegisterMode, -} from "./register-mode.js"; +import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; function clean(value?: string): string { return value?.trim() ?? ""; } -function parseOptionalBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; +/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */ +function deepMergeConfig>(base: T, override: Partial): T { + const merged = { ...base, ...override } as Record; + // Merge known nested objects (dm, actions) so partial overrides keep base fields + for (const key of ["dm", "actions"] as const) { + const b = base[key]; + const o = override[key]; + if (typeof b === "object" && b !== null && typeof o === "object" && o !== null) { + merged[key] = { ...(b as Record), ...(o as Record) }; + } } - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (!normalized) { - return undefined; - } - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - return undefined; + return merged as T; } -function resolveMatrixLocalpart(userId: string): string { - const trimmed = userId.trim(); - const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; - const localpart = noPrefix.split(":")[0]?.trim() || ""; - if (!localpart) { - throw new Error(`Invalid Matrix userId for registration: ${userId}`); - } - return localpart; -} - -async function registerMatrixPasswordAccount(params: { - homeserver: string; - userId: string; - password: string; - deviceId?: string; - deviceName?: string; -}): Promise<{ - access_token?: string; - user_id?: string; - device_id?: string; -}> { - const registerClient = new MatrixClient(params.homeserver, ""); - const payload = { - username: resolveMatrixLocalpart(params.userId), - password: params.password, - inhibit_login: false, - device_id: params.deviceId, - initial_device_display_name: params.deviceName ?? "OpenClaw Gateway", - }; - - let firstError: unknown = null; - try { - return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, { - ...payload, - auth: { type: "m.login.dummy" }, - })) as { - access_token?: string; - user_id?: string; - device_id?: string; - }; - } catch (err) { - firstError = err; - } - - try { - return (await registerClient.doRequest( - "POST", - "/_matrix/client/v3/register", - undefined, - payload, - )) as { - access_token?: string; - user_id?: string; - device_id?: string; - }; - } catch (err) { - const firstMessage = firstError instanceof Error ? firstError.message : String(firstError); - const secondMessage = err instanceof Error ? err.message : String(err); - throw new Error( - `Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`, - ); - } -} - -export function resolveMatrixConfig( +/** + * Resolve Matrix config for a specific account, with fallback to top-level config. + * This supports both multi-account (channels.matrix.accounts.*) and + * single-account (channels.matrix.*) configurations. + */ +export function resolveMatrixConfigForAccount( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + accountId?: string | null, env: NodeJS.ProcessEnv = process.env, ): MatrixResolvedConfig { - const matrix = cfg.channels?.matrix ?? {}; + const normalizedAccountId = normalizeAccountId(accountId); + const matrixBase = cfg.channels?.matrix ?? {}; + const accounts = cfg.channels?.matrix?.accounts; + + // Try to get account-specific config first (direct lookup, then case-insensitive fallback) + let accountConfig = accounts?.[normalizedAccountId]; + if (!accountConfig && accounts) { + for (const key of Object.keys(accounts)) { + if (normalizeAccountId(key) === normalizedAccountId) { + accountConfig = accounts[key]; + break; + } + } + } + + // Deep merge: account-specific values override top-level values, preserving + // nested object inheritance (dm, actions, groups) so partial overrides work. + const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase; + const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; - const register = - parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false; - const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined; const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; const initialSyncLimit = typeof matrix.initialSyncLimit === "number" @@ -119,22 +67,30 @@ export function resolveMatrixConfig( userId, accessToken, password, - register, - deviceId, deviceName, initialSyncLimit, encryption, }; } +/** + * Single-account function for backward compatibility - resolves default account config. + */ +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): MatrixResolvedConfig { + return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env); +} + export async function resolveMatrixAuth(params?: { cfg?: CoreConfig; env?: NodeJS.ProcessEnv; + accountId?: string | null; }): Promise { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; - const resolved = resolveMatrixConfig(cfg, env); - const registerFromConfig = cfg.channels?.matrix?.register === true; + const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env); if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } @@ -146,7 +102,8 @@ export async function resolveMatrixAuth(params?: { touchMatrixCredentials, } = await import("../credentials.js"); - const cached = loadMatrixCredentials(env); + const accountId = params?.accountId; + const cached = loadMatrixCredentials(env, accountId); const cachedCredentials = cached && credentialsMatchConfig(cached, { @@ -156,84 +113,44 @@ export async function resolveMatrixAuth(params?: { ? cached : null; - if (registerFromConfig) { - if (!resolved.userId) { - throw new Error("Matrix userId is required when matrix.register=true"); - } - if (!resolved.password) { - throw new Error("Matrix password is required when matrix.register=true"); - } - await prepareMatrixRegisterMode({ - cfg, - homeserver: resolved.homeserver, - userId: resolved.userId, - env, - }); - } - // If we have an access token, we can fetch userId via whoami if not provided - if (resolved.accessToken && !registerFromConfig) { + if (resolved.accessToken) { let userId = resolved.userId; - const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken; - let knownDeviceId = hasMatchingCachedToken - ? cachedCredentials?.deviceId || resolved.deviceId - : resolved.deviceId; - - if (!userId || !knownDeviceId) { - // Fetch whoami when we need to resolve userId and/or deviceId from token auth. + if (!userId) { + // Fetch userId from access token via whoami ensureMatrixSdkLoggingConfigured(); const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); - const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { - user_id?: string; - device_id?: string; - }; - if (!userId) { - const fetchedUserId = whoami.user_id?.trim(); - if (!fetchedUserId) { - throw new Error("Matrix whoami did not return user_id"); - } - userId = fetchedUserId; - } - if (!knownDeviceId) { - knownDeviceId = whoami.device_id?.trim() || resolved.deviceId; - } - } - - const shouldRefreshCachedCredentials = - !cachedCredentials || - !hasMatchingCachedToken || - cachedCredentials.userId !== userId || - (cachedCredentials.deviceId || undefined) !== knownDeviceId; - if (shouldRefreshCachedCredentials) { - saveMatrixCredentials({ - homeserver: resolved.homeserver, - userId, - accessToken: resolved.accessToken, - deviceId: knownDeviceId, - }); - } else if (hasMatchingCachedToken) { - touchMatrixCredentials(env); + const whoami = await tempClient.getUserId(); + userId = whoami; + // Save the credentials with the fetched userId + saveMatrixCredentials( + { + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }, + env, + accountId, + ); + } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + touchMatrixCredentials(env, accountId); } return { homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, - password: resolved.password, - deviceId: knownDeviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, }; } - if (cachedCredentials && !registerFromConfig) { - touchMatrixCredentials(env); + if (cachedCredentials) { + touchMatrixCredentials(env, accountId); return { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, accessToken: cachedCredentials.accessToken, - password: resolved.password, - deviceId: cachedCredentials.deviceId || resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, @@ -250,78 +167,53 @@ export async function resolveMatrixAuth(params?: { ); } - // Login with password using the same hardened request path as other Matrix HTTP calls. - ensureMatrixSdkLoggingConfigured(); - const loginClient = new MatrixClient(resolved.homeserver, ""); - let login: { + // Login with password using HTTP API + const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", + }), + }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + throw new Error(`Matrix login failed: ${errorText}`); + } + + const login = (await loginResponse.json()) as { access_token?: string; user_id?: string; device_id?: string; }; - try { - login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { - type: "m.login.password", - identifier: { type: "m.id.user", user: resolved.userId }, - password: resolved.password, - device_id: resolved.deviceId, - initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", - })) as { - access_token?: string; - user_id?: string; - device_id?: string; - }; - } catch (loginErr) { - if (!resolved.register) { - throw loginErr; - } - try { - login = await registerMatrixPasswordAccount({ - homeserver: resolved.homeserver, - userId: resolved.userId, - password: resolved.password, - deviceId: resolved.deviceId, - deviceName: resolved.deviceName, - }); - } catch (registerErr) { - const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr); - const registerMessage = - registerErr instanceof Error ? registerErr.message : String(registerErr); - throw new Error( - `Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`, - ); - } - } const accessToken = login.access_token?.trim(); if (!accessToken) { - throw new Error("Matrix login/registration did not return an access token"); + throw new Error("Matrix login did not return an access token"); } const auth: MatrixAuth = { homeserver: resolved.homeserver, userId: login.user_id ?? resolved.userId, accessToken, - password: resolved.password, - deviceId: login.device_id ?? resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, }; - saveMatrixCredentials({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - deviceId: auth.deviceId, - }); - - if (registerFromConfig) { - await finalizeMatrixRegisterConfigAfterSuccess({ + saveMatrixCredentials( + { homeserver: auth.homeserver, userId: auth.userId, - deviceId: auth.deviceId, - }); - } + accessToken: auth.accessToken, + deviceId: login.device_id, + }, + env, + accountId, + ); return auth; } diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 7626a6c8477..dd9c99214bb 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -1,5 +1,11 @@ import fs from "node:fs"; -import { MatrixClient } from "../sdk.js"; +import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; +import { + LogService, + MatrixClient, + SimpleFsStorageProvider, + RustSdkCryptoStorageProvider, +} from "@vector-im/matrix-bot-sdk"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { maybeMigrateLegacyStorage, @@ -7,50 +13,111 @@ import { writeStorageMeta, } from "./storage.js"; +function sanitizeUserIdList(input: unknown, label: string): string[] { + if (input == null) { + return []; + } + if (!Array.isArray(input)) { + LogService.warn( + "MatrixClientLite", + `Expected ${label} list to be an array, got ${typeof input}`, + ); + return []; + } + const filtered = input.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); + if (filtered.length !== input.length) { + LogService.warn( + "MatrixClientLite", + `Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`, + ); + } + return filtered; +} + export async function createMatrixClient(params: { homeserver: string; - userId?: string; + userId: string; accessToken: string; - password?: string; - deviceId?: string; encryption?: boolean; localTimeoutMs?: number; - initialSyncLimit?: number; accountId?: string | null; }): Promise { ensureMatrixSdkLoggingConfigured(); const env = process.env; - const userId = params.userId?.trim() || "unknown"; - const matrixClientUserId = params.userId?.trim() || undefined; + // Create storage provider const storagePaths = resolveMatrixStoragePaths({ homeserver: params.homeserver, - userId, + userId: params.userId, accessToken: params.accessToken, accountId: params.accountId, env, }); maybeMigrateLegacyStorage({ storagePaths, env }); fs.mkdirSync(storagePaths.rootDir, { recursive: true }); + const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath); + + // Create crypto storage if encryption is enabled + let cryptoStorage: ICryptoStorageProvider | undefined; + if (params.encryption) { + fs.mkdirSync(storagePaths.cryptoPath, { recursive: true }); + + try { + const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); + cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite); + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Failed to initialize crypto storage, E2EE disabled:", + err, + ); + } + } writeStorageMeta({ storagePaths, homeserver: params.homeserver, - userId, + userId: params.userId, accountId: params.accountId, }); - const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`; + const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage); - return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, { - userId: matrixClientUserId, - password: params.password, - deviceId: params.deviceId, - encryption: params.encryption, - localTimeoutMs: params.localTimeoutMs, - initialSyncLimit: params.initialSyncLimit, - recoveryKeyPath: storagePaths.recoveryKeyPath, - idbSnapshotPath: storagePaths.idbSnapshotPath, - cryptoDatabasePrefix, - }); + if (client.crypto) { + const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto); + client.crypto.updateSyncData = async ( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + changedDeviceLists, + leftDeviceLists, + ) => { + const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list"); + const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list"); + try { + return await originalUpdateSyncData( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + safeChanged, + safeLeft, + ); + } catch (err) { + const message = typeof err === "string" ? err : err instanceof Error ? err.message : ""; + if (message.includes("Expect value to be String")) { + LogService.warn( + "MatrixClientLite", + "Ignoring malformed device list entries during crypto sync", + message, + ); + return; + } + throw err; + } + }; + } + + return client; } diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index b4914678424..c5ef702b019 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,4 +1,4 @@ -import { ConsoleLogger, LogService } from "../sdk/logger.js"; +import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk"; let matrixSdkLoggingConfigured = false; const matrixSdkBaseLogger = new ConsoleLogger(); diff --git a/extensions/matrix/src/matrix/client/register-mode.test.ts b/extensions/matrix/src/matrix/client/register-mode.test.ts deleted file mode 100644 index 9b4354538ef..00000000000 --- a/extensions/matrix/src/matrix/client/register-mode.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CoreConfig } from "../../types.js"; -import * as runtimeModule from "../../runtime.js"; -import { - finalizeMatrixRegisterConfigAfterSuccess, - prepareMatrixRegisterMode, - resetPreparedMatrixRegisterModesForTests, -} from "./register-mode.js"; - -describe("matrix register mode helpers", () => { - const tempDirs: string[] = []; - - afterEach(() => { - resetPreparedMatrixRegisterModesForTests(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - vi.restoreAllMocks(); - }); - - it("moves existing matrix state into a .bak snapshot before fresh registration", async () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-")); - tempDirs.push(stateDir); - const credentialsDir = path.join(stateDir, "credentials", "matrix"); - const accountsDir = path.join(credentialsDir, "accounts"); - fs.mkdirSync(accountsDir, { recursive: true }); - fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n'); - fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n"); - - const cfg = { - channels: { - matrix: { - userId: "@pinguini:matrix.gumadeiras.com", - register: true, - encryption: true, - }, - }, - } as CoreConfig; - - const backupDir = await prepareMatrixRegisterMode({ - cfg, - homeserver: "https://matrix.gumadeiras.com", - userId: "@pinguini:matrix.gumadeiras.com", - env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, - }); - - expect(backupDir).toBeTruthy(); - expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false); - expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false); - expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true); - expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true); - expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true); - }); - - it("updates matrix config after successful register mode auth", async () => { - const writeConfigFile = vi.fn(async () => {}); - vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({ - config: { - loadConfig: () => - ({ - channels: { - matrix: { - register: true, - accessToken: "stale-token", - userId: "@pinguini:matrix.gumadeiras.com", - }, - }, - }) as CoreConfig, - writeConfigFile, - }, - } as never); - - const updated = await finalizeMatrixRegisterConfigAfterSuccess({ - homeserver: "https://matrix.gumadeiras.com", - userId: "@pinguini:matrix.gumadeiras.com", - deviceId: "DEVICE123", - }); - expect(updated).toBe(true); - expect(writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - channels: expect.objectContaining({ - matrix: expect.objectContaining({ - register: false, - homeserver: "https://matrix.gumadeiras.com", - userId: "@pinguini:matrix.gumadeiras.com", - deviceId: "DEVICE123", - }), - }), - }), - ); - const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig; - expect(written.channels?.matrix?.accessToken).toBeUndefined(); - }); -}); diff --git a/extensions/matrix/src/matrix/client/register-mode.ts b/extensions/matrix/src/matrix/client/register-mode.ts deleted file mode 100644 index 45bef00a14e..00000000000 --- a/extensions/matrix/src/matrix/client/register-mode.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { CoreConfig } from "../../types.js"; -import { getMatrixRuntime } from "../../runtime.js"; -import { resolveMatrixCredentialsDir } from "../credentials.js"; - -const preparedRegisterKeys = new Set(); - -function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string { - try { - return getMatrixRuntime().state.resolveStateDir(env, os.homedir); - } catch { - // fall through to deterministic fallback for tests/early init - } - const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); - if (override) { - if (override.startsWith("~")) { - const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir()); - return path.resolve(expanded); - } - return path.resolve(override); - } - return path.join(os.homedir(), ".openclaw"); -} - -function buildRegisterKey(params: { homeserver: string; userId: string }): string { - return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`; -} - -function buildBackupDirName(now = new Date()): string { - const ts = now.toISOString().replace(/[:.]/g, "-"); - const suffix = Math.random().toString(16).slice(2, 8); - return `${ts}-${suffix}`; -} - -export async function prepareMatrixRegisterMode(params: { - cfg: CoreConfig; - homeserver: string; - userId: string; - env?: NodeJS.ProcessEnv; -}): Promise { - const env = params.env ?? process.env; - const registerKey = buildRegisterKey({ - homeserver: params.homeserver, - userId: params.userId, - }); - if (preparedRegisterKeys.has(registerKey)) { - return null; - } - - const stateDir = resolveStateDirFromEnv(env); - const credentialsDir = resolveMatrixCredentialsDir(env, stateDir); - if (!fs.existsSync(credentialsDir)) { - return null; - } - - const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak"); - if (entries.length === 0) { - return null; - } - - const backupRoot = path.join(credentialsDir, ".bak"); - fs.mkdirSync(backupRoot, { recursive: true }); - const backupDir = path.join(backupRoot, buildBackupDirName()); - fs.mkdirSync(backupDir, { recursive: true }); - - const matrixConfig = params.cfg.channels?.matrix ?? {}; - fs.writeFileSync( - path.join(backupDir, "matrix-config.json"), - JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"), - "utf-8", - ); - - for (const entry of entries) { - fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry)); - } - - preparedRegisterKeys.add(registerKey); - return backupDir; -} - -export async function finalizeMatrixRegisterConfigAfterSuccess(params: { - homeserver: string; - userId: string; - deviceId?: string; -}): Promise { - let runtime: ReturnType | null = null; - try { - runtime = getMatrixRuntime(); - } catch { - return false; - } - - const cfg = runtime.config.loadConfig() as CoreConfig; - if (cfg.channels?.matrix?.register !== true) { - return false; - } - - const matrixCfg = cfg.channels?.matrix ?? {}; - const nextMatrix: Record = { - ...matrixCfg, - register: false, - homeserver: params.homeserver, - userId: params.userId, - ...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}), - }; - // Registration mode should continue relying on password + cached credentials, not stale inline token. - delete nextMatrix.accessToken; - - const next: CoreConfig = { - ...cfg, - channels: { - ...(cfg.channels ?? {}), - matrix: nextMatrix as CoreConfig["channels"]["matrix"], - }, - }; - - await runtime.config.writeConfigFile(next as never); - return true; -} - -export function resetPreparedMatrixRegisterModesForTests(): void { - preparedRegisterKeys.clear(); -} diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 11b72e6ad0c..c04c61829ab 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,10 +1,11 @@ -import type { MatrixClient } from "../sdk.js"; -import type { CoreConfig } from "../types.js"; -import type { MatrixAuth } from "./types.js"; -import { LogService } from "../sdk/logger.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { LogService } from "@vector-im/matrix-bot-sdk"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { CoreConfig } from "../../types.js"; import { resolveMatrixAuth } from "./config.js"; import { createMatrixClient } from "./create-client.js"; import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; +import type { MatrixAuth } from "./types.js"; type SharedMatrixClientState = { client: MatrixClient; @@ -13,17 +14,19 @@ type SharedMatrixClientState = { cryptoReady: boolean; }; -let sharedClientState: SharedMatrixClientState | null = null; -let sharedClientPromise: Promise | null = null; -let sharedClientStartPromise: Promise | null = null; +// Support multiple accounts with separate clients +const sharedClientStates = new Map(); +const sharedClientPromises = new Map>(); +const sharedClientStartPromises = new Map>(); function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { + const normalizedAccountId = normalizeAccountId(accountId); return [ auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain", - accountId ?? DEFAULT_ACCOUNT_KEY, + normalizedAccountId || DEFAULT_ACCOUNT_KEY, ].join("|"); } @@ -36,11 +39,8 @@ async function createSharedMatrixClient(params: { homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, - password: params.auth.password, - deviceId: params.auth.deviceId, encryption: params.auth.encryption, localTimeoutMs: params.timeoutMs, - initialSyncLimit: params.auth.initialSyncLimit, accountId: params.accountId, }); return { @@ -60,11 +60,13 @@ async function ensureSharedClientStarted(params: { if (params.state.started) { return; } - if (sharedClientStartPromise) { - await sharedClientStartPromise; + const key = params.state.key; + const existingStartPromise = sharedClientStartPromises.get(key); + if (existingStartPromise) { + await existingStartPromise; return; } - sharedClientStartPromise = (async () => { + const startPromise = (async () => { const client = params.state.client; // Initialize crypto if enabled @@ -72,7 +74,9 @@ async function ensureSharedClientStarted(params: { try { const joinedRooms = await client.getJoinedRooms(); if (client.crypto) { - await client.crypto.prepare(joinedRooms); + await (client.crypto as { prepare: (rooms?: string[]) => Promise }).prepare( + joinedRooms, + ); params.state.cryptoReady = true; } } catch (err) { @@ -83,10 +87,11 @@ async function ensureSharedClientStarted(params: { await client.start(); params.state.started = true; })(); + sharedClientStartPromises.set(key, startPromise); try { - await sharedClientStartPromise; + await startPromise; } finally { - sharedClientStartPromise = null; + sharedClientStartPromises.delete(key); } } @@ -100,48 +105,51 @@ export async function resolveSharedMatrixClient( accountId?: string | null; } = {}, ): Promise { - const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env })); - const key = buildSharedClientKey(auth, params.accountId); + const accountId = normalizeAccountId(params.accountId); + const auth = + params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId })); + const key = buildSharedClientKey(auth, accountId); const shouldStart = params.startClient !== false; - if (sharedClientState?.key === key) { + // Check if we already have a client for this key + const existingState = sharedClientStates.get(key); + if (existingState) { if (shouldStart) { await ensureSharedClientStarted({ - state: sharedClientState, + state: existingState, timeoutMs: params.timeoutMs, initialSyncLimit: auth.initialSyncLimit, encryption: auth.encryption, }); } - return sharedClientState.client; + return existingState.client; } - if (sharedClientPromise) { - const pending = await sharedClientPromise; - if (pending.key === key) { - if (shouldStart) { - await ensureSharedClientStarted({ - state: pending, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return pending.client; + // Check if there's a pending creation for this key + const existingPromise = sharedClientPromises.get(key); + if (existingPromise) { + const pending = await existingPromise; + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); } - pending.client.stop(); - sharedClientState = null; - sharedClientPromise = null; + return pending.client; } - sharedClientPromise = createSharedMatrixClient({ + // Create a new client for this account + const createPromise = createSharedMatrixClient({ auth, timeoutMs: params.timeoutMs, - accountId: params.accountId, + accountId, }); + sharedClientPromises.set(key, createPromise); try { - const created = await sharedClientPromise; - sharedClientState = created; + const created = await createPromise; + sharedClientStates.set(key, created); if (shouldStart) { await ensureSharedClientStarted({ state: created, @@ -152,7 +160,7 @@ export async function resolveSharedMatrixClient( } return created.client; } finally { - sharedClientPromise = null; + sharedClientPromises.delete(key); } } @@ -161,13 +169,33 @@ export async function waitForMatrixSync(_params: { timeoutMs?: number; abortSignal?: AbortSignal; }): Promise { - // matrix-js-sdk handles sync lifecycle in start() for this integration. + // @vector-im/matrix-bot-sdk handles sync internally in start() // This is kept for API compatibility but is essentially a no-op now } -export function stopSharedClient(): void { - if (sharedClientState) { - sharedClientState.client.stop(); - sharedClientState = null; +export function stopSharedClient(key?: string): void { + if (key) { + // Stop a specific client + const state = sharedClientStates.get(key); + if (state) { + state.client.stop(); + sharedClientStates.delete(key); + } + } else { + // Stop all clients (backward compatible behavior) + for (const state of sharedClientStates.values()) { + state.client.stop(); + } + sharedClientStates.clear(); } } + +/** + * Stop the shared client for a specific account. + * Use this instead of stopSharedClient() when shutting down a single account + * to avoid stopping all accounts. + */ +export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { + const key = buildSharedClientKey(auth, normalizeAccountId(accountId)); + stopSharedClient(key); +} diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 2575eb2c8f2..32f9768c68c 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -39,8 +39,8 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { } { const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); return { - storagePath: path.join(stateDir, "credentials", "matrix", "bot-storage.json"), - cryptoPath: path.join(stateDir, "credentials", "matrix", "crypto"), + storagePath: path.join(stateDir, "matrix", "bot-storage.json"), + cryptoPath: path.join(stateDir, "matrix", "crypto"), }; } @@ -59,7 +59,6 @@ export function resolveMatrixStoragePaths(params: { const tokenHash = hashAccessToken(params.accessToken); const rootDir = path.join( stateDir, - "credentials", "matrix", "accounts", accountKey, @@ -71,8 +70,6 @@ export function resolveMatrixStoragePaths(params: { storagePath: path.join(rootDir, "bot-storage.json"), cryptoPath: path.join(rootDir, "crypto"), metaPath: path.join(rootDir, STORAGE_META_FILENAME), - recoveryKeyPath: path.join(rootDir, "recovery-key.json"), - idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"), accountKey, tokenHash, }; diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index 438a16e4243..ec1b3002bc7 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -2,9 +2,7 @@ export type MatrixResolvedConfig = { homeserver: string; userId: string; accessToken?: string; - deviceId?: string; password?: string; - register?: boolean; deviceName?: string; initialSyncLimit?: number; encryption?: boolean; @@ -21,8 +19,6 @@ export type MatrixAuth = { homeserver: string; userId: string; accessToken: string; - password?: string; - deviceId?: string; deviceName?: string; initialSyncLimit?: number; encryption?: boolean; @@ -33,8 +29,6 @@ export type MatrixStoragePaths = { storagePath: string; cryptoPath: string; metaPath: string; - recoveryKeyPath: string; - idbSnapshotPath: string; accountKey: string; tokenHash: string; }; diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index c8218fc200c..6941af8af68 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -1,30 +1,19 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk"; -const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"]; - -function resolveMissingMatrixPackages(): string[] { - try { - const req = createRequire(import.meta.url); - return REQUIRED_MATRIX_PACKAGES.filter((pkg) => { - try { - req.resolve(pkg); - return false; - } catch { - return true; - } - }); - } catch { - return [...REQUIRED_MATRIX_PACKAGES]; - } -} +const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; export function isMatrixSdkAvailable(): boolean { - return resolveMissingMatrixPackages().length === 0; + try { + const req = createRequire(import.meta.url); + req.resolve(MATRIX_SDK_PACKAGE); + return true; + } catch { + return false; + } } function resolvePluginRoot(): string { @@ -32,85 +21,6 @@ function resolvePluginRoot(): string { return path.resolve(currentDir, "..", ".."); } -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout(params: { - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; -}): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - export async function ensureMatrixSdkInstalled(params: { runtime: RuntimeEnv; confirm?: (message: string) => Promise; @@ -120,13 +30,9 @@ export async function ensureMatrixSdkInstalled(params: { } const confirm = params.confirm; if (confirm) { - const ok = await confirm( - "Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?", - ); + const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?"); if (!ok) { - throw new Error( - "Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).", - ); + throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first)."); } } @@ -135,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runFixedCommandWithTimeout({ + const result = await runPluginCommandWithTimeout({ argv: command, cwd: root, timeoutMs: 300_000, @@ -147,11 +53,8 @@ export async function ensureMatrixSdkInstalled(params: { ); } if (!isMatrixSdkAvailable()) { - const missing = resolveMissingMatrixPackages(); throw new Error( - missing.length > 0 - ? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}` - : "Matrix dependency install completed but Matrix dependencies are still missing.", + "Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.", ); } } diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts deleted file mode 100644 index 1c42af12572..00000000000 --- a/extensions/matrix/src/matrix/monitor/auto-join.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { CoreConfig } from "../../types.js"; -import { setMatrixRuntime } from "../../runtime.js"; -import { registerMatrixAutoJoin } from "./auto-join.js"; - -type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise; - -function createClientStub() { - let inviteHandler: InviteHandler | null = null; - const client = { - on: vi.fn((eventName: string, listener: unknown) => { - if (eventName === "room.invite") { - inviteHandler = listener as InviteHandler; - } - return client; - }), - joinRoom: vi.fn(async () => {}), - getRoomStateEvent: vi.fn(async () => ({})), - } as unknown as import("../sdk.js").MatrixClient; - - return { - client, - getInviteHandler: () => inviteHandler, - joinRoom: (client as unknown as { joinRoom: ReturnType }).joinRoom, - getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType }) - .getRoomStateEvent, - }; -} - -describe("registerMatrixAutoJoin", () => { - beforeEach(() => { - setMatrixRuntime({ - logging: { - shouldLogVerbose: () => false, - }, - } as unknown as PluginRuntime); - }); - - it("joins all invites when autoJoin=always", async () => { - const { client, getInviteHandler, joinRoom } = createClientStub(); - const cfg: CoreConfig = { - channels: { - matrix: { - autoJoin: "always", - }, - }, - }; - - registerMatrixAutoJoin({ - client, - cfg, - runtime: { - log: vi.fn(), - error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk").RuntimeEnv, - }); - - const inviteHandler = getInviteHandler(); - expect(inviteHandler).toBeTruthy(); - await inviteHandler!("!room:example.org", {}); - - expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); - }); - - it("ignores invites outside allowlist when autoJoin=allowlist", async () => { - const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub(); - getRoomStateEvent.mockResolvedValue({ - alias: "#other:example.org", - alt_aliases: ["#else:example.org"], - }); - const cfg: CoreConfig = { - channels: { - matrix: { - autoJoin: "allowlist", - autoJoinAllowlist: ["#allowed:example.org"], - }, - }, - }; - - registerMatrixAutoJoin({ - client, - cfg, - runtime: { - log: vi.fn(), - error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk").RuntimeEnv, - }); - - const inviteHandler = getInviteHandler(); - expect(inviteHandler).toBeTruthy(); - await inviteHandler!("!room:example.org", {}); - - expect(joinRoom).not.toHaveBeenCalled(); - }); - - it("joins invite when alias matches allowlist", async () => { - const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub(); - getRoomStateEvent.mockResolvedValue({ - alias: "#allowed:example.org", - alt_aliases: ["#backup:example.org"], - }); - const cfg: CoreConfig = { - channels: { - matrix: { - autoJoin: "allowlist", - autoJoinAllowlist: [" #allowed:example.org "], - }, - }, - }; - - registerMatrixAutoJoin({ - client, - cfg, - runtime: { - log: vi.fn(), - error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk").RuntimeEnv, - }); - - const inviteHandler = getInviteHandler(); - expect(inviteHandler).toBeTruthy(); - await inviteHandler!("!room:example.org", {}); - - expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); - }); -}); diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 2185d53d161..9f36ae405d8 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,7 +1,8 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk"; import type { RuntimeEnv } from "openclaw/plugin-sdk"; -import type { CoreConfig } from "../../types.js"; -import type { MatrixClient } from "../sdk.js"; import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; export function registerMatrixAutoJoin(params: { client: MatrixClient; @@ -17,52 +18,47 @@ export function registerMatrixAutoJoin(params: { runtime.log?.(message); }; const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; - const autoJoinAllowlist = new Set( - (cfg.channels?.matrix?.autoJoinAllowlist ?? []) - .map((entry) => String(entry).trim()) - .filter(Boolean), - ); + const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; if (autoJoin === "off") { return; } if (autoJoin === "always") { + // Use the built-in autojoin mixin for "always" mode + AutojoinRoomsMixin.setupOnClient(client); logVerbose("matrix: auto-join enabled for all invites"); - } else { - logVerbose("matrix: auto-join enabled for allowlist invites"); + return; } - // Handle invites directly so both "always" and "allowlist" modes share the same path. + // For "allowlist" mode, handle invites manually client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { - if (autoJoin === "allowlist") { - let alias: string | undefined; - let altAliases: string[] = []; - try { - const aliasState = await client - .getRoomStateEvent(roomId, "m.room.canonical_alias", "") - .catch(() => null); - alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined; - altAliases = - aliasState && Array.isArray(aliasState.alt_aliases) - ? aliasState.alt_aliases - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean) - : []; - } catch { - // Ignore errors - } + if (autoJoin !== "allowlist") { + return; + } - const allowed = - autoJoinAllowlist.has("*") || - autoJoinAllowlist.has(roomId) || - (alias ? autoJoinAllowlist.has(alias) : false) || - altAliases.some((value) => autoJoinAllowlist.has(value)); + // Get room alias if available + let alias: string | undefined; + let altAliases: string[] = []; + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + alias = aliasState?.alias; + altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : []; + } catch { + // Ignore errors + } - if (!allowed) { - logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); - return; - } + const allowed = + autoJoinAllowlist.includes("*") || + autoJoinAllowlist.includes(roomId) || + (alias ? autoJoinAllowlist.includes(alias) : false) || + altAliases.some((value) => autoJoinAllowlist.includes(value)); + + if (!allowed) { + logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); + return; } try { diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index de767e1db08..5cd6e88758e 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; type DirectMessageCheck = { roomId: string; 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 00000000000..3754cfd178e --- /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 3608991da16..279517d521d 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,16 +1,43 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk"; import type { MatrixAuth } from "../client.js"; -import type { MatrixClient } from "../sdk.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; logVerboseMessage: (message: string) => void; warnedEncryptedRooms: Set; warnedCryptoMissingRooms: Set; - logger: { warn: (meta: Record, message: string) => void }; + logger: RuntimeLogger; formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; }): void { @@ -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"; @@ -42,10 +88,11 @@ export function registerMatrixMonitorEvents(params: { client.on( "room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => { - logger.warn( - { roomId, eventId: event.event_id, error: error.message }, - "Failed to decrypt message", - ); + logger.warn("Failed to decrypt message", { + roomId, + eventId: event.event_id, + error: error.message, + }); logVerboseMessage( `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, ); @@ -76,7 +123,7 @@ export function registerMatrixMonitorEvents(params: { warnedEncryptedRooms.add(roomId); const warning = "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; - logger.warn({ roomId }, warning); + logger.warn(warning, { roomId }); } if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { warnedCryptoMissingRooms.add(roomId); @@ -86,7 +133,7 @@ export function registerMatrixMonitorEvents(params: { downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", }); const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; - logger.warn({ roomId }, warning); + logger.warn(warning, { roomId }); } return; } diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index f2090247d15..77e88162af3 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,3 +1,4 @@ +import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { createReplyPrefixOptions, createTypingCallbacks, @@ -5,23 +6,19 @@ import { logInboundDrop, logTypingFailure, resolveControlCommandGate, + type PluginRuntime, type RuntimeEnv, + type RuntimeLogger, } from "openclaw/plugin-sdk"; -import type { CoreConfig, ReplyToMode } from "../../types.js"; -import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; -import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; +import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; +import { fetchEventSummary } from "../actions/summary.js"; import { formatPollAsText, isPollStartType, 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, @@ -33,38 +30,19 @@ import { resolveMentions } from "./mentions.js"; import { deliverMatrixReplies } from "./replies.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; import { EventType, RelationType } from "./types.js"; export type MatrixMonitorHandlerParams = { client: MatrixClient; - core: { - logging: { - shouldLogVerbose: () => boolean; - }; - channel: (typeof import("openclaw/plugin-sdk"))["channel"]; - system: { - enqueueSystemEvent: ( - text: string, - meta: { sessionKey?: string | null; contextKey?: string | null }, - ) => void; - }; - }; + core: PluginRuntime; cfg: CoreConfig; runtime: RuntimeEnv; - logger: { - info: (message: string | Record, ...meta: unknown[]) => void; - warn: (meta: Record, message: string) => void; - }; + logger: RuntimeLogger; logVerboseMessage: (message: string) => void; allowFrom: string[]; - roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig } - ? MatrixConfig extends { groups?: infer Groups } - ? Groups - : Record | undefined - : Record | undefined; - mentionRegexes: ReturnType< - (typeof import("openclaw/plugin-sdk"))["channel"]["mentions"]["buildMentionRegexes"] - >; + roomsConfig: Record | undefined; + mentionRegexes: ReturnType; groupPolicy: "open" | "allowlist" | "disabled"; replyToMode: ReplyToMode; threadReplies: "off" | "inbound" | "always"; @@ -85,6 +63,7 @@ export type MatrixMonitorHandlerParams = { roomId: string, ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; getMemberDisplayName: (roomId: string, userId: string) => Promise; + accountId?: string | null; }; export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { @@ -110,18 +89,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam directTracker, getRoomInfo, getMemberDisplayName, + accountId, } = params; return async (roomId: string, event: MatrixRawEvent) => { try { const eventType = event.type; if (eventType === EventType.RoomMessageEncrypted) { - // Encrypted payloads are emitted separately after decryption. + // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled return; } const isPollEvent = isPollStartType(eventType); - const locationContent = event.content as LocationMessageEventContent; + const locationContent = event.content as unknown as LocationMessageEventContent; const isLocationEvent = eventType === EventType.Location || (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); @@ -159,9 +139,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const roomName = roomInfo.name; const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean); - let content = event.content as RoomMessageEventContent; + let content = event.content as unknown as RoomMessageEventContent; if (isPollEvent) { - const pollStartContent = event.content as PollStartContent; + const pollStartContent = event.content as unknown as PollStartContent; const pollSummary = parsePollStartContent(pollStartContent); if (pollSummary) { pollSummary.eventId = event.event_id ?? ""; @@ -233,9 +213,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await core.channel.pairing - .readAllowFromStore("matrix") - .catch(() => []); + const storeAllowFrom = + dmPolicy === "allowlist" + ? [] + : await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); @@ -435,7 +416,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam hasControlCommandInMessage; const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { - logger.info({ roomId, reason: "no-mention" }, "skipping room message"); + logger.info("skipping room message", { roomId, reason: "no-mention" }); return; } @@ -446,19 +427,69 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam threadReplies, messageId, threadRootId, - isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata. + isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available }); - const route = core.channel.routing.resolveAgentRoute({ + const baseRoute = core.channel.routing.resolveAgentRoute({ cfg, channel: "matrix", + accountId, peer: { - kind: isDirectMessage ? "dm" : "channel", + kind: isDirectMessage ? "direct" : "channel", id: isDirectMessage ? senderId : roomId, }, }); + + const route = { + ...baseRoute, + sessionKey: threadRootId + ? `${baseRoute.sessionKey}:thread:${threadRootId}` + : baseRoute.sessionKey, + }; + + let threadStarterBody: string | undefined; + let threadLabel: string | undefined; + let parentSessionKey: string | undefined; + + if (threadRootId) { + const existingSession = core.channel.session.readSessionUpdatedAt({ + storePath: core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: baseRoute.agentId, + }), + sessionKey: route.sessionKey, + }); + + if (existingSession === undefined) { + try { + const rootEvent = await fetchEventSummary(client, roomId, threadRootId); + if (rootEvent?.body) { + const rootSenderName = rootEvent.sender + ? await getMemberDisplayName(roomId, rootEvent.sender) + : undefined; + + threadStarterBody = core.channel.reply.formatAgentEnvelope({ + channel: "Matrix", + from: rootSenderName ?? rootEvent.sender ?? "Unknown", + timestamp: rootEvent.timestamp, + envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg), + body: rootEvent.body, + }); + + threadLabel = `Matrix thread in ${roomName ?? roomId}`; + parentSessionKey = baseRoute.sessionKey; + } + } catch (err) { + logVerboseMessage( + `matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`, + ); + } + } + } + const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const textWithId = threadRootId + ? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]` + : `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); @@ -479,13 +510,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, + BodyForAgent: bodyText, RawBody: bodyText, CommandBody: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, To: `room:${roomId}`, SessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "channel", + ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel", ConversationLabel: envelopeFrom, SenderName: senderName, SenderId: senderId, @@ -508,6 +540,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam CommandSource: "text" as const, OriginatingChannel: "matrix" as const, OriginatingTo: `room:${roomId}`, + ThreadStarterBody: threadStarterBody, + ThreadLabel: threadLabel, + ParentSessionKey: parentSessionKey, }); await core.channel.session.recordInboundSession({ @@ -523,14 +558,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } : undefined, onRecordError: (err) => { - logger.warn( - { - error: String(err), - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - }, - "failed updating session meta", - ); + logger.warn("failed updating session meta", { + error: String(err), + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + }); }, }); @@ -565,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, @@ -611,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], @@ -628,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/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 8e4b1ba1c64..936eabdd346 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,5 @@ -import { format } from "node:util"; import { + createLoggerBackedRuntime, GROUP_POLICY_BLOCKED_LABEL, mergeAllowlist, resolveAllowlistProviderRuntimeGroupPolicy, @@ -48,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; @@ -326,7 +319,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); logVerboseMessage("matrix: client started"); - // Shared client is already started via resolveSharedMatrixClient. + // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient logger.info(`matrix: logged in as ${auth.userId}`); // If E2EE is enabled, trigger device verification diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts index 7380ec215b4..41c91aecc16 100644 --- a/extensions/matrix/src/matrix/monitor/location.ts +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -1,9 +1,9 @@ +import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk"; import { formatLocationText, toLocationContext, type NormalizedLocation, } from "openclaw/plugin-sdk"; -import type { LocationMessageEventContent } from "../sdk.js"; import { EventType } from "./types.js"; export type MatrixLocationPayload = { diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index dcf7af8ad69..11b045609a9 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -22,14 +22,12 @@ describe("downloadMatrixMedia", () => { setMatrixRuntime(runtimeStub); }); - it("decrypts encrypted media when file payloads are present", async () => { + function makeEncryptedMediaFixture() { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - const client = { crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("../sdk.js").MatrixClient; - + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; const file = { url: "mxc://example/file", key: { @@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => { hashes: { sha256: "hash" }, v: "v2", }; + return { decryptMedia, client, file }; + } + + it("decrypts encrypted media when file payloads are present", async () => { + const { decryptMedia, client, file } = makeEncryptedMediaFixture(); const result = await downloadMatrixMedia({ client, @@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => { }); it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { - const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - - const client = { - crypto: { decryptMedia }, - mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("../sdk.js").MatrixClient; - - const file = { - url: "mxc://example/file", - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }; + const { decryptMedia, client, file } = makeEncryptedMediaFixture(); await expect( downloadMatrixMedia({ diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index 83057aa6c56..baf366186c4 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.js"; // Type for encrypted file info @@ -21,7 +21,7 @@ async function fetchMatrixMediaBuffer(params: { mxcUrl: string; maxBytes: number; }): Promise<{ buffer: Buffer; headerType?: string } | null> { - // The client wrapper exposes mxcToHttp for Matrix media URIs. + // @vector-im/matrix-bot-sdk provides mxcToHttp helper const url = params.client.mxcToHttp(params.mxcUrl); if (!url) { return null; @@ -44,7 +44,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. - * Uses the Matrix crypto adapter's decryptMedia helper. + * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b5..dfbfbabb8af 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 061bca34cdb..c86c7dde688 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,5 +1,5 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk"; -import type { MatrixClient } from "../sdk.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; @@ -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/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 4a7624d7c10..764147d3539 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; export type MatrixRoomInfo = { name?: string; diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts index 1a2f260f243..a384957166b 100644 --- a/extensions/matrix/src/matrix/monitor/threads.ts +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -1,4 +1,4 @@ -// Type for raw Matrix event payload consumed by thread helpers. +// Type for raw Matrix event from @vector-im/matrix-bot-sdk type MatrixRawEvent = { event_id: string; sender: string; diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index 5d578868f3a..c910f931fa9 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -1,4 +1,4 @@ -import type { EncryptedFile, MatrixRawEvent, MessageEventContent } from "../sdk.js"; +import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk"; export const EventType = { RoomMessage: "m.room.message", @@ -12,6 +12,18 @@ export const RelationType = { Thread: "m.thread", } as const; +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; +}; + export type RoomMessageEventContent = MessageEventContent & { url?: string; file?: EncryptedFile; diff --git a/extensions/matrix/src/matrix/probe.test.ts b/extensions/matrix/src/matrix/probe.test.ts deleted file mode 100644 index a15c433185c..00000000000 --- a/extensions/matrix/src/matrix/probe.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const createMatrixClientMock = vi.fn(); -const isBunRuntimeMock = vi.fn(() => false); - -vi.mock("./client.js", () => ({ - createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), - isBunRuntime: () => isBunRuntimeMock(), -})); - -import { probeMatrix } from "./probe.js"; - -describe("probeMatrix", () => { - beforeEach(() => { - vi.clearAllMocks(); - isBunRuntimeMock.mockReturnValue(false); - createMatrixClientMock.mockResolvedValue({ - getUserId: vi.fn(async () => "@bot:example.org"), - }); - }); - - it("passes undefined userId when not provided", async () => { - const result = await probeMatrix({ - homeserver: "https://matrix.example.org", - accessToken: "tok", - timeoutMs: 1234, - }); - - expect(result.ok).toBe(true); - expect(createMatrixClientMock).toHaveBeenCalledWith({ - homeserver: "https://matrix.example.org", - userId: undefined, - accessToken: "tok", - localTimeoutMs: 1234, - }); - }); - - it("trims provided userId before client creation", async () => { - await probeMatrix({ - homeserver: "https://matrix.example.org", - accessToken: "tok", - userId: " @bot:example.org ", - timeoutMs: 500, - }); - - expect(createMatrixClientMock).toHaveBeenCalledWith({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok", - localTimeoutMs: 500, - }); - }); -}); diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index fbd05ee1068..5681b242c24 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -42,14 +42,13 @@ export async function probeMatrix(params: { }; } try { - const inputUserId = params.userId?.trim() || undefined; const client = await createMatrixClient({ homeserver: params.homeserver, - userId: inputUserId, + userId: params.userId ?? "", accessToken: params.accessToken, localTimeoutMs: params.timeoutMs, }); - // The client wrapper resolves user ID via whoami when needed. + // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally const userId = await client.getUserId(); result.ok = true; result.userId = userId ?? null; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts deleted file mode 100644 index e2c6bdcfdf0..00000000000 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ /dev/null @@ -1,751 +0,0 @@ -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -class FakeMatrixEvent extends EventEmitter { - private readonly roomId: string; - private readonly eventId: string; - private readonly sender: string; - private readonly type: string; - private readonly ts: number; - private readonly content: Record; - private readonly stateKey?: string; - private readonly unsigned?: { - age?: number; - redacted_because?: unknown; - }; - private readonly decryptionFailure: boolean; - - constructor(params: { - roomId: string; - eventId: string; - sender: string; - type: string; - ts: number; - content: Record; - stateKey?: string; - unsigned?: { - age?: number; - redacted_because?: unknown; - }; - decryptionFailure?: boolean; - }) { - super(); - this.roomId = params.roomId; - this.eventId = params.eventId; - this.sender = params.sender; - this.type = params.type; - this.ts = params.ts; - this.content = params.content; - this.stateKey = params.stateKey; - this.unsigned = params.unsigned; - this.decryptionFailure = params.decryptionFailure === true; - } - - getRoomId(): string { - return this.roomId; - } - - getId(): string { - return this.eventId; - } - - getSender(): string { - return this.sender; - } - - getType(): string { - return this.type; - } - - getTs(): number { - return this.ts; - } - - getContent(): Record { - return this.content; - } - - getUnsigned(): { age?: number; redacted_because?: unknown } { - return this.unsigned ?? {}; - } - - getStateKey(): string | undefined { - return this.stateKey; - } - - isDecryptionFailure(): boolean { - return this.decryptionFailure; - } -} - -type MatrixJsClientStub = EventEmitter & { - startClient: ReturnType; - stopClient: ReturnType; - initRustCrypto: ReturnType; - getUserId: ReturnType; - getDeviceId: ReturnType; - getJoinedRooms: ReturnType; - getJoinedRoomMembers: ReturnType; - getStateEvent: ReturnType; - getAccountData: ReturnType; - setAccountData: ReturnType; - getRoomIdForAlias: ReturnType; - sendMessage: ReturnType; - sendEvent: ReturnType; - sendStateEvent: ReturnType; - redactEvent: ReturnType; - getProfileInfo: ReturnType; - joinRoom: ReturnType; - mxcUrlToHttp: ReturnType; - uploadContent: ReturnType; - fetchRoomEvent: ReturnType; - sendTyping: ReturnType; - getRoom: ReturnType; - getRooms: ReturnType; - getCrypto: ReturnType; - decryptEventIfNeeded: ReturnType; -}; - -function createMatrixJsClientStub(): MatrixJsClientStub { - const client = new EventEmitter() as MatrixJsClientStub; - client.startClient = vi.fn(async () => {}); - client.stopClient = vi.fn(); - client.initRustCrypto = vi.fn(async () => {}); - client.getUserId = vi.fn(() => "@bot:example.org"); - client.getDeviceId = vi.fn(() => "DEVICE123"); - client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] })); - client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} })); - client.getStateEvent = vi.fn(async () => ({})); - client.getAccountData = vi.fn(() => undefined); - client.setAccountData = vi.fn(async () => {}); - client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" })); - client.sendMessage = vi.fn(async () => ({ event_id: "$sent" })); - client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" })); - client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" })); - client.redactEvent = vi.fn(async () => ({ event_id: "$redact" })); - client.getProfileInfo = vi.fn(async () => ({})); - client.joinRoom = vi.fn(async () => ({})); - client.mxcUrlToHttp = vi.fn(() => null); - client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" })); - client.fetchRoomEvent = vi.fn(async () => ({})); - client.sendTyping = vi.fn(async () => {}); - client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); - client.getRooms = vi.fn(() => []); - client.getCrypto = vi.fn(() => undefined); - client.decryptEventIfNeeded = vi.fn(async () => {}); - return client; -} - -let matrixJsClient = createMatrixJsClientStub(); -let lastCreateClientOpts: Record | null = null; - -vi.mock("matrix-js-sdk", () => ({ - ClientEvent: { Event: "event", Room: "Room" }, - MatrixEventEvent: { Decrypted: "decrypted" }, - createClient: vi.fn((opts: Record) => { - lastCreateClientOpts = opts; - return matrixJsClient; - }), -})); - -import { MatrixClient } from "./sdk.js"; - -describe("MatrixClient request hardening", () => { - beforeEach(() => { - matrixJsClient = createMatrixJsClientStub(); - lastCreateClientOpts = null; - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("blocks absolute endpoints unless explicitly allowed", async () => { - const fetchMock = vi.fn(async () => { - return new Response("{}", { - status: 200, - headers: { "content-type": "application/json" }, - }); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const client = new MatrixClient("https://matrix.example.org", "token"); - await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow( - "Absolute Matrix endpoint is blocked by default", - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => { - const fetchMock = vi.fn(async () => { - return new Response("", { - status: 302, - headers: { - location: "http://evil.example.org/next", - }, - }); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const client = new MatrixClient("https://matrix.example.org", "token"); - - await expect( - client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { - allowAbsoluteEndpoint: true, - }), - ).rejects.toThrow("Blocked cross-protocol redirect"); - }); - - it("strips authorization when redirect crosses origin", async () => { - const calls: Array<{ url: string; headers: Headers }> = []; - const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => { - calls.push({ - url: String(url), - headers: new Headers(init?.headers), - }); - if (calls.length === 1) { - return new Response("", { - status: 302, - headers: { location: "https://cdn.example.org/next" }, - }); - } - return new Response("{}", { - status: 200, - headers: { "content-type": "application/json" }, - }); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const client = new MatrixClient("https://matrix.example.org", "token"); - await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { - allowAbsoluteEndpoint: true, - }); - - expect(calls).toHaveLength(2); - expect(calls[0]?.url).toBe("https://matrix.example.org/start"); - expect(calls[0]?.headers.get("authorization")).toBe("Bearer token"); - expect(calls[1]?.url).toBe("https://cdn.example.org/next"); - expect(calls[1]?.headers.get("authorization")).toBeNull(); - }); - - it("aborts requests after timeout", async () => { - vi.useFakeTimers(); - const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => { - return new Promise((_, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new Error("aborted")); - }); - }); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - localTimeoutMs: 25, - }); - - const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami"); - const assertion = expect(pending).rejects.toThrow("aborted"); - await vi.advanceTimersByTimeAsync(30); - - await assertion; - }); -}); - -describe("MatrixClient event bridge", () => { - beforeEach(() => { - matrixJsClient = createMatrixJsClientStub(); - lastCreateClientOpts = null; - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("emits room.message only after encrypted events decrypt", async () => { - const client = new MatrixClient("https://matrix.example.org", "token"); - const messageEvents: Array<{ roomId: string; type: string }> = []; - - client.on("room.message", (roomId, event) => { - messageEvents.push({ roomId, type: event.type }); - }); - - await client.start(); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - }); - const decrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.message", - ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - }, - }); - - matrixJsClient.emit("event", encrypted); - expect(messageEvents).toHaveLength(0); - - encrypted.emit("decrypted", decrypted); - // Simulate a second normal event emission from the SDK after decryption. - matrixJsClient.emit("event", decrypted); - expect(messageEvents).toEqual([ - { - roomId: "!room:example.org", - type: "m.room.message", - }, - ]); - }); - - it("emits room.failed_decryption when decrypting fails", async () => { - const client = new MatrixClient("https://matrix.example.org", "token"); - const failed: string[] = []; - const delivered: string[] = []; - - client.on("room.failed_decryption", (_roomId, _event, error) => { - failed.push(error.message); - }); - client.on("room.message", (_roomId, event) => { - delivered.push(event.type); - }); - - await client.start(); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - }); - const decrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.message", - ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - }, - }); - - matrixJsClient.emit("event", encrypted); - encrypted.emit("decrypted", decrypted, new Error("decrypt failed")); - - expect(failed).toEqual(["decrypt failed"]); - expect(delivered).toHaveLength(0); - }); - - it("retries failed decryption and emits room.message after late key availability", async () => { - vi.useFakeTimers(); - const client = new MatrixClient("https://matrix.example.org", "token"); - const failed: string[] = []; - const delivered: string[] = []; - - client.on("room.failed_decryption", (_roomId, _event, error) => { - failed.push(error.message); - }); - client.on("room.message", (_roomId, event) => { - delivered.push(event.type); - }); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - decryptionFailure: true, - }); - const decrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.message", - ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - }, - }); - - matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { - encrypted.emit("decrypted", decrypted); - }); - - await client.start(); - matrixJsClient.emit("event", encrypted); - encrypted.emit("decrypted", encrypted, new Error("missing room key")); - - expect(failed).toEqual(["missing room key"]); - expect(delivered).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1_600); - - expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); - expect(failed).toEqual(["missing room key"]); - expect(delivered).toEqual(["m.room.message"]); - }); - - it("retries failed decryptions immediately on crypto key update signals", async () => { - vi.useFakeTimers(); - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - }); - const failed: string[] = []; - const delivered: string[] = []; - const cryptoListeners = new Map void>(); - - matrixJsClient.getCrypto = vi.fn(() => ({ - on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { - cryptoListeners.set(eventName, listener); - }), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage: vi.fn(async () => {}), - requestOwnUserVerification: vi.fn(async () => null), - })); - - client.on("room.failed_decryption", (_roomId, _event, error) => { - failed.push(error.message); - }); - client.on("room.message", (_roomId, event) => { - delivered.push(event.type); - }); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - decryptionFailure: true, - }); - const decrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.message", - ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - }, - }); - matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { - encrypted.emit("decrypted", decrypted); - }); - - await client.start(); - matrixJsClient.emit("event", encrypted); - encrypted.emit("decrypted", encrypted, new Error("missing room key")); - - expect(failed).toEqual(["missing room key"]); - expect(delivered).toHaveLength(0); - - const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); - expect(trigger).toBeTypeOf("function"); - trigger?.(); - await Promise.resolve(); - - expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); - expect(delivered).toEqual(["m.room.message"]); - }); - - it("stops decryption retries after hitting retry cap", async () => { - vi.useFakeTimers(); - const client = new MatrixClient("https://matrix.example.org", "token"); - const failed: string[] = []; - - client.on("room.failed_decryption", (_roomId, _event, error) => { - failed.push(error.message); - }); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - decryptionFailure: true, - }); - - matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { - throw new Error("still missing key"); - }); - - await client.start(); - matrixJsClient.emit("event", encrypted); - encrypted.emit("decrypted", encrypted, new Error("missing room key")); - - expect(failed).toEqual(["missing room key"]); - - await vi.advanceTimersByTimeAsync(200_000); - expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); - - await vi.advanceTimersByTimeAsync(200_000); - expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); - }); - - it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => { - vi.useFakeTimers(); - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - }); - const delivered: string[] = []; - const cryptoListeners = new Map void>(); - - matrixJsClient.getCrypto = vi.fn(() => ({ - on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { - cryptoListeners.set(eventName, listener); - }), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage: vi.fn(async () => {}), - requestOwnUserVerification: vi.fn(async () => null), - })); - - client.on("room.message", (_roomId, event) => { - delivered.push(event.type); - }); - - const encrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.encrypted", - ts: Date.now(), - content: {}, - decryptionFailure: true, - }); - const decrypted = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$event", - sender: "@alice:example.org", - type: "m.room.message", - ts: Date.now(), - content: { - msgtype: "m.text", - body: "hello", - }, - }); - - let releaseRetry: (() => void) | null = null; - matrixJsClient.decryptEventIfNeeded = vi.fn( - async () => - await new Promise((resolve) => { - releaseRetry = () => { - encrypted.emit("decrypted", decrypted); - resolve(); - }; - }), - ); - - await client.start(); - matrixJsClient.emit("event", encrypted); - encrypted.emit("decrypted", encrypted, new Error("missing room key")); - - const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); - expect(trigger).toBeTypeOf("function"); - trigger?.(); - trigger?.(); - await Promise.resolve(); - - expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); - releaseRetry?.(); - await Promise.resolve(); - expect(delivered).toEqual(["m.room.message"]); - }); - - it("emits room.invite when a membership invite targets the current user", async () => { - const client = new MatrixClient("https://matrix.example.org", "token"); - const invites: string[] = []; - - client.on("room.invite", (roomId) => { - invites.push(roomId); - }); - - await client.start(); - - const inviteMembership = new FakeMatrixEvent({ - roomId: "!room:example.org", - eventId: "$invite", - sender: "@alice:example.org", - type: "m.room.member", - ts: Date.now(), - stateKey: "@bot:example.org", - content: { - membership: "invite", - }, - }); - - matrixJsClient.emit("event", inviteMembership); - - expect(invites).toEqual(["!room:example.org"]); - }); - - it("emits room.invite when SDK emits Room event with invite membership", async () => { - const client = new MatrixClient("https://matrix.example.org", "token"); - const invites: string[] = []; - client.on("room.invite", (roomId) => { - invites.push(roomId); - }); - - await client.start(); - - matrixJsClient.emit("Room", { - roomId: "!invite:example.org", - getMyMembership: () => "invite", - }); - - expect(invites).toEqual(["!invite:example.org"]); - }); - - it("replays outstanding invite rooms at startup", async () => { - matrixJsClient.getRooms = vi.fn(() => [ - { - roomId: "!pending:example.org", - getMyMembership: () => "invite", - }, - { - roomId: "!joined:example.org", - getMyMembership: () => "join", - }, - ]); - - const client = new MatrixClient("https://matrix.example.org", "token"); - const invites: string[] = []; - client.on("room.invite", (roomId) => { - invites.push(roomId); - }); - - await client.start(); - - expect(invites).toEqual(["!pending:example.org"]); - }); -}); - -describe("MatrixClient crypto bootstrapping", () => { - beforeEach(() => { - matrixJsClient = createMatrixJsClientStub(); - lastCreateClientOpts = null; - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("passes cryptoDatabasePrefix into initRustCrypto", async () => { - matrixJsClient.getCrypto = vi.fn(() => undefined); - - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - cryptoDatabasePrefix: "openclaw-matrix-test", - }); - - await client.start(); - - expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({ - cryptoDatabasePrefix: "openclaw-matrix-test", - }); - }); - - it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => { - const bootstrapCrossSigning = vi.fn(async () => {}); - matrixJsClient.getCrypto = vi.fn(() => ({ - on: vi.fn(), - bootstrapCrossSigning, - bootstrapSecretStorage: vi.fn(async () => {}), - requestOwnUserVerification: vi.fn(async () => null), - })); - - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - }); - - await client.start(); - - expect(bootstrapCrossSigning).toHaveBeenCalledWith( - expect.objectContaining({ - authUploadDeviceSigningKeys: expect.any(Function), - }), - ); - }); - - it("provides secret storage callbacks and resolves stored recovery key", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-")); - const recoveryKeyPath = path.join(tmpDir, "recovery-key.json"); - const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64"); - fs.writeFileSync( - recoveryKeyPath, - JSON.stringify({ - version: 1, - createdAt: new Date().toISOString(), - keyId: "SSSSKEY", - privateKeyBase64, - }), - "utf8", - ); - - new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - recoveryKeyPath, - }); - - const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { - getSecretStorageKey?: ( - params: { keys: Record }, - name: string, - ) => Promise<[string, Uint8Array] | null>; - } | null; - expect(callbacks?.getSecretStorageKey).toBeTypeOf("function"); - - const resolved = await callbacks?.getSecretStorageKey?.( - { keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } }, - "m.cross_signing.master", - ); - expect(resolved?.[0]).toBe("SSSSKEY"); - expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); - }); - - it("schedules periodic crypto snapshot persistence with fake timers", async () => { - vi.useFakeTimers(); - const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]); - - const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { - encryption: true, - idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"), - cryptoDatabasePrefix: "openclaw-matrix-interval", - }); - - await client.start(); - const callsAfterStart = databasesSpy.mock.calls.length; - - await vi.advanceTimersByTimeAsync(60_000); - expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart); - - client.stop(); - const callsAfterStop = databasesSpy.mock.calls.length; - await vi.advanceTimersByTimeAsync(120_000); - expect(databasesSpy.mock.calls.length).toBe(callsAfterStop); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts deleted file mode 100644 index 018148a3cd5..00000000000 --- a/extensions/matrix/src/matrix/sdk.ts +++ /dev/null @@ -1,527 +0,0 @@ -// Polyfill IndexedDB for WASM crypto in Node.js -import "fake-indexeddb/auto"; -import { - ClientEvent, - createClient as createMatrixJsClient, - type MatrixClient as MatrixJsClient, - type MatrixEvent, -} from "matrix-js-sdk"; -import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; -import { EventEmitter } from "node:events"; -import type { - MatrixClientEventMap, - MatrixCryptoBootstrapApi, - MatrixRawEvent, - MessageEventContent, -} from "./sdk/types.js"; -import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; -import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js"; -import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js"; -import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js"; -import { MatrixAuthedHttpClient } from "./sdk/http-client.js"; -import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js"; -import { ConsoleLogger, LogService, noop } from "./sdk/logger.js"; -import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js"; -import { type HttpMethod, type QueryParams } from "./sdk/transport.js"; -import { MatrixVerificationManager } from "./sdk/verification-manager.js"; - -export { ConsoleLogger, LogService }; -export type { - DimensionalFileInfo, - FileWithThumbnailInfo, - TimedFileInfo, - VideoFileInfo, -} from "./sdk/types.js"; -export type { - EncryptedFile, - LocationMessageEventContent, - MessageEventContent, - TextualMessageEventContent, -} from "./sdk/types.js"; - -export class MatrixClient { - private readonly client: MatrixJsClient; - private readonly emitter = new EventEmitter(); - private readonly httpClient: MatrixAuthedHttpClient; - private readonly localTimeoutMs: number; - private readonly initialSyncLimit?: number; - private readonly encryptionEnabled: boolean; - private readonly idbSnapshotPath?: string; - private readonly cryptoDatabasePrefix?: string; - private bridgeRegistered = false; - private started = false; - private selfUserId: string | null; - private readonly dmRoomIds = new Set(); - private cryptoInitialized = false; - private readonly decryptBridge: MatrixDecryptBridge; - private readonly verificationManager = new MatrixVerificationManager(); - private readonly recoveryKeyStore: MatrixRecoveryKeyStore; - private readonly cryptoBootstrapper: MatrixCryptoBootstrapper; - - readonly dms = { - update: async (): Promise => { - await this.refreshDmCache(); - }, - isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId), - }; - - crypto?: MatrixCryptoFacade; - - constructor( - homeserver: string, - accessToken: string, - _storage?: unknown, - _cryptoStorage?: unknown, - opts: { - userId?: string; - password?: string; - deviceId?: string; - localTimeoutMs?: number; - encryption?: boolean; - initialSyncLimit?: number; - recoveryKeyPath?: string; - idbSnapshotPath?: string; - cryptoDatabasePrefix?: string; - } = {}, - ) { - this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken); - this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000); - this.initialSyncLimit = opts.initialSyncLimit; - this.encryptionEnabled = opts.encryption === true; - this.idbSnapshotPath = opts.idbSnapshotPath; - this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; - this.selfUserId = opts.userId?.trim() || null; - this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath); - const cryptoCallbacks = this.encryptionEnabled - ? this.recoveryKeyStore.buildCryptoCallbacks() - : undefined; - this.client = createMatrixJsClient({ - baseUrl: homeserver, - accessToken, - userId: opts.userId, - deviceId: opts.deviceId, - localTimeoutMs: this.localTimeoutMs, - cryptoCallbacks, - verificationMethods: [ - VerificationMethod.Sas, - VerificationMethod.ShowQrCode, - VerificationMethod.ScanQrCode, - VerificationMethod.Reciprocate, - ], - }); - this.decryptBridge = new MatrixDecryptBridge({ - client: this.client, - toRaw: (event) => matrixEventToRaw(event), - emitDecryptedEvent: (roomId, event) => { - this.emitter.emit("room.decrypted_event", roomId, event); - }, - emitMessage: (roomId, event) => { - this.emitter.emit("room.message", roomId, event); - }, - emitFailedDecryption: (roomId, event, error) => { - this.emitter.emit("room.failed_decryption", roomId, event, error); - }, - }); - this.cryptoBootstrapper = new MatrixCryptoBootstrapper({ - getUserId: () => this.getUserId(), - getPassword: () => opts.password, - getDeviceId: () => this.client.getDeviceId(), - verificationManager: this.verificationManager, - recoveryKeyStore: this.recoveryKeyStore, - decryptBridge: this.decryptBridge, - }); - - if (this.encryptionEnabled) { - this.crypto = createMatrixCryptoFacade({ - client: this.client, - verificationManager: this.verificationManager, - recoveryKeyStore: this.recoveryKeyStore, - getRoomStateEvent: (roomId, eventType, stateKey = "") => - this.getRoomStateEvent(roomId, eventType, stateKey), - downloadContent: (mxcUrl) => this.downloadContent(mxcUrl), - }); - } - } - - on( - eventName: TEvent, - listener: (...args: MatrixClientEventMap[TEvent]) => void, - ): this; - on(eventName: string, listener: (...args: unknown[]) => void): this; - on(eventName: string, listener: (...args: unknown[]) => void): this { - this.emitter.on(eventName, listener as (...args: unknown[]) => void); - return this; - } - - off( - eventName: TEvent, - listener: (...args: MatrixClientEventMap[TEvent]) => void, - ): this; - off(eventName: string, listener: (...args: unknown[]) => void): this; - off(eventName: string, listener: (...args: unknown[]) => void): this { - this.emitter.off(eventName, listener as (...args: unknown[]) => void); - return this; - } - - private idbPersistTimer: ReturnType | null = null; - - async start(): Promise { - if (this.started) { - return; - } - - this.registerBridge(); - await this.initializeCryptoIfNeeded(); - - await this.client.startClient({ - initialSyncLimit: this.initialSyncLimit, - }); - this.started = true; - this.emitOutstandingInviteEvents(); - await this.refreshDmCache().catch(noop); - } - - stop(): void { - if (this.idbPersistTimer) { - clearInterval(this.idbPersistTimer); - this.idbPersistTimer = null; - } - this.decryptBridge.stop(); - // Final persist on shutdown - persistIdbToDisk({ - snapshotPath: this.idbSnapshotPath, - databasePrefix: this.cryptoDatabasePrefix, - }).catch(noop); - this.client.stopClient(); - this.started = false; - } - - private async initializeCryptoIfNeeded(): Promise { - if (!this.encryptionEnabled || this.cryptoInitialized) { - return; - } - - // Restore persisted IndexedDB crypto store before initializing WASM crypto. - await restoreIdbFromDisk(this.idbSnapshotPath); - - try { - await this.client.initRustCrypto({ - cryptoDatabasePrefix: this.cryptoDatabasePrefix, - }); - this.cryptoInitialized = true; - - const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; - if (crypto) { - await this.cryptoBootstrapper.bootstrap(crypto); - } - - // Persist the crypto store after successful init (captures fresh keys on first run). - await persistIdbToDisk({ - snapshotPath: this.idbSnapshotPath, - databasePrefix: this.cryptoDatabasePrefix, - }); - - // Periodically persist to capture new Olm sessions and room keys. - this.idbPersistTimer = setInterval(() => { - persistIdbToDisk({ - snapshotPath: this.idbSnapshotPath, - databasePrefix: this.cryptoDatabasePrefix, - }).catch(noop); - }, 60_000); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err); - } - } - - async getUserId(): Promise { - const fromClient = this.client.getUserId(); - if (fromClient) { - this.selfUserId = fromClient; - return fromClient; - } - if (this.selfUserId) { - return this.selfUserId; - } - const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { - user_id?: string; - }; - const resolved = whoami.user_id?.trim(); - if (!resolved) { - throw new Error("Matrix whoami did not return user_id"); - } - this.selfUserId = resolved; - return resolved; - } - - async getJoinedRooms(): Promise { - const joined = await this.client.getJoinedRooms(); - return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : []; - } - - async getJoinedRoomMembers(roomId: string): Promise { - const members = await this.client.getJoinedRoomMembers(roomId); - const joined = members?.joined; - if (!joined || typeof joined !== "object") { - return []; - } - return Object.keys(joined); - } - - async getRoomStateEvent( - roomId: string, - eventType: string, - stateKey = "", - ): Promise> { - const state = await this.client.getStateEvent(roomId, eventType, stateKey); - return (state ?? {}) as Record; - } - - async getAccountData(eventType: string): Promise | undefined> { - const event = this.client.getAccountData(eventType); - return (event?.getContent() as Record | undefined) ?? undefined; - } - - async setAccountData(eventType: string, content: Record): Promise { - await this.client.setAccountData(eventType as never, content as never); - await this.refreshDmCache().catch(noop); - } - - async resolveRoom(aliasOrRoomId: string): Promise { - if (aliasOrRoomId.startsWith("!")) { - return aliasOrRoomId; - } - if (!aliasOrRoomId.startsWith("#")) { - return aliasOrRoomId; - } - try { - const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId); - return resolved.room_id ?? null; - } catch { - return null; - } - } - - async sendMessage(roomId: string, content: MessageEventContent): Promise { - const sent = await this.client.sendMessage(roomId, content as never); - return sent.event_id; - } - - async sendEvent( - roomId: string, - eventType: string, - content: Record, - ): Promise { - const sent = await this.client.sendEvent(roomId, eventType as never, content as never); - return sent.event_id; - } - - async sendStateEvent( - roomId: string, - eventType: string, - stateKey: string, - content: Record, - ): Promise { - const sent = await this.client.sendStateEvent( - roomId, - eventType as never, - content as never, - stateKey, - ); - return sent.event_id; - } - - async redactEvent(roomId: string, eventId: string, reason?: string): Promise { - const sent = await this.client.redactEvent( - roomId, - eventId, - undefined, - reason?.trim() ? { reason } : undefined, - ); - return sent.event_id; - } - - async doRequest( - method: HttpMethod, - endpoint: string, - qs?: QueryParams, - body?: unknown, - opts?: { allowAbsoluteEndpoint?: boolean }, - ): Promise { - return await this.httpClient.requestJson({ - method, - endpoint, - qs, - body, - timeoutMs: this.localTimeoutMs, - allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint, - }); - } - - async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> { - return await this.client.getProfileInfo(userId); - } - - async joinRoom(roomId: string): Promise { - await this.client.joinRoom(roomId); - } - - mxcToHttp(mxcUrl: string): string | null { - return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true); - } - - async downloadContent(mxcUrl: string, allowRemote = true): Promise { - const parsed = parseMxc(mxcUrl); - if (!parsed) { - throw new Error(`Invalid Matrix content URI: ${mxcUrl}`); - } - const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`; - const response = await this.httpClient.requestRaw({ - method: "GET", - endpoint, - qs: { allow_remote: allowRemote }, - timeoutMs: this.localTimeoutMs, - }); - return response; - } - - async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise { - const uploaded = await this.client.uploadContent(file, { - type: contentType || "application/octet-stream", - name: filename, - includeFilename: Boolean(filename), - }); - return uploaded.content_uri; - } - - async getEvent(roomId: string, eventId: string): Promise> { - return (await this.client.fetchRoomEvent(roomId, eventId)) as Record; - } - - async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise { - await this.client.sendTyping(roomId, typing, timeoutMs); - } - - async sendReadReceipt(roomId: string, eventId: string): Promise { - await this.httpClient.requestJson({ - method: "POST", - endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent( - eventId, - )}`, - body: {}, - timeoutMs: this.localTimeoutMs, - }); - } - - private registerBridge(): void { - if (this.bridgeRegistered) { - return; - } - this.bridgeRegistered = true; - - this.client.on(ClientEvent.Event, (event: MatrixEvent) => { - const roomId = event.getRoomId(); - if (!roomId) { - return; - } - - const raw = matrixEventToRaw(event); - const isEncryptedEvent = raw.type === "m.room.encrypted"; - this.emitter.emit("room.event", roomId, raw); - if (isEncryptedEvent) { - this.emitter.emit("room.encrypted_event", roomId, raw); - } else { - if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) { - this.emitter.emit("room.message", roomId, raw); - } - } - - const stateKey = raw.state_key ?? ""; - const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; - const membership = - raw.type === "m.room.member" - ? (raw.content as { membership?: string }).membership - : undefined; - if (stateKey && selfUserId && stateKey === selfUserId) { - if (membership === "invite") { - this.emitter.emit("room.invite", roomId, raw); - } else if (membership === "join") { - this.emitter.emit("room.join", roomId, raw); - } - } - - if (isEncryptedEvent) { - this.decryptBridge.attachEncryptedEvent(event, roomId); - } - }); - - // Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events. - this.client.on(ClientEvent.Room, (room) => { - this.emitMembershipForRoom(room); - }); - } - - private emitMembershipForRoom(room: unknown): void { - const roomObj = room as { - roomId?: string; - getMyMembership?: () => string | null | undefined; - selfMembership?: string | null | undefined; - }; - const roomId = roomObj.roomId?.trim(); - if (!roomId) { - return; - } - const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined; - const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; - if (!selfUserId) { - return; - } - const raw: MatrixRawEvent = { - type: "m.room.member", - room_id: roomId, - sender: selfUserId, - state_key: selfUserId, - content: { membership }, - origin_server_ts: Date.now(), - unsigned: { age: 0 }, - }; - if (membership === "invite") { - this.emitter.emit("room.invite", roomId, raw); - return; - } - if (membership === "join") { - this.emitter.emit("room.join", roomId, raw); - } - } - - private emitOutstandingInviteEvents(): void { - const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms; - if (typeof listRooms !== "function") { - return; - } - const rooms = listRooms.call(this.client); - if (!Array.isArray(rooms)) { - return; - } - for (const room of rooms) { - this.emitMembershipForRoom(room); - } - } - - private async refreshDmCache(): Promise { - const direct = await this.getAccountData("m.direct"); - this.dmRoomIds.clear(); - if (!direct || typeof direct !== "object") { - return; - } - for (const value of Object.values(direct)) { - if (!Array.isArray(value)) { - continue; - } - for (const roomId of value) { - if (typeof roomId === "string" && roomId.trim()) { - this.dmRoomIds.add(roomId); - } - } - } - } -} diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts deleted file mode 100644 index a75f75062a8..00000000000 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; -import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js"; - -function createBootstrapperDeps() { - return { - getUserId: vi.fn(async () => "@bot:example.org"), - getPassword: vi.fn(() => "super-secret-password"), - getDeviceId: vi.fn(() => "DEVICE123"), - verificationManager: { - trackVerificationRequest: vi.fn(), - }, - recoveryKeyStore: { - bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}), - }, - decryptBridge: { - bindCryptoRetrySignals: vi.fn(), - }, - }; -} - -function createCryptoApi(overrides?: Partial): MatrixCryptoBootstrapApi { - return { - on: vi.fn(), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage: vi.fn(async () => {}), - requestOwnUserVerification: vi.fn(async () => null), - ...overrides, - }; -} - -describe("MatrixCryptoBootstrapper", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => { - const deps = createBootstrapperDeps(); - const crypto = createCryptoApi({ - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => true, - })), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith( - expect.objectContaining({ - authUploadDeviceSigningKeys: expect.any(Function), - }), - ); - expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( - crypto, - ); - expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2); - expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); - }); - - it("forces new cross-signing keys only when readiness check still fails", async () => { - const deps = createBootstrapperDeps(); - const bootstrapCrossSigning = vi.fn(async () => {}); - const crypto = createCryptoApi({ - bootstrapCrossSigning, - isCrossSigningReady: vi - .fn<() => Promise>() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true), - userHasCrossSigningKeys: vi - .fn<() => Promise>() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true), - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => true, - })), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); - expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - authUploadDeviceSigningKeys: expect.any(Function), - }), - ); - expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - setupNewCrossSigning: true, - authUploadDeviceSigningKeys: expect.any(Function), - }), - ); - }); - - it("uses password UIA fallback when null and dummy auth fail", async () => { - const deps = createBootstrapperDeps(); - const bootstrapCrossSigning = vi.fn(async () => {}); - const crypto = createCryptoApi({ - bootstrapCrossSigning, - isCrossSigningReady: vi.fn(async () => true), - userHasCrossSigningKeys: vi.fn(async () => true), - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => true, - })), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - const firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as { - authUploadDeviceSigningKeys?: ( - makeRequest: (authData: Record | null) => Promise, - ) => Promise; - }; - expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function"); - - const seenAuthStages: Array | null> = []; - const result = await firstCall.authUploadDeviceSigningKeys?.(async (authData) => { - seenAuthStages.push(authData); - if (authData === null) { - throw new Error("need auth"); - } - if (authData.type === "m.login.dummy") { - throw new Error("dummy rejected"); - } - if (authData.type === "m.login.password") { - return "ok"; - } - throw new Error("unexpected auth stage"); - }); - - expect(result).toBe("ok"); - expect(seenAuthStages).toEqual([ - null, - { type: "m.login.dummy" }, - { - type: "m.login.password", - identifier: { type: "m.id.user", user: "@bot:example.org" }, - password: "super-secret-password", - }, - ]); - }); - - it("resets cross-signing when first bootstrap attempt throws", async () => { - const deps = createBootstrapperDeps(); - const bootstrapCrossSigning = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error("first attempt failed")) - .mockResolvedValueOnce(undefined); - const crypto = createCryptoApi({ - bootstrapCrossSigning, - isCrossSigningReady: vi.fn(async () => true), - userHasCrossSigningKeys: vi.fn(async () => true), - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => true, - })), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); - expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - setupNewCrossSigning: true, - authUploadDeviceSigningKeys: expect.any(Function), - }), - ); - }); - - it("marks own device verified and cross-signs it when needed", async () => { - const deps = createBootstrapperDeps(); - const setDeviceVerified = vi.fn(async () => {}); - const crossSignDevice = vi.fn(async () => {}); - const crypto = createCryptoApi({ - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => false, - localVerified: false, - crossSigningVerified: false, - signedByOwner: false, - })), - setDeviceVerified, - crossSignDevice, - isCrossSigningReady: vi.fn(async () => true), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); - expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); - }); - - it("auto-accepts incoming verification requests from other users", async () => { - const deps = createBootstrapperDeps(); - const listeners = new Map void>(); - const crypto = createCryptoApi({ - getDeviceVerificationStatus: vi.fn(async () => ({ - isVerified: () => true, - })), - on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { - listeners.set(eventName, listener); - }), - }); - const bootstrapper = new MatrixCryptoBootstrapper( - deps as unknown as MatrixCryptoBootstrapperDeps, - ); - - await bootstrapper.bootstrap(crypto); - - const verificationRequest = { - otherUserId: "@alice:example.org", - isSelfVerification: false, - initiatedByMe: false, - accept: vi.fn(async () => {}), - }; - const listener = Array.from(listeners.entries()).find(([eventName]) => - eventName.toLowerCase().includes("verificationrequest"), - )?.[1]; - expect(listener).toBeTypeOf("function"); - await listener?.(verificationRequest); - - expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith( - verificationRequest, - ); - expect(verificationRequest.accept).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts deleted file mode 100644 index 56676615822..00000000000 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; -import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; -import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; -import type { - MatrixAuthDict, - MatrixCryptoBootstrapApi, - MatrixRawEvent, - MatrixUiAuthCallback, -} from "./types.js"; -import type { - MatrixVerificationManager, - MatrixVerificationRequestLike, -} from "./verification-manager.js"; -import { LogService } from "./logger.js"; - -export type MatrixCryptoBootstrapperDeps = { - getUserId: () => Promise; - getPassword?: () => string | undefined; - getDeviceId: () => string | null | undefined; - verificationManager: MatrixVerificationManager; - recoveryKeyStore: MatrixRecoveryKeyStore; - decryptBridge: Pick, "bindCryptoRetrySignals">; -}; - -export class MatrixCryptoBootstrapper { - constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} - - async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise { - await this.bootstrapSecretStorage(crypto); - await this.bootstrapCrossSigning(crypto); - await this.bootstrapSecretStorage(crypto); - await this.ensureOwnDeviceTrust(crypto); - this.registerVerificationRequestHandler(crypto); - } - - private createSigningKeysUiAuthCallback(params: { - userId: string; - password?: string; - }): MatrixUiAuthCallback { - return async (makeRequest: (authData: MatrixAuthDict | null) => Promise): Promise => { - try { - return await makeRequest(null); - } catch { - // Some homeservers require an explicit dummy UIA stage even when no user interaction is needed. - try { - return await makeRequest({ type: "m.login.dummy" }); - } catch { - if (!params.password?.trim()) { - throw new Error( - "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", - ); - } - return await makeRequest({ - type: "m.login.password", - identifier: { type: "m.id.user", user: params.userId }, - password: params.password, - }); - } - } - }; - } - - private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise { - const userId = await this.deps.getUserId(); - const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({ - userId, - password: this.deps.getPassword?.(), - }); - const hasPublishedCrossSigningKeys = async (): Promise => { - if (typeof crypto.userHasCrossSigningKeys !== "function") { - return true; - } - try { - return await crypto.userHasCrossSigningKeys(userId, true); - } catch { - return false; - } - }; - const isCrossSigningReady = async (): Promise => { - if (typeof crypto.isCrossSigningReady !== "function") { - return true; - } - try { - return await crypto.isCrossSigningReady(); - } catch { - return false; - } - }; - - // First pass: preserve existing cross-signing identity and ensure public keys are uploaded. - try { - await crypto.bootstrapCrossSigning({ - authUploadDeviceSigningKeys, - }); - } catch (err) { - LogService.warn( - "MatrixClientLite", - "Initial cross-signing bootstrap failed, trying reset:", - err, - ); - try { - await crypto.bootstrapCrossSigning({ - setupNewCrossSigning: true, - authUploadDeviceSigningKeys, - }); - } catch (resetErr) { - LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr); - return; - } - } - - const firstPassReady = await isCrossSigningReady(); - const firstPassPublished = await hasPublishedCrossSigningKeys(); - if (firstPassReady && firstPassPublished) { - LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); - return; - } - - // Fallback: recover from broken local/server state by creating a fresh identity. - try { - await crypto.bootstrapCrossSigning({ - setupNewCrossSigning: true, - authUploadDeviceSigningKeys, - }); - } catch (err) { - LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); - return; - } - - const finalReady = await isCrossSigningReady(); - const finalPublished = await hasPublishedCrossSigningKeys(); - if (finalReady && finalPublished) { - LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); - return; - } - LogService.warn( - "MatrixClientLite", - "Cross-signing bootstrap finished but server keys are still not published", - ); - } - - private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise { - try { - await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto); - LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); - } - } - - private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { - // Auto-accept incoming verification requests from other users/devices. - crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { - const verificationRequest = request as MatrixVerificationRequestLike; - this.deps.verificationManager.trackVerificationRequest(verificationRequest); - const otherUserId = verificationRequest.otherUserId; - const isSelfVerification = verificationRequest.isSelfVerification; - const initiatedByMe = verificationRequest.initiatedByMe; - - if (isSelfVerification || initiatedByMe) { - LogService.debug( - "MatrixClientLite", - `Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`, - ); - return; - } - - try { - LogService.info( - "MatrixClientLite", - `Auto-accepting verification request from ${otherUserId}`, - ); - await verificationRequest.accept(); - LogService.info( - "MatrixClientLite", - `Verification request from ${otherUserId} accepted, waiting for SAS...`, - ); - } catch (err) { - LogService.warn( - "MatrixClientLite", - `Failed to auto-accept verification from ${otherUserId}:`, - err, - ); - } - }); - - this.deps.decryptBridge.bindCryptoRetrySignals(crypto); - LogService.info("MatrixClientLite", "Verification request handler registered"); - } - - private async ensureOwnDeviceTrust(crypto: MatrixCryptoBootstrapApi): Promise { - const deviceId = this.deps.getDeviceId()?.trim(); - if (!deviceId) { - return; - } - const userId = await this.deps.getUserId(); - - const deviceStatus = - typeof crypto.getDeviceVerificationStatus === "function" - ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) - : null; - const alreadyVerified = - deviceStatus?.isVerified?.() === true || - deviceStatus?.localVerified === true || - deviceStatus?.crossSigningVerified === true || - deviceStatus?.signedByOwner === true; - - if (alreadyVerified) { - return; - } - - if (typeof crypto.setDeviceVerified === "function") { - await crypto.setDeviceVerified(userId, deviceId, true); - } - - if (typeof crypto.crossSignDevice === "function") { - const crossSigningReady = - typeof crypto.isCrossSigningReady === "function" - ? await crypto.isCrossSigningReady() - : true; - if (crossSigningReady) { - await crypto.crossSignDevice(deviceId); - } - } - } -} diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts deleted file mode 100644 index e6494adc4bf..00000000000 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; -import type { MatrixVerificationManager } from "./verification-manager.js"; -import { createMatrixCryptoFacade } from "./crypto-facade.js"; - -describe("createMatrixCryptoFacade", () => { - it("detects encrypted rooms from cached room state", async () => { - const facade = createMatrixCryptoFacade({ - client: { - getRoom: () => ({ - hasEncryptionStateEvent: () => true, - }), - getCrypto: () => undefined, - }, - verificationManager: { - requestOwnUserVerification: vi.fn(), - listVerifications: vi.fn(async () => []), - requestVerification: vi.fn(), - acceptVerification: vi.fn(), - cancelVerification: vi.fn(), - startVerification: vi.fn(), - generateVerificationQr: vi.fn(), - scanVerificationQr: vi.fn(), - confirmVerificationSas: vi.fn(), - mismatchVerificationSas: vi.fn(), - confirmVerificationReciprocateQr: vi.fn(), - getVerificationSas: vi.fn(), - } as unknown as MatrixVerificationManager, - recoveryKeyStore: { - getRecoveryKeySummary: vi.fn(() => null), - } as unknown as MatrixRecoveryKeyStore, - getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })), - downloadContent: vi.fn(async () => Buffer.alloc(0)), - }); - - await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); - }); - - it("falls back to server room state when room cache has no encryption event", async () => { - const getRoomStateEvent = vi.fn(async () => ({ - algorithm: "m.megolm.v1.aes-sha2", - })); - const facade = createMatrixCryptoFacade({ - client: { - getRoom: () => ({ - hasEncryptionStateEvent: () => false, - }), - getCrypto: () => undefined, - }, - verificationManager: { - requestOwnUserVerification: vi.fn(), - listVerifications: vi.fn(async () => []), - requestVerification: vi.fn(), - acceptVerification: vi.fn(), - cancelVerification: vi.fn(), - startVerification: vi.fn(), - generateVerificationQr: vi.fn(), - scanVerificationQr: vi.fn(), - confirmVerificationSas: vi.fn(), - mismatchVerificationSas: vi.fn(), - confirmVerificationReciprocateQr: vi.fn(), - getVerificationSas: vi.fn(), - } as unknown as MatrixVerificationManager, - recoveryKeyStore: { - getRecoveryKeySummary: vi.fn(() => null), - } as unknown as MatrixRecoveryKeyStore, - getRoomStateEvent, - downloadContent: vi.fn(async () => Buffer.alloc(0)), - }); - - await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); - expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", ""); - }); - - it("forwards verification requests and uses client crypto API", async () => { - const crypto = { requestOwnUserVerification: vi.fn(async () => null) }; - const requestVerification = vi.fn(async () => ({ - id: "verification-1", - otherUserId: "@alice:example.org", - isSelfVerification: false, - initiatedByMe: true, - phase: 2, - phaseName: "ready", - pending: true, - methods: ["m.sas.v1"], - canAccept: false, - hasSas: false, - hasReciprocateQr: false, - completed: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - })); - const facade = createMatrixCryptoFacade({ - client: { - getRoom: () => null, - getCrypto: () => crypto, - }, - verificationManager: { - requestOwnUserVerification: vi.fn(async () => null), - listVerifications: vi.fn(async () => []), - requestVerification, - acceptVerification: vi.fn(), - cancelVerification: vi.fn(), - startVerification: vi.fn(), - generateVerificationQr: vi.fn(), - scanVerificationQr: vi.fn(), - confirmVerificationSas: vi.fn(), - mismatchVerificationSas: vi.fn(), - confirmVerificationReciprocateQr: vi.fn(), - getVerificationSas: vi.fn(), - } as unknown as MatrixVerificationManager, - recoveryKeyStore: { - getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })), - } as unknown as MatrixRecoveryKeyStore, - getRoomStateEvent: vi.fn(async () => ({})), - downloadContent: vi.fn(async () => Buffer.alloc(0)), - }); - - const result = await facade.requestVerification({ - userId: "@alice:example.org", - deviceId: "DEVICE", - }); - - expect(requestVerification).toHaveBeenCalledWith(crypto, { - userId: "@alice:example.org", - deviceId: "DEVICE", - }); - expect(result.id).toBe("verification-1"); - await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" }); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts deleted file mode 100644 index e31131415cb..00000000000 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; -import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; -import type { EncryptedFile } from "./types.js"; -import type { - MatrixVerificationCryptoApi, - MatrixVerificationManager, - MatrixVerificationMethod, - MatrixVerificationSummary, -} from "./verification-manager.js"; - -type MatrixCryptoFacadeClient = { - getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null; - getCrypto: () => unknown; -}; - -export type MatrixCryptoFacade = { - prepare: (joinedRooms: string[]) => Promise; - updateSyncData: ( - toDeviceMessages: unknown, - otkCounts: unknown, - unusedFallbackKeyAlgs: unknown, - changedDeviceLists: unknown, - leftDeviceLists: unknown, - ) => Promise; - isRoomEncrypted: (roomId: string) => Promise; - requestOwnUserVerification: () => Promise; - encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit }>; - decryptMedia: (file: EncryptedFile) => Promise; - getRecoveryKey: () => Promise<{ - encodedPrivateKey?: string; - keyId?: string | null; - createdAt?: string; - } | null>; - listVerifications: () => Promise; - requestVerification: (params: { - ownUser?: boolean; - userId?: string; - deviceId?: string; - roomId?: string; - }) => Promise; - acceptVerification: (id: string) => Promise; - cancelVerification: ( - id: string, - params?: { reason?: string; code?: string }, - ) => Promise; - startVerification: ( - id: string, - method?: MatrixVerificationMethod, - ) => Promise; - generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>; - scanVerificationQr: (id: string, qrDataBase64: string) => Promise; - confirmVerificationSas: (id: string) => Promise; - mismatchVerificationSas: (id: string) => Promise; - confirmVerificationReciprocateQr: (id: string) => Promise; - getVerificationSas: ( - id: string, - ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; -}; - -export function createMatrixCryptoFacade(deps: { - client: MatrixCryptoFacadeClient; - verificationManager: MatrixVerificationManager; - recoveryKeyStore: MatrixRecoveryKeyStore; - getRoomStateEvent: ( - roomId: string, - eventType: string, - stateKey?: string, - ) => Promise>; - downloadContent: (mxcUrl: string) => Promise; -}): MatrixCryptoFacade { - return { - prepare: async (_joinedRooms: string[]) => { - // matrix-js-sdk performs crypto prep during startup; no extra work required here. - }, - updateSyncData: async ( - _toDeviceMessages: unknown, - _otkCounts: unknown, - _unusedFallbackKeyAlgs: unknown, - _changedDeviceLists: unknown, - _leftDeviceLists: unknown, - ) => { - // compatibility no-op - }, - isRoomEncrypted: async (roomId: string): Promise => { - const room = deps.client.getRoom(roomId); - if (room?.hasEncryptionStateEvent()) { - return true; - } - try { - const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", ""); - return typeof event.algorithm === "string" && event.algorithm.length > 0; - } catch { - return false; - } - }, - requestOwnUserVerification: async (): Promise => { - const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; - return await deps.verificationManager.requestOwnUserVerification(crypto); - }, - encryptMedia: async ( - buffer: Buffer, - ): Promise<{ buffer: Buffer; file: Omit }> => { - const encrypted = Attachment.encrypt(new Uint8Array(buffer)); - const mediaInfoJson = encrypted.mediaEncryptionInfo; - if (!mediaInfoJson) { - throw new Error("Matrix media encryption failed: missing media encryption info"); - } - const parsed = JSON.parse(mediaInfoJson) as EncryptedFile; - return { - buffer: Buffer.from(encrypted.encryptedData), - file: { - key: parsed.key, - iv: parsed.iv, - hashes: parsed.hashes, - v: parsed.v, - }, - }; - }, - decryptMedia: async (file: EncryptedFile): Promise => { - const encrypted = await deps.downloadContent(file.url); - const metadata: EncryptedFile = { - url: file.url, - key: file.key, - iv: file.iv, - hashes: file.hashes, - v: file.v, - }; - const attachment = new EncryptedAttachment( - new Uint8Array(encrypted), - JSON.stringify(metadata), - ); - const decrypted = Attachment.decrypt(attachment); - return Buffer.from(decrypted); - }, - getRecoveryKey: async () => { - return deps.recoveryKeyStore.getRecoveryKeySummary(); - }, - listVerifications: async () => { - return deps.verificationManager.listVerifications(); - }, - requestVerification: async (params) => { - const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; - return await deps.verificationManager.requestVerification(crypto, params); - }, - acceptVerification: async (id) => { - return await deps.verificationManager.acceptVerification(id); - }, - cancelVerification: async (id, params) => { - return await deps.verificationManager.cancelVerification(id, params); - }, - startVerification: async (id, method = "sas") => { - return await deps.verificationManager.startVerification(id, method); - }, - generateVerificationQr: async (id) => { - return await deps.verificationManager.generateVerificationQr(id); - }, - scanVerificationQr: async (id, qrDataBase64) => { - return await deps.verificationManager.scanVerificationQr(id, qrDataBase64); - }, - confirmVerificationSas: async (id) => { - return await deps.verificationManager.confirmVerificationSas(id); - }, - mismatchVerificationSas: async (id) => { - return deps.verificationManager.mismatchVerificationSas(id); - }, - confirmVerificationReciprocateQr: async (id) => { - return deps.verificationManager.confirmVerificationReciprocateQr(id); - }, - getVerificationSas: async (id) => { - return deps.verificationManager.getVerificationSas(id); - }, - }; -} diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts deleted file mode 100644 index 1df9e8748bd..00000000000 --- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk"; -import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; -import { LogService, noop } from "./logger.js"; - -type MatrixDecryptIfNeededClient = { - decryptEventIfNeeded?: ( - event: MatrixEvent, - opts?: { - isRetry?: boolean; - }, - ) => Promise; -}; - -type MatrixDecryptRetryState = { - event: MatrixEvent; - roomId: string; - eventId: string; - attempts: number; - inFlight: boolean; - timer: ReturnType | null; -}; - -type DecryptBridgeRawEvent = { - event_id: string; -}; - -type MatrixCryptoRetrySignalSource = { - on: (eventName: string, listener: (...args: unknown[]) => void) => void; -}; - -const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500; -const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000; -const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8; - -function resolveDecryptRetryKey(roomId: string, eventId: string): string | null { - if (!roomId || !eventId) { - return null; - } - return `${roomId}|${eventId}`; -} - -function isDecryptionFailure(event: MatrixEvent): boolean { - return ( - typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" && - (event as { isDecryptionFailure: () => boolean }).isDecryptionFailure() - ); -} - -export class MatrixDecryptBridge { - private readonly trackedEncryptedEvents = new WeakSet(); - private readonly decryptedMessageDedupe = new Map(); - private readonly decryptRetries = new Map(); - private readonly failedDecryptionsNotified = new Set(); - private cryptoRetrySignalsBound = false; - - constructor( - private readonly deps: { - client: MatrixDecryptIfNeededClient; - toRaw: (event: MatrixEvent) => TRawEvent; - emitDecryptedEvent: (roomId: string, event: TRawEvent) => void; - emitMessage: (roomId: string, event: TRawEvent) => void; - emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void; - }, - ) {} - - shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean { - if (!eventId) { - return true; - } - const key = `${roomId}|${eventId}`; - const createdAt = this.decryptedMessageDedupe.get(key); - if (createdAt === undefined) { - return true; - } - this.decryptedMessageDedupe.delete(key); - return false; - } - - attachEncryptedEvent(event: MatrixEvent, roomId: string): void { - if (this.trackedEncryptedEvents.has(event)) { - return; - } - this.trackedEncryptedEvents.add(event); - event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => { - this.handleEncryptedEventDecrypted({ - roomId, - encryptedEvent: event, - decryptedEvent, - err, - }); - }); - } - - retryPendingNow(reason: string): void { - const pending = Array.from(this.decryptRetries.entries()); - if (pending.length === 0) { - return; - } - LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`); - for (const [retryKey, state] of pending) { - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - if (state.inFlight) { - continue; - } - this.runDecryptRetry(retryKey).catch(noop); - } - } - - bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void { - if (!crypto || this.cryptoRetrySignalsBound) { - return; - } - this.cryptoRetrySignalsBound = true; - - const trigger = (reason: string): void => { - this.retryPendingNow(reason); - }; - - crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => { - trigger("crypto.keyBackupDecryptionKeyCached"); - }); - crypto.on(CryptoEvent.RehydrationCompleted, () => { - trigger("dehydration.RehydrationCompleted"); - }); - crypto.on(CryptoEvent.DevicesUpdated, () => { - trigger("crypto.devicesUpdated"); - }); - crypto.on(CryptoEvent.KeysChanged, () => { - trigger("crossSigning.keysChanged"); - }); - } - - stop(): void { - for (const retryKey of this.decryptRetries.keys()) { - this.clearDecryptRetry(retryKey); - } - } - - private handleEncryptedEventDecrypted(params: { - roomId: string; - encryptedEvent: MatrixEvent; - decryptedEvent: MatrixEvent; - err?: Error; - }): void { - const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId; - const decryptedRaw = this.deps.toRaw(params.decryptedEvent); - const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || ""; - const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId); - - if (params.err) { - this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err); - this.scheduleDecryptRetry({ - event: params.encryptedEvent, - roomId: decryptedRoomId, - eventId: retryEventId, - }); - return; - } - - if (isDecryptionFailure(params.decryptedEvent)) { - this.emitFailedDecryptionOnce( - retryKey, - decryptedRoomId, - decryptedRaw, - new Error("Matrix event failed to decrypt"), - ); - this.scheduleDecryptRetry({ - event: params.encryptedEvent, - roomId: decryptedRoomId, - eventId: retryEventId, - }); - return; - } - - if (retryKey) { - this.clearDecryptRetry(retryKey); - } - this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id); - this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw); - this.deps.emitMessage(decryptedRoomId, decryptedRaw); - } - - private emitFailedDecryptionOnce( - retryKey: string | null, - roomId: string, - event: TRawEvent, - error: Error, - ): void { - if (retryKey) { - if (this.failedDecryptionsNotified.has(retryKey)) { - return; - } - this.failedDecryptionsNotified.add(retryKey); - } - this.deps.emitFailedDecryption(roomId, event, error); - } - - private scheduleDecryptRetry(params: { - event: MatrixEvent; - roomId: string; - eventId: string; - }): void { - const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId); - if (!retryKey) { - return; - } - const existing = this.decryptRetries.get(retryKey); - if (existing?.timer || existing?.inFlight) { - return; - } - const attempts = (existing?.attempts ?? 0) + 1; - if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) { - this.clearDecryptRetry(retryKey); - LogService.debug( - "MatrixClientLite", - `Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`, - ); - return; - } - const delayMs = Math.min( - MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), - MATRIX_DECRYPT_RETRY_MAX_DELAY_MS, - ); - const next: MatrixDecryptRetryState = { - event: params.event, - roomId: params.roomId, - eventId: params.eventId, - attempts, - inFlight: false, - timer: null, - }; - next.timer = setTimeout(() => { - this.runDecryptRetry(retryKey).catch(noop); - }, delayMs); - this.decryptRetries.set(retryKey, next); - } - - private async runDecryptRetry(retryKey: string): Promise { - const state = this.decryptRetries.get(retryKey); - if (!state || state.inFlight) { - return; - } - - state.inFlight = true; - state.timer = null; - const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; - if (!canDecrypt) { - this.clearDecryptRetry(retryKey); - return; - } - - try { - await this.deps.client.decryptEventIfNeeded?.(state.event, { - isRetry: true, - }); - } catch { - // Retry with backoff until we hit the configured retry cap. - } finally { - state.inFlight = false; - } - - if (isDecryptionFailure(state.event)) { - this.scheduleDecryptRetry(state); - return; - } - - this.clearDecryptRetry(retryKey); - } - - private clearDecryptRetry(retryKey: string): void { - const state = this.decryptRetries.get(retryKey); - if (state?.timer) { - clearTimeout(state.timer); - } - this.decryptRetries.delete(retryKey); - this.failedDecryptionsNotified.delete(retryKey); - } - - private rememberDecryptedMessage(roomId: string, eventId: string): void { - if (!eventId) { - return; - } - const now = Date.now(); - this.pruneDecryptedMessageDedupe(now); - this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now); - } - - private pruneDecryptedMessageDedupe(now: number): void { - const ttlMs = 30_000; - for (const [key, createdAt] of this.decryptedMessageDedupe) { - if (now - createdAt > ttlMs) { - this.decryptedMessageDedupe.delete(key); - } - } - const maxEntries = 2048; - while (this.decryptedMessageDedupe.size > maxEntries) { - const oldest = this.decryptedMessageDedupe.keys().next().value; - if (oldest === undefined) { - break; - } - this.decryptedMessageDedupe.delete(oldest); - } - } -} diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts deleted file mode 100644 index b3fff8fc52b..00000000000 --- a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { MatrixEvent } from "matrix-js-sdk"; -import { describe, expect, it } from "vitest"; -import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js"; - -describe("event-helpers", () => { - it("parses mxc URIs", () => { - expect(parseMxc("mxc://server.example/media-id")).toEqual({ - server: "server.example", - mediaId: "media-id", - }); - expect(parseMxc("not-mxc")).toBeNull(); - }); - - it("builds HTTP errors from JSON and plain text payloads", () => { - const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" })); - expect(fromJson.message).toBe("forbidden"); - expect(fromJson.statusCode).toBe(403); - - const fromText = buildHttpError(500, "internal failure"); - expect(fromText.message).toBe("internal failure"); - expect(fromText.statusCode).toBe(500); - }); - - it("serializes Matrix events and resolves state key from available sources", () => { - const viaGetter = { - getId: () => "$1", - getSender: () => "@alice:example.org", - getType: () => "m.room.member", - getTs: () => 1000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({ age: 1 }), - getStateKey: () => "@alice:example.org", - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org"); - - const viaWire = { - getId: () => "$2", - getSender: () => "@bob:example.org", - getType: () => "m.room.member", - getTs: () => 2000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({}), - getStateKey: () => undefined, - getWireContent: () => ({ state_key: "@bob:example.org" }), - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org"); - - const viaRaw = { - getId: () => "$3", - getSender: () => "@carol:example.org", - getType: () => "m.room.member", - getTs: () => 3000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({}), - getStateKey: () => undefined, - event: { state_key: "@carol:example.org" }, - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org"); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.ts b/extensions/matrix/src/matrix/sdk/event-helpers.ts deleted file mode 100644 index b9e62f3a944..00000000000 --- a/extensions/matrix/src/matrix/sdk/event-helpers.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { MatrixEvent } from "matrix-js-sdk"; -import type { MatrixRawEvent } from "./types.js"; - -export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent { - const unsigned = (event.getUnsigned?.() ?? {}) as { - age?: number; - redacted_because?: unknown; - }; - const raw: MatrixRawEvent = { - event_id: event.getId() ?? "", - sender: event.getSender() ?? "", - type: event.getType() ?? "", - origin_server_ts: event.getTs() ?? 0, - content: ((event.getContent?.() ?? {}) as Record) || {}, - unsigned, - }; - const stateKey = resolveMatrixStateKey(event); - if (typeof stateKey === "string") { - raw.state_key = stateKey; - } - return raw; -} - -export function parseMxc(url: string): { server: string; mediaId: string } | null { - const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); - if (!match) { - return null; - } - return { - server: match[1], - mediaId: match[2], - }; -} - -export function buildHttpError( - statusCode: number, - bodyText: string, -): Error & { statusCode: number } { - let message = `Matrix HTTP ${statusCode}`; - if (bodyText.trim()) { - try { - const parsed = JSON.parse(bodyText) as { error?: string }; - if (typeof parsed.error === "string" && parsed.error.trim()) { - message = parsed.error.trim(); - } else { - message = bodyText.slice(0, 500); - } - } catch { - message = bodyText.slice(0, 500); - } - } - return Object.assign(new Error(message), { statusCode }); -} - -function resolveMatrixStateKey(event: MatrixEvent): string | undefined { - const direct = event.getStateKey?.(); - if (typeof direct === "string") { - return direct; - } - const wireContent = ( - event as { getWireContent?: () => { state_key?: unknown } } - ).getWireContent?.(); - if (wireContent && typeof wireContent.state_key === "string") { - return wireContent.state_key; - } - const rawEvent = (event as { event?: { state_key?: unknown } }).event; - if (rawEvent && typeof rawEvent.state_key === "string") { - return rawEvent.state_key; - } - return undefined; -} diff --git a/extensions/matrix/src/matrix/sdk/http-client.test.ts b/extensions/matrix/src/matrix/sdk/http-client.test.ts deleted file mode 100644 index f2b7ed59ee6..00000000000 --- a/extensions/matrix/src/matrix/sdk/http-client.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { performMatrixRequestMock } = vi.hoisted(() => ({ - performMatrixRequestMock: vi.fn(), -})); - -vi.mock("./transport.js", () => ({ - performMatrixRequest: performMatrixRequestMock, -})); - -import { MatrixAuthedHttpClient } from "./http-client.js"; - -describe("MatrixAuthedHttpClient", () => { - beforeEach(() => { - performMatrixRequestMock.mockReset(); - }); - - it("parses JSON responses and forwards absolute-endpoint opt-in", async () => { - performMatrixRequestMock.mockResolvedValue({ - response: new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - text: '{"ok":true}', - buffer: Buffer.from('{"ok":true}', "utf8"), - }); - - const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); - const result = await client.requestJson({ - method: "GET", - endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", - timeoutMs: 5000, - allowAbsoluteEndpoint: true, - }); - - expect(result).toEqual({ ok: true }); - expect(performMatrixRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "GET", - endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", - allowAbsoluteEndpoint: true, - }), - ); - }); - - it("returns plain text when response is not JSON", async () => { - performMatrixRequestMock.mockResolvedValue({ - response: new Response("pong", { - status: 200, - headers: { "content-type": "text/plain" }, - }), - text: "pong", - buffer: Buffer.from("pong", "utf8"), - }); - - const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); - const result = await client.requestJson({ - method: "GET", - endpoint: "/_matrix/client/v3/ping", - timeoutMs: 5000, - }); - - expect(result).toBe("pong"); - }); - - it("returns raw buffers for media requests", async () => { - const payload = Buffer.from([1, 2, 3, 4]); - performMatrixRequestMock.mockResolvedValue({ - response: new Response(payload, { status: 200 }), - text: payload.toString("utf8"), - buffer: payload, - }); - - const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); - const result = await client.requestRaw({ - method: "GET", - endpoint: "/_matrix/media/v3/download/example/id", - timeoutMs: 5000, - }); - - expect(result).toEqual(payload); - }); - - it("raises HTTP errors with status code metadata", async () => { - performMatrixRequestMock.mockResolvedValue({ - response: new Response(JSON.stringify({ error: "forbidden" }), { - status: 403, - headers: { "content-type": "application/json" }, - }), - text: JSON.stringify({ error: "forbidden" }), - buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"), - }); - - const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); - await expect( - client.requestJson({ - method: "GET", - endpoint: "/_matrix/client/v3/rooms", - timeoutMs: 5000, - }), - ).rejects.toMatchObject({ - message: "forbidden", - statusCode: 403, - }); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/http-client.ts b/extensions/matrix/src/matrix/sdk/http-client.ts deleted file mode 100644 index d047bcc9c77..00000000000 --- a/extensions/matrix/src/matrix/sdk/http-client.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { buildHttpError } from "./event-helpers.js"; -import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js"; - -export class MatrixAuthedHttpClient { - constructor( - private readonly homeserver: string, - private readonly accessToken: string, - ) {} - - async requestJson(params: { - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - body?: unknown; - timeoutMs: number; - allowAbsoluteEndpoint?: boolean; - }): Promise { - const { response, text } = await performMatrixRequest({ - homeserver: this.homeserver, - accessToken: this.accessToken, - method: params.method, - endpoint: params.endpoint, - qs: params.qs, - body: params.body, - timeoutMs: params.timeoutMs, - allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, - }); - if (!response.ok) { - throw buildHttpError(response.status, text); - } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - if (!text.trim()) { - return {}; - } - return JSON.parse(text); - } - return text; - } - - async requestRaw(params: { - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - timeoutMs: number; - allowAbsoluteEndpoint?: boolean; - }): Promise { - const { response, buffer } = await performMatrixRequest({ - homeserver: this.homeserver, - accessToken: this.accessToken, - method: params.method, - endpoint: params.endpoint, - qs: params.qs, - timeoutMs: params.timeoutMs, - raw: true, - allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, - }); - if (!response.ok) { - throw buildHttpError(response.status, buffer.toString("utf8")); - } - return buffer; - } -} diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.ts deleted file mode 100644 index c9a09b6030f..00000000000 --- a/extensions/matrix/src/matrix/sdk/idb-persistence.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; -import fs from "node:fs"; -import path from "node:path"; -import { LogService } from "./logger.js"; - -type IdbStoreSnapshot = { - name: string; - keyPath: IDBObjectStoreParameters["keyPath"]; - autoIncrement: boolean; - indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[]; - records: { key: IDBValidKey; value: unknown }[]; -}; - -type IdbDatabaseSnapshot = { - name: string; - version: number; - stores: IdbStoreSnapshot[]; -}; - -function idbReq(req: IDBRequest): Promise { - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -async function dumpIndexedDatabases(databasePrefix?: string): Promise { - const idb = fakeIndexedDB; - const dbList = await idb.databases(); - const snapshot: IdbDatabaseSnapshot[] = []; - const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null; - - for (const { name, version } of dbList) { - if (!name || !version) continue; - if (expectedPrefix && !name.startsWith(expectedPrefix)) continue; - const db: IDBDatabase = await new Promise((resolve, reject) => { - const r = idb.open(name, version); - r.onsuccess = () => resolve(r.result); - r.onerror = () => reject(r.error); - }); - - const stores: IdbStoreSnapshot[] = []; - for (const storeName of db.objectStoreNames) { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const storeInfo: IdbStoreSnapshot = { - name: storeName, - keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"], - autoIncrement: store.autoIncrement, - indexes: [], - records: [], - }; - for (const idxName of store.indexNames) { - const idx = store.index(idxName); - storeInfo.indexes.push({ - name: idxName, - keyPath: idx.keyPath as string | string[], - multiEntry: idx.multiEntry, - unique: idx.unique, - }); - } - const keys = await idbReq(store.getAllKeys()); - const values = await idbReq(store.getAll()); - storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] })); - stores.push(storeInfo); - } - snapshot.push({ name, version, stores }); - db.close(); - } - return snapshot; -} - -async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise { - const idb = fakeIndexedDB; - for (const dbSnap of snapshot) { - await new Promise((resolve, reject) => { - const r = idb.open(dbSnap.name, dbSnap.version); - r.onupgradeneeded = () => { - const db = r.result; - for (const storeSnap of dbSnap.stores) { - const opts: IDBObjectStoreParameters = {}; - if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath; - if (storeSnap.autoIncrement) opts.autoIncrement = true; - const store = db.createObjectStore(storeSnap.name, opts); - for (const idx of storeSnap.indexes) { - store.createIndex(idx.name, idx.keyPath, { - unique: idx.unique, - multiEntry: idx.multiEntry, - }); - } - } - }; - r.onsuccess = async () => { - try { - const db = r.result; - for (const storeSnap of dbSnap.stores) { - if (storeSnap.records.length === 0) continue; - const tx = db.transaction(storeSnap.name, "readwrite"); - const store = tx.objectStore(storeSnap.name); - for (const rec of storeSnap.records) { - if (storeSnap.keyPath !== null) { - store.put(rec.value); - } else { - store.put(rec.value, rec.key); - } - } - await new Promise((res) => { - tx.oncomplete = () => res(); - }); - } - db.close(); - resolve(); - } catch (err) { - reject(err); - } - }; - r.onerror = () => reject(r.error); - }); - } -} - -function resolveDefaultIdbSnapshotPath(): string { - const stateDir = - process.env.OPENCLAW_STATE_DIR || - process.env.MOLTBOT_STATE_DIR || - path.join(process.env.HOME || "/tmp", ".openclaw"); - return path.join(stateDir, "credentials", "matrix", "crypto-idb-snapshot.json"); -} - -export async function restoreIdbFromDisk(snapshotPath?: string): Promise { - const resolvedPath = snapshotPath ?? resolveDefaultIdbSnapshotPath(); - try { - const data = fs.readFileSync(resolvedPath, "utf8"); - const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data); - if (!Array.isArray(snapshot) || snapshot.length === 0) return false; - await restoreIndexedDatabases(snapshot); - LogService.info( - "IdbPersistence", - `Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`, - ); - return true; - } catch { - return false; - } -} - -export async function persistIdbToDisk(params?: { - snapshotPath?: string; - databasePrefix?: string; -}): Promise { - const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath(); - try { - const snapshot = await dumpIndexedDatabases(params?.databasePrefix); - if (snapshot.length === 0) return; - fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); - fs.writeFileSync(snapshotPath, JSON.stringify(snapshot)); - LogService.debug( - "IdbPersistence", - `Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`, - ); - } catch (err) { - LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err); - } -} diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts deleted file mode 100644 index 866f959e912..00000000000 --- a/extensions/matrix/src/matrix/sdk/logger.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type Logger = { - trace: (module: string, ...messageOrObject: unknown[]) => void; - debug: (module: string, ...messageOrObject: unknown[]) => void; - info: (module: string, ...messageOrObject: unknown[]) => void; - warn: (module: string, ...messageOrObject: unknown[]) => void; - error: (module: string, ...messageOrObject: unknown[]) => void; -}; - -export function noop(): void { - // no-op -} - -export class ConsoleLogger { - trace(module: string, ...messageOrObject: unknown[]): void { - console.debug(`[${module}]`, ...messageOrObject); - } - - debug(module: string, ...messageOrObject: unknown[]): void { - console.debug(`[${module}]`, ...messageOrObject); - } - - info(module: string, ...messageOrObject: unknown[]): void { - console.info(`[${module}]`, ...messageOrObject); - } - - warn(module: string, ...messageOrObject: unknown[]): void { - console.warn(`[${module}]`, ...messageOrObject); - } - - error(module: string, ...messageOrObject: unknown[]): void { - console.error(`[${module}]`, ...messageOrObject); - } -} - -const defaultLogger = new ConsoleLogger(); -let activeLogger: Logger = defaultLogger; - -export const LogService = { - setLogger(logger: Logger): void { - activeLogger = logger; - }, - trace(module: string, ...messageOrObject: unknown[]): void { - activeLogger.trace(module, ...messageOrObject); - }, - debug(module: string, ...messageOrObject: unknown[]): void { - activeLogger.debug(module, ...messageOrObject); - }, - info(module: string, ...messageOrObject: unknown[]): void { - activeLogger.info(module, ...messageOrObject); - }, - warn(module: string, ...messageOrObject: unknown[]): void { - activeLogger.warn(module, ...messageOrObject); - }, - error(module: string, ...messageOrObject: unknown[]): void { - activeLogger.error(module, ...messageOrObject); - }, -}; diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts deleted file mode 100644 index 91ff58120ef..00000000000 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixCryptoBootstrapApi } from "./types.js"; -import { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; - -function createTempRecoveryKeyPath(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-")); - return path.join(dir, "recovery-key.json"); -} - -describe("MatrixRecoveryKeyStore", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("loads a stored recovery key for requested secret-storage keys", async () => { - const recoveryKeyPath = createTempRecoveryKeyPath(); - fs.writeFileSync( - recoveryKeyPath, - JSON.stringify({ - version: 1, - createdAt: new Date().toISOString(), - keyId: "SSSS", - privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), - }), - "utf8", - ); - - const store = new MatrixRecoveryKeyStore(recoveryKeyPath); - const callbacks = store.buildCryptoCallbacks(); - const resolved = await callbacks.getSecretStorageKey?.( - { keys: { SSSS: { name: "test" } } }, - "m.cross_signing.master", - ); - - expect(resolved?.[0]).toBe("SSSS"); - expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); - }); - - it("persists cached secret-storage keys with secure file permissions", () => { - const recoveryKeyPath = createTempRecoveryKeyPath(); - const store = new MatrixRecoveryKeyStore(recoveryKeyPath); - const callbacks = store.buildCryptoCallbacks(); - - callbacks.cacheSecretStorageKey?.( - "KEY123", - { - name: "openclaw", - }, - new Uint8Array([9, 8, 7]), - ); - - const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { - keyId?: string; - privateKeyBase64?: string; - }; - expect(saved.keyId).toBe("KEY123"); - expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64")); - - const mode = fs.statSync(recoveryKeyPath).mode & 0o777; - expect(mode).toBe(0o600); - }); - - it("creates and persists a recovery key when secret storage is missing", async () => { - const recoveryKeyPath = createTempRecoveryKeyPath(); - const store = new MatrixRecoveryKeyStore(recoveryKeyPath); - const generated = { - keyId: "GENERATED", - keyInfo: { name: "generated" }, - privateKey: new Uint8Array([5, 6, 7, 8]), - encodedPrivateKey: "encoded-generated-key", - }; - const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); - const bootstrapSecretStorage = vi.fn( - async (opts?: { createSecretStorageKey?: () => Promise }) => { - await opts?.createSecretStorageKey?.(); - }, - ); - const crypto = { - on: vi.fn(), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage, - createRecoveryKeyFromPassphrase, - getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })), - requestOwnUserVerification: vi.fn(async () => null), - } as unknown as MatrixCryptoBootstrapApi; - - await store.bootstrapSecretStorageWithRecoveryKey(crypto); - - expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); - expect(bootstrapSecretStorage).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewSecretStorage: true, - }), - ); - expect(store.getRecoveryKeySummary()).toMatchObject({ - keyId: "GENERATED", - encodedPrivateKey: "encoded-generated-key", - }); - }); - - it("rebinds stored recovery key to server default key id when it changes", async () => { - const recoveryKeyPath = createTempRecoveryKeyPath(); - fs.writeFileSync( - recoveryKeyPath, - JSON.stringify({ - version: 1, - createdAt: new Date().toISOString(), - keyId: "OLD", - privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), - }), - "utf8", - ); - const store = new MatrixRecoveryKeyStore(recoveryKeyPath); - - const bootstrapSecretStorage = vi.fn(async () => {}); - const createRecoveryKeyFromPassphrase = vi.fn(async () => { - throw new Error("should not be called"); - }); - const crypto = { - on: vi.fn(), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage, - createRecoveryKeyFromPassphrase, - getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), - requestOwnUserVerification: vi.fn(async () => null), - } as unknown as MatrixCryptoBootstrapApi; - - await store.bootstrapSecretStorageWithRecoveryKey(crypto); - - expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled(); - expect(store.getRecoveryKeySummary()).toMatchObject({ - keyId: "NEW", - }); - }); - - it("recreates secret storage when default key exists but is not usable locally", async () => { - const recoveryKeyPath = createTempRecoveryKeyPath(); - const store = new MatrixRecoveryKeyStore(recoveryKeyPath); - const generated = { - keyId: "RECOVERED", - keyInfo: { name: "recovered" }, - privateKey: new Uint8Array([1, 1, 2, 3]), - encodedPrivateKey: "encoded-recovered-key", - }; - const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); - const bootstrapSecretStorage = vi.fn( - async (opts?: { createSecretStorageKey?: () => Promise }) => { - await opts?.createSecretStorageKey?.(); - }, - ); - const crypto = { - on: vi.fn(), - bootstrapCrossSigning: vi.fn(async () => {}), - bootstrapSecretStorage, - createRecoveryKeyFromPassphrase, - getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })), - requestOwnUserVerification: vi.fn(async () => null), - } as unknown as MatrixCryptoBootstrapApi; - - await store.bootstrapSecretStorageWithRecoveryKey(crypto); - - expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); - expect(bootstrapSecretStorage).toHaveBeenCalledWith( - expect.objectContaining({ - setupNewSecretStorage: true, - }), - ); - expect(store.getRecoveryKeySummary()).toMatchObject({ - keyId: "RECOVERED", - encodedPrivateKey: "encoded-recovered-key", - }); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts deleted file mode 100644 index 460218c2df0..00000000000 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ /dev/null @@ -1,253 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { - MatrixCryptoBootstrapApi, - MatrixCryptoCallbacks, - MatrixGeneratedSecretStorageKey, - MatrixSecretStorageStatus, - MatrixStoredRecoveryKey, -} from "./types.js"; -import { LogService } from "./logger.js"; - -export class MatrixRecoveryKeyStore { - private readonly secretStorageKeyCache = new Map< - string, - { key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] } - >(); - - constructor(private readonly recoveryKeyPath?: string) {} - - buildCryptoCallbacks(): MatrixCryptoCallbacks { - return { - getSecretStorageKey: async ({ keys }) => { - const requestedKeyIds = Object.keys(keys ?? {}); - if (requestedKeyIds.length === 0) { - return null; - } - - for (const keyId of requestedKeyIds) { - const cached = this.secretStorageKeyCache.get(keyId); - if (cached) { - return [keyId, new Uint8Array(cached.key)]; - } - } - - const stored = this.loadStoredRecoveryKey(); - if (!stored || !stored.privateKeyBase64) { - return null; - } - const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64")); - if (privateKey.length === 0) { - return null; - } - - if (stored.keyId && requestedKeyIds.includes(stored.keyId)) { - this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo); - return [stored.keyId, privateKey]; - } - - const firstRequestedKeyId = requestedKeyIds[0]; - if (!firstRequestedKeyId) { - return null; - } - this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo); - return [firstRequestedKeyId, privateKey]; - }, - cacheSecretStorageKey: (keyId, keyInfo, key) => { - const privateKey = new Uint8Array(key); - const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = { - passphrase: keyInfo?.passphrase, - name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined, - }; - this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo); - - const stored = this.loadStoredRecoveryKey(); - this.saveRecoveryKeyToDisk({ - keyId, - keyInfo: normalizedKeyInfo, - privateKey, - encodedPrivateKey: stored?.encodedPrivateKey, - }); - }, - }; - } - - getRecoveryKeySummary(): { - encodedPrivateKey?: string; - keyId?: string | null; - createdAt?: string; - } | null { - const stored = this.loadStoredRecoveryKey(); - if (!stored) { - return null; - } - return { - encodedPrivateKey: stored.encodedPrivateKey, - keyId: stored.keyId, - createdAt: stored.createdAt, - }; - } - - async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): Promise { - let status: MatrixSecretStorageStatus | null = null; - if (typeof crypto.getSecretStorageStatus === "function") { - try { - status = await crypto.getSecretStorageStatus(); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err); - } - } - - const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId); - const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some( - (valid) => valid === false, - ); - let generatedRecoveryKey = false; - const storedRecovery = this.loadStoredRecoveryKey(); - let recoveryKey = storedRecovery - ? { - keyInfo: storedRecovery.keyInfo, - privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")), - encodedPrivateKey: storedRecovery.encodedPrivateKey, - } - : null; - - if (recoveryKey && status?.defaultKeyId) { - const defaultKeyId = status.defaultKeyId; - this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo); - if (storedRecovery?.keyId !== defaultKeyId) { - this.saveRecoveryKeyToDisk({ - keyId: defaultKeyId, - keyInfo: recoveryKey.keyInfo, - privateKey: recoveryKey.privateKey, - encodedPrivateKey: recoveryKey.encodedPrivateKey, - }); - } - } - - const ensureRecoveryKey = async (): Promise => { - if (recoveryKey) { - return recoveryKey; - } - if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") { - throw new Error( - "Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)", - ); - } - recoveryKey = await crypto.createRecoveryKeyFromPassphrase(); - this.saveRecoveryKeyToDisk(recoveryKey); - generatedRecoveryKey = true; - return recoveryKey; - }; - - const shouldRecreateSecretStorage = - !hasDefaultSecretStorageKey || - (!recoveryKey && status?.ready === false) || - hasKnownInvalidSecrets; - - if (hasKnownInvalidSecrets) { - // Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key. - recoveryKey = null; - } - - const secretStorageOptions: { - createSecretStorageKey?: () => Promise; - setupNewSecretStorage?: boolean; - setupNewKeyBackup?: boolean; - } = { - setupNewKeyBackup: false, - }; - - if (shouldRecreateSecretStorage) { - secretStorageOptions.setupNewSecretStorage = true; - secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; - } - - await crypto.bootstrapSecretStorage(secretStorageOptions); - - if (generatedRecoveryKey && this.recoveryKeyPath) { - LogService.warn( - "MatrixClientLite", - `Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`, - ); - } - } - - private rememberSecretStorageKey( - keyId: string, - key: Uint8Array, - keyInfo?: MatrixStoredRecoveryKey["keyInfo"], - ): void { - if (!keyId.trim()) { - return; - } - this.secretStorageKeyCache.set(keyId, { - key: new Uint8Array(key), - keyInfo, - }); - } - - private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null { - if (!this.recoveryKeyPath) { - return null; - } - try { - if (!fs.existsSync(this.recoveryKeyPath)) { - return null; - } - const raw = fs.readFileSync(this.recoveryKeyPath, "utf8"); - const parsed = JSON.parse(raw) as Partial; - if ( - parsed.version !== 1 || - typeof parsed.createdAt !== "string" || - typeof parsed.privateKeyBase64 !== "string" || - !parsed.privateKeyBase64.trim() - ) { - return null; - } - return { - version: 1, - createdAt: parsed.createdAt, - keyId: typeof parsed.keyId === "string" ? parsed.keyId : null, - encodedPrivateKey: - typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined, - privateKeyBase64: parsed.privateKeyBase64, - keyInfo: - parsed.keyInfo && typeof parsed.keyInfo === "object" - ? { - passphrase: parsed.keyInfo.passphrase, - name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined, - } - : undefined, - }; - } catch { - return null; - } - } - - private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void { - if (!this.recoveryKeyPath) { - return; - } - try { - const payload: MatrixStoredRecoveryKey = { - version: 1, - createdAt: new Date().toISOString(), - keyId: typeof params.keyId === "string" ? params.keyId : null, - encodedPrivateKey: params.encodedPrivateKey, - privateKeyBase64: Buffer.from(params.privateKey).toString("base64"), - keyInfo: params.keyInfo - ? { - passphrase: params.keyInfo.passphrase, - name: params.keyInfo.name, - } - : undefined, - }; - fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true }); - fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8"); - fs.chmodSync(this.recoveryKeyPath, 0o600); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err); - } - } -} diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts deleted file mode 100644 index 8b2a7f8899d..00000000000 --- a/extensions/matrix/src/matrix/sdk/transport.ts +++ /dev/null @@ -1,171 +0,0 @@ -export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; - -type QueryValue = - | string - | number - | boolean - | null - | undefined - | Array; - -export type QueryParams = Record | null | undefined; - -function normalizeEndpoint(endpoint: string): string { - if (!endpoint) { - return "/"; - } - return endpoint.startsWith("/") ? endpoint : `/${endpoint}`; -} - -function applyQuery(url: URL, qs: QueryParams): void { - if (!qs) { - return; - } - for (const [key, rawValue] of Object.entries(qs)) { - if (rawValue === undefined || rawValue === null) { - continue; - } - if (Array.isArray(rawValue)) { - for (const item of rawValue) { - if (item === undefined || item === null) { - continue; - } - url.searchParams.append(key, String(item)); - } - continue; - } - url.searchParams.set(key, String(rawValue)); - } -} - -function isRedirectStatus(statusCode: number): boolean { - return statusCode >= 300 && statusCode < 400; -} - -async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise { - let currentUrl = new URL(url.toString()); - let method = (init.method ?? "GET").toUpperCase(); - let body = init.body; - let headers = new Headers(init.headers ?? {}); - const maxRedirects = 5; - - for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { - const response = await fetch(currentUrl, { - ...init, - method, - body, - headers, - redirect: "manual", - }); - - if (!isRedirectStatus(response.status)) { - return response; - } - - const location = response.headers.get("location"); - if (!location) { - throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`); - } - - const nextUrl = new URL(location, currentUrl); - if (nextUrl.protocol !== currentUrl.protocol) { - throw new Error( - `Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`, - ); - } - - if (nextUrl.origin !== currentUrl.origin) { - headers = new Headers(headers); - headers.delete("authorization"); - } - - if ( - response.status === 303 || - ((response.status === 301 || response.status === 302) && - method !== "GET" && - method !== "HEAD") - ) { - method = "GET"; - body = undefined; - headers = new Headers(headers); - headers.delete("content-type"); - headers.delete("content-length"); - } - - currentUrl = nextUrl; - } - - throw new Error(`Too many redirects while requesting ${url.toString()}`); -} - -export async function performMatrixRequest(params: { - homeserver: string; - accessToken: string; - method: HttpMethod; - endpoint: string; - qs?: QueryParams; - body?: unknown; - timeoutMs: number; - raw?: boolean; - allowAbsoluteEndpoint?: boolean; -}): Promise<{ response: Response; text: string; buffer: Buffer }> { - const isAbsoluteEndpoint = - params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://"); - if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) { - throw new Error( - `Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`, - ); - } - - const baseUrl = isAbsoluteEndpoint - ? new URL(params.endpoint) - : new URL(normalizeEndpoint(params.endpoint), params.homeserver); - applyQuery(baseUrl, params.qs); - - const headers = new Headers(); - headers.set("Accept", params.raw ? "*/*" : "application/json"); - if (params.accessToken) { - headers.set("Authorization", `Bearer ${params.accessToken}`); - } - - let body: BodyInit | undefined; - if (params.body !== undefined) { - if ( - params.body instanceof Uint8Array || - params.body instanceof ArrayBuffer || - typeof params.body === "string" - ) { - body = params.body as BodyInit; - } else { - headers.set("Content-Type", "application/json"); - body = JSON.stringify(params.body); - } - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); - try { - const response = await fetchWithSafeRedirects(baseUrl, { - method: params.method, - headers, - body, - signal: controller.signal, - }); - if (params.raw) { - const bytes = Buffer.from(await response.arrayBuffer()); - return { - response, - text: bytes.toString("utf8"), - buffer: bytes, - }; - } - const text = await response.text(); - return { - response, - text, - buffer: Buffer.from(text, "utf8"), - }; - } finally { - clearTimeout(timeoutId); - } -} diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts deleted file mode 100644 index da5448f3aef..00000000000 --- a/extensions/matrix/src/matrix/sdk/types.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { MatrixVerificationRequestLike } from "./verification-manager.js"; - -export type MatrixRawEvent = { - event_id: string; - sender: string; - type: string; - origin_server_ts: number; - content: Record; - unsigned?: { - age?: number; - redacted_because?: unknown; - }; - state_key?: string; -}; - -export type MatrixClientEventMap = { - "room.event": [roomId: string, event: MatrixRawEvent]; - "room.message": [roomId: string, event: MatrixRawEvent]; - "room.encrypted_event": [roomId: string, event: MatrixRawEvent]; - "room.decrypted_event": [roomId: string, event: MatrixRawEvent]; - "room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error]; - "room.invite": [roomId: string, event: MatrixRawEvent]; - "room.join": [roomId: string, event: MatrixRawEvent]; -}; - -export type EncryptedFile = { - url: string; - key: { - kty: string; - key_ops: string[]; - alg: string; - k: string; - ext: boolean; - }; - iv: string; - hashes: Record; - v: string; -}; - -export type FileWithThumbnailInfo = { - size?: number; - mimetype?: string; - thumbnail_url?: string; - thumbnail_info?: { - w?: number; - h?: number; - mimetype?: string; - size?: number; - }; -}; - -export type DimensionalFileInfo = FileWithThumbnailInfo & { - w?: number; - h?: number; -}; - -export type TimedFileInfo = FileWithThumbnailInfo & { - duration?: number; -}; - -export type VideoFileInfo = DimensionalFileInfo & - TimedFileInfo & { - duration?: number; - }; - -export type MessageEventContent = { - msgtype?: string; - body?: string; - format?: string; - formatted_body?: string; - filename?: string; - url?: string; - file?: EncryptedFile; - info?: Record; - "m.relates_to"?: Record; - "m.new_content"?: unknown; - "m.mentions"?: { - user_ids?: string[]; - room?: boolean; - }; - [key: string]: unknown; -}; - -export type TextualMessageEventContent = MessageEventContent & { - msgtype: string; - body: string; -}; - -export type LocationMessageEventContent = MessageEventContent & { - msgtype?: string; - geo_uri?: string; -}; - -export type MatrixSecretStorageStatus = { - ready: boolean; - defaultKeyId: string | null; - secretStorageKeyValidityMap?: Record; -}; - -export type MatrixGeneratedSecretStorageKey = { - keyId?: string | null; - keyInfo?: { - passphrase?: unknown; - name?: string; - }; - privateKey: Uint8Array; - encodedPrivateKey?: string; -}; - -export type MatrixDeviceVerificationStatusLike = { - isVerified?: () => boolean; - localVerified?: boolean; - crossSigningVerified?: boolean; - signedByOwner?: boolean; -}; - -export type MatrixSecretStorageKeyDescription = { - passphrase?: unknown; - name?: string; - [key: string]: unknown; -}; - -export type MatrixCryptoCallbacks = { - getSecretStorageKey?: ( - params: { keys: Record }, - name: string, - ) => Promise<[string, Uint8Array] | null>; - cacheSecretStorageKey?: ( - keyId: string, - keyInfo: MatrixSecretStorageKeyDescription, - key: Uint8Array, - ) => void; -}; - -export type MatrixStoredRecoveryKey = { - version: 1; - createdAt: string; - keyId?: string | null; - encodedPrivateKey?: string; - privateKeyBase64: string; - keyInfo?: { - passphrase?: unknown; - name?: string; - }; -}; - -export type MatrixAuthDict = Record; - -export type MatrixUiAuthCallback = ( - makeRequest: (authData: MatrixAuthDict | null) => Promise, -) => Promise; - -export type MatrixCryptoBootstrapApi = { - on: (eventName: string, listener: (...args: unknown[]) => void) => void; - bootstrapCrossSigning: (opts: { - setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?: MatrixUiAuthCallback; - }) => Promise; - bootstrapSecretStorage: (opts?: { - createSecretStorageKey?: () => Promise; - setupNewSecretStorage?: boolean; - setupNewKeyBackup?: boolean; - }) => Promise; - createRecoveryKeyFromPassphrase?: (password?: string) => Promise; - getSecretStorageStatus?: () => Promise; - requestOwnUserVerification: () => Promise; - requestDeviceVerification?: ( - userId: string, - deviceId: string, - ) => Promise; - requestVerificationDM?: ( - userId: string, - roomId: string, - ) => Promise; - getDeviceVerificationStatus?: ( - userId: string, - deviceId: string, - ) => Promise; - setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; - crossSignDevice?: (deviceId: string) => Promise; - isCrossSigningReady?: () => Promise; - userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; -}; diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts deleted file mode 100644 index 1efd5dc2389..00000000000 --- a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js"; -import { EventEmitter } from "node:events"; -import { describe, expect, it, vi } from "vitest"; -import { - MatrixVerificationManager, - type MatrixShowQrCodeCallbacks, - type MatrixShowSasCallbacks, - type MatrixVerificationRequestLike, - type MatrixVerifierLike, -} from "./verification-manager.js"; - -class MockVerifier extends EventEmitter implements MatrixVerifierLike { - constructor( - private readonly sasCallbacks: MatrixShowSasCallbacks | null, - private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null, - private readonly verifyImpl: () => Promise = async () => {}, - ) { - super(); - } - - verify(): Promise { - return this.verifyImpl(); - } - - cancel(_e: Error): void { - void _e; - } - - getShowSasCallbacks(): MatrixShowSasCallbacks | null { - return this.sasCallbacks; - } - - getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null { - return this.qrCallbacks; - } -} - -class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike { - transactionId?: string; - roomId?: string; - initiatedByMe = false; - otherUserId = "@alice:example.org"; - otherDeviceId?: string; - isSelfVerification = false; - phase = VerificationPhase.Requested; - pending = true; - accepting = false; - declining = false; - methods: string[] = ["m.sas.v1"]; - chosenMethod?: string | null; - cancellationCode?: string | null; - verifier?: MatrixVerifierLike; - - constructor(init?: Partial) { - super(); - Object.assign(this, init); - } - - accept = vi.fn(async () => { - this.phase = VerificationPhase.Ready; - }); - - cancel = vi.fn(async () => { - this.phase = VerificationPhase.Cancelled; - }); - - startVerification = vi.fn(async (_method: string) => { - if (!this.verifier) { - throw new Error("verifier not configured"); - } - this.phase = VerificationPhase.Started; - return this.verifier; - }); - - scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => { - if (!this.verifier) { - throw new Error("verifier not configured"); - } - this.phase = VerificationPhase.Started; - return this.verifier; - }); - - generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3])); -} - -describe("MatrixVerificationManager", () => { - it("reuses the same tracked id for repeated transaction IDs", () => { - const manager = new MatrixVerificationManager(); - const first = new MockVerificationRequest({ - transactionId: "txn-1", - phase: VerificationPhase.Requested, - }); - const second = new MockVerificationRequest({ - transactionId: "txn-1", - phase: VerificationPhase.Ready, - pending: false, - chosenMethod: "m.sas.v1", - }); - - const firstSummary = manager.trackVerificationRequest(first); - const secondSummary = manager.trackVerificationRequest(second); - - expect(secondSummary.id).toBe(firstSummary.id); - expect(secondSummary.phase).toBe(VerificationPhase.Ready); - expect(secondSummary.pending).toBe(false); - expect(secondSummary.chosenMethod).toBe("m.sas.v1"); - }); - - it("starts SAS verification and exposes SAS payload/callback flow", async () => { - const confirm = vi.fn(async () => {}); - const mismatch = vi.fn(); - const verifier = new MockVerifier( - { - sas: { - decimal: [111, 222, 333], - emoji: [ - ["cat", "cat"], - ["dog", "dog"], - ["fox", "fox"], - ], - }, - confirm, - mismatch, - cancel: vi.fn(), - }, - null, - async () => {}, - ); - const request = new MockVerificationRequest({ - transactionId: "txn-2", - verifier, - }); - const manager = new MatrixVerificationManager(); - const tracked = manager.trackVerificationRequest(request); - - const started = await manager.startVerification(tracked.id, "sas"); - expect(started.hasSas).toBe(true); - - const sas = manager.getVerificationSas(tracked.id); - expect(sas.decimal).toEqual([111, 222, 333]); - expect(sas.emoji?.length).toBe(3); - - await manager.confirmVerificationSas(tracked.id); - expect(confirm).toHaveBeenCalledTimes(1); - - manager.mismatchVerificationSas(tracked.id); - expect(mismatch).toHaveBeenCalledTimes(1); - }); - - it("prunes stale terminal sessions during list operations", () => { - const now = new Date("2026-02-08T15:00:00.000Z").getTime(); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy.mockReturnValue(now); - - const manager = new MatrixVerificationManager(); - manager.trackVerificationRequest( - new MockVerificationRequest({ - transactionId: "txn-old-done", - phase: VerificationPhase.Done, - pending: false, - }), - ); - - nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1); - const summaries = manager.listVerifications(); - - expect(summaries).toHaveLength(0); - nowSpy.mockRestore(); - }); -}); diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts deleted file mode 100644 index a9a378aa0b1..00000000000 --- a/extensions/matrix/src/matrix/sdk/verification-manager.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { - VerificationPhase, - VerificationRequestEvent, - VerifierEvent, -} from "matrix-js-sdk/lib/crypto-api/verification.js"; -import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; - -export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr"; - -export type MatrixVerificationSummary = { - id: string; - transactionId?: string; - roomId?: string; - otherUserId: string; - otherDeviceId?: string; - isSelfVerification: boolean; - initiatedByMe: boolean; - phase: number; - phaseName: string; - pending: boolean; - methods: string[]; - chosenMethod?: string | null; - canAccept: boolean; - hasSas: boolean; - hasReciprocateQr: boolean; - completed: boolean; - error?: string; - createdAt: string; - updatedAt: string; -}; - -export type MatrixShowSasCallbacks = { - sas: { - decimal?: [number, number, number]; - emoji?: Array<[string, string]>; - }; - confirm: () => Promise; - mismatch: () => void; - cancel: () => void; -}; - -export type MatrixShowQrCodeCallbacks = { - confirm: () => void; - cancel: () => void; -}; - -export type MatrixVerifierLike = { - verify: () => Promise; - cancel: (e: Error) => void; - getShowSasCallbacks: () => MatrixShowSasCallbacks | null; - getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null; - on: (eventName: string, listener: (...args: unknown[]) => void) => void; -}; - -export type MatrixVerificationRequestLike = { - transactionId?: string; - roomId?: string; - initiatedByMe: boolean; - otherUserId: string; - otherDeviceId?: string; - isSelfVerification: boolean; - phase: number; - pending: boolean; - accepting: boolean; - declining: boolean; - methods: string[]; - chosenMethod?: string | null; - cancellationCode?: string | null; - accept: () => Promise; - cancel: (params?: { reason?: string; code?: string }) => Promise; - startVerification: (method: string) => Promise; - scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise; - generateQRCode: () => Promise; - verifier?: MatrixVerifierLike; - on: (eventName: string, listener: (...args: unknown[]) => void) => void; -}; - -export type MatrixVerificationCryptoApi = { - requestOwnUserVerification: () => Promise; - requestDeviceVerification?: ( - userId: string, - deviceId: string, - ) => Promise; - requestVerificationDM?: ( - userId: string, - roomId: string, - ) => Promise; -}; - -type MatrixVerificationSession = { - id: string; - request: MatrixVerificationRequestLike; - createdAtMs: number; - updatedAtMs: number; - error?: string; - activeVerifier?: MatrixVerifierLike; - verifyPromise?: Promise; - verifyStarted: boolean; - sasCallbacks?: MatrixShowSasCallbacks; - reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; -}; - -const MAX_TRACKED_VERIFICATION_SESSIONS = 256; -const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000; - -export class MatrixVerificationManager { - private readonly verificationSessions = new Map(); - private verificationSessionCounter = 0; - private readonly trackedVerificationRequests = new WeakSet(); - private readonly trackedVerificationVerifiers = new WeakSet(); - - private pruneVerificationSessions(nowMs: number): void { - for (const [id, session] of this.verificationSessions) { - const phase = session.request.phase; - const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled; - if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) { - this.verificationSessions.delete(id); - } - } - - if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) { - return; - } - - const sortedByAge = Array.from(this.verificationSessions.entries()).sort( - (a, b) => a[1].updatedAtMs - b[1].updatedAtMs, - ); - const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS; - for (let i = 0; i < overflow; i += 1) { - const entry = sortedByAge[i]; - if (entry) { - this.verificationSessions.delete(entry[0]); - } - } - } - - private getVerificationPhaseName(phase: number): string { - switch (phase) { - case VerificationPhase.Unsent: - return "unsent"; - case VerificationPhase.Requested: - return "requested"; - case VerificationPhase.Ready: - return "ready"; - case VerificationPhase.Started: - return "started"; - case VerificationPhase.Cancelled: - return "cancelled"; - case VerificationPhase.Done: - return "done"; - default: - return `unknown(${phase})`; - } - } - - private touchVerificationSession(session: MatrixVerificationSession): void { - session.updatedAtMs = Date.now(); - } - - private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary { - const request = session.request; - const phase = request.phase; - const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining; - return { - id: session.id, - transactionId: request.transactionId, - roomId: request.roomId, - otherUserId: request.otherUserId, - otherDeviceId: request.otherDeviceId, - isSelfVerification: request.isSelfVerification, - initiatedByMe: request.initiatedByMe, - phase, - phaseName: this.getVerificationPhaseName(phase), - pending: request.pending, - methods: Array.isArray(request.methods) ? request.methods : [], - chosenMethod: request.chosenMethod ?? null, - canAccept, - hasSas: Boolean(session.sasCallbacks), - hasReciprocateQr: Boolean(session.reciprocateQrCallbacks), - completed: phase === VerificationPhase.Done, - error: session.error, - createdAt: new Date(session.createdAtMs).toISOString(), - updatedAt: new Date(session.updatedAtMs).toISOString(), - }; - } - - private findVerificationSession(id: string): MatrixVerificationSession { - const direct = this.verificationSessions.get(id); - if (direct) { - return direct; - } - for (const session of this.verificationSessions.values()) { - if (session.request.transactionId === id) { - return session; - } - } - throw new Error(`Matrix verification request not found: ${id}`); - } - - private ensureVerificationRequestTracked(session: MatrixVerificationSession): void { - const requestObj = session.request as unknown as object; - if (this.trackedVerificationRequests.has(requestObj)) { - return; - } - this.trackedVerificationRequests.add(requestObj); - session.request.on(VerificationRequestEvent.Change, () => { - this.touchVerificationSession(session); - if (session.request.verifier) { - this.attachVerifierToVerificationSession(session, session.request.verifier); - } - }); - } - - private attachVerifierToVerificationSession( - session: MatrixVerificationSession, - verifier: MatrixVerifierLike, - ): void { - session.activeVerifier = verifier; - this.touchVerificationSession(session); - - const maybeSas = verifier.getShowSasCallbacks(); - if (maybeSas) { - session.sasCallbacks = maybeSas; - } - const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); - if (maybeReciprocateQr) { - session.reciprocateQrCallbacks = maybeReciprocateQr; - } - - const verifierObj = verifier as unknown as object; - if (this.trackedVerificationVerifiers.has(verifierObj)) { - return; - } - this.trackedVerificationVerifiers.add(verifierObj); - - verifier.on(VerifierEvent.ShowSas, (sas) => { - session.sasCallbacks = sas as MatrixShowSasCallbacks; - this.touchVerificationSession(session); - }); - verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => { - session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks; - this.touchVerificationSession(session); - }); - verifier.on(VerifierEvent.Cancel, (err) => { - session.error = err instanceof Error ? err.message : String(err); - this.touchVerificationSession(session); - }); - } - - private ensureVerificationStarted(session: MatrixVerificationSession): void { - if (!session.activeVerifier || session.verifyStarted) { - return; - } - session.verifyStarted = true; - const verifier = session.activeVerifier; - session.verifyPromise = verifier - .verify() - .then(() => { - this.touchVerificationSession(session); - }) - .catch((err) => { - session.error = err instanceof Error ? err.message : String(err); - this.touchVerificationSession(session); - }); - } - - trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { - this.pruneVerificationSessions(Date.now()); - const txId = request.transactionId?.trim(); - if (txId) { - for (const existing of this.verificationSessions.values()) { - if (existing.request.transactionId === txId) { - existing.request = request; - this.ensureVerificationRequestTracked(existing); - if (request.verifier) { - this.attachVerifierToVerificationSession(existing, request.verifier); - } - this.touchVerificationSession(existing); - return this.buildVerificationSummary(existing); - } - } - } - - const now = Date.now(); - const id = `verification-${++this.verificationSessionCounter}`; - const session: MatrixVerificationSession = { - id, - request, - createdAtMs: now, - updatedAtMs: now, - verifyStarted: false, - }; - this.verificationSessions.set(session.id, session); - this.ensureVerificationRequestTracked(session); - if (request.verifier) { - this.attachVerifierToVerificationSession(session, request.verifier); - } - return this.buildVerificationSummary(session); - } - - async requestOwnUserVerification( - crypto: MatrixVerificationCryptoApi | undefined, - ): Promise { - if (!crypto) { - return null; - } - const request = - (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; - if (!request) { - return null; - } - return this.trackVerificationRequest(request); - } - - listVerifications(): MatrixVerificationSummary[] { - this.pruneVerificationSessions(Date.now()); - const summaries = Array.from(this.verificationSessions.values()).map((session) => - this.buildVerificationSummary(session), - ); - return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - } - - async requestVerification( - crypto: MatrixVerificationCryptoApi | undefined, - params: { - ownUser?: boolean; - userId?: string; - deviceId?: string; - roomId?: string; - }, - ): Promise { - if (!crypto) { - throw new Error("Matrix crypto is not available"); - } - let request: MatrixVerificationRequestLike | null = null; - if (params.ownUser) { - request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; - } else if (params.userId && params.deviceId && crypto.requestDeviceVerification) { - request = await crypto.requestDeviceVerification(params.userId, params.deviceId); - } else if (params.userId && params.roomId && crypto.requestVerificationDM) { - request = await crypto.requestVerificationDM(params.userId, params.roomId); - } else { - throw new Error( - "Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId", - ); - } - - if (!request) { - throw new Error("Matrix verification request could not be created"); - } - return this.trackVerificationRequest(request); - } - - async acceptVerification(id: string): Promise { - const session = this.findVerificationSession(id); - await session.request.accept(); - this.touchVerificationSession(session); - return this.buildVerificationSummary(session); - } - - async cancelVerification( - id: string, - params?: { reason?: string; code?: string }, - ): Promise { - const session = this.findVerificationSession(id); - await session.request.cancel(params); - this.touchVerificationSession(session); - return this.buildVerificationSummary(session); - } - - async startVerification( - id: string, - method: MatrixVerificationMethod = "sas", - ): Promise { - const session = this.findVerificationSession(id); - if (method !== "sas") { - throw new Error("Matrix startVerification currently supports only SAS directly"); - } - const verifier = await session.request.startVerification(VerificationMethod.Sas); - this.attachVerifierToVerificationSession(session, verifier); - this.ensureVerificationStarted(session); - return this.buildVerificationSummary(session); - } - - async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> { - const session = this.findVerificationSession(id); - const qr = await session.request.generateQRCode(); - if (!qr) { - throw new Error("Matrix verification QR data is not available yet"); - } - return { qrDataBase64: Buffer.from(qr).toString("base64") }; - } - - async scanVerificationQr(id: string, qrDataBase64: string): Promise { - const session = this.findVerificationSession(id); - const trimmed = qrDataBase64.trim(); - if (!trimmed) { - throw new Error("Matrix verification QR payload is required"); - } - const qrBytes = Buffer.from(trimmed, "base64"); - if (qrBytes.length === 0) { - throw new Error("Matrix verification QR payload is invalid base64"); - } - const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes)); - this.attachVerifierToVerificationSession(session, verifier); - this.ensureVerificationStarted(session); - return this.buildVerificationSummary(session); - } - - async confirmVerificationSas(id: string): Promise { - const session = this.findVerificationSession(id); - const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); - if (!callbacks) { - throw new Error("Matrix SAS confirmation is not available for this verification request"); - } - session.sasCallbacks = callbacks; - await callbacks.confirm(); - this.touchVerificationSession(session); - return this.buildVerificationSummary(session); - } - - mismatchVerificationSas(id: string): MatrixVerificationSummary { - const session = this.findVerificationSession(id); - const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); - if (!callbacks) { - throw new Error("Matrix SAS mismatch is not available for this verification request"); - } - session.sasCallbacks = callbacks; - callbacks.mismatch(); - this.touchVerificationSession(session); - return this.buildVerificationSummary(session); - } - - confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary { - const session = this.findVerificationSession(id); - const callbacks = - session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks(); - if (!callbacks) { - throw new Error( - "Matrix reciprocate-QR confirmation is not available for this verification request", - ); - } - session.reciprocateQrCallbacks = callbacks; - callbacks.confirm(); - this.touchVerificationSession(session); - return this.buildVerificationSummary(session); - } - - getVerificationSas(id: string): { - decimal?: [number, number, number]; - emoji?: Array<[string, string]>; - } { - const session = this.findVerificationSession(id); - const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); - if (!callbacks) { - throw new Error("Matrix SAS data is not available for this verification request"); - } - session.sasCallbacks = callbacks; - return { - decimal: callbacks.sas.decimal, - emoji: callbacks.sas.emoji, - }; - } -} 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 00000000000..aa4765eaab3 --- /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 00000000000..daf5e40931e --- /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.test.ts b/extensions/matrix/src/matrix/send.test.ts index b31d73fd5a8..931a92e3aa2 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -2,12 +2,36 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../runtime.js"; +vi.mock("music-metadata", () => ({ + // `resolveMediaDurationMs` lazily imports `music-metadata`; in tests we don't + // need real duration parsing and the real module is expensive to load. + parseBuffer: vi.fn().mockResolvedValue({ format: {} }), +})); + +vi.mock("@vector-im/matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + LogService: { + setLogger: vi.fn(), + }, + MatrixClient: vi.fn(), + SimpleFsStorageProvider: vi.fn(), + RustSdkCryptoStorageProvider: vi.fn(), +})); + const loadWebMediaMock = vi.fn().mockResolvedValue({ buffer: Buffer.from("media"), fileName: "photo.png", contentType: "image/png", kind: "image", }); +const mediaKindFromMimeMock = vi.fn(() => "image"); +const isVoiceCompatibleAudioMock = vi.fn(() => false); const getImageMetadataMock = vi.fn().mockResolvedValue(null); const resizeToJpegMock = vi.fn(); @@ -16,11 +40,13 @@ const runtimeStub = { loadConfig: () => ({}), }, media: { - loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), - mediaKindFromMime: () => "image", - isVoiceCompatibleAudio: () => false, - getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), - resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), + loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"], + mediaKindFromMime: + mediaKindFromMimeMock as unknown as PluginRuntime["media"]["mediaKindFromMime"], + isVoiceCompatibleAudio: + isVoiceCompatibleAudioMock as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"], + getImageMetadata: getImageMetadataMock as unknown as PluginRuntime["media"]["getImageMetadata"], + resizeToJpeg: resizeToJpegMock as unknown as PluginRuntime["media"]["resizeToJpeg"], }, channel: { text: { @@ -43,18 +69,20 @@ const makeClient = () => { sendMessage, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - } as unknown as import("./sdk.js").MatrixClient; + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; return { client, sendMessage, uploadContent }; }; -describe("sendMessageMatrix media", () => { - beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ sendMessageMatrix } = await import("./send.js")); - }); +beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); +}); +describe("sendMessageMatrix media", () => { beforeEach(() => { vi.clearAllMocks(); + mediaKindFromMimeMock.mockReturnValue("image"); + isVoiceCompatibleAudioMock.mockReturnValue(false); setMatrixRuntime(runtimeStub); }); @@ -117,14 +145,69 @@ describe("sendMessageMatrix media", () => { expect(content.url).toBeUndefined(); expect(content.file?.url).toBe("mxc://example/file"); }); + + it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => { + const { client, sendMessage } = makeClient(); + mediaKindFromMimeMock.mockReturnValue("audio"); + isVoiceCompatibleAudioMock.mockReturnValue(true); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + fileName: "clip.mp3", + contentType: "audio/mpeg", + kind: "audio", + }); + + await sendMessageMatrix("room:!room:example", "voice caption", { + client, + mediaUrl: "file:///tmp/clip.mp3", + audioAsVoice: true, + }); + + expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({ + contentType: "audio/mpeg", + fileName: "clip.mp3", + }); + expect(sendMessage).toHaveBeenCalledTimes(2); + const mediaContent = sendMessage.mock.calls[0]?.[1] as { + msgtype?: string; + body?: string; + "org.matrix.msc3245.voice"?: Record; + }; + expect(mediaContent.msgtype).toBe("m.audio"); + expect(mediaContent.body).toBe("Voice message"); + expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({}); + }); + + it("keeps regular audio payload when audioAsVoice media is incompatible", async () => { + const { client, sendMessage } = makeClient(); + mediaKindFromMimeMock.mockReturnValue("audio"); + isVoiceCompatibleAudioMock.mockReturnValue(false); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + fileName: "clip.wav", + contentType: "audio/wav", + kind: "audio", + }); + + await sendMessageMatrix("room:!room:example", "voice caption", { + client, + mediaUrl: "file:///tmp/clip.wav", + audioAsVoice: true, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + const mediaContent = sendMessage.mock.calls[0]?.[1] as { + msgtype?: string; + body?: string; + "org.matrix.msc3245.voice"?: Record; + }; + expect(mediaContent.msgtype).toBe("m.audio"); + expect(mediaContent.body).toBe("voice caption"); + expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined(); + }); }); describe("sendMessageMatrix threads", () => { - beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ sendMessageMatrix } = await import("./send.js")); - }); - beforeEach(() => { vi.clearAllMocks(); setMatrixRuntime(runtimeStub); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index a4387984550..dd72ec2883b 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,7 +1,8 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "openclaw/plugin-sdk"; -import type { MatrixClient } from "./sdk.js"; 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,102 +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) => { - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; + return await enqueueSend(roomId, async () => { + 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, - }); - 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); + 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(); @@ -176,6 +180,7 @@ export async function sendPollMatrix( const pollPayload = threadId ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } : pollContent; + // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); return { diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 746573f9bcc..9eee35e88ba 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,13 +1,10 @@ -import type { MatrixClient } from "../sdk.js"; -import type { CoreConfig } from "../types.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; -import { getActiveMatrixClient } from "../active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "../client.js"; +import type { CoreConfig } from "../../types.js"; +import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js"; +import { createPreparedMatrixClient } from "../client-bootstrap.js"; +import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js"; const getCore = () => getMatrixRuntime(); @@ -17,8 +14,35 @@ export function ensureNodeRuntime() { } } -export function resolveMediaMaxBytes(): number | undefined { +/** Look up account config with case-insensitive key fallback. */ +function findAccountConfig( + accounts: Record | undefined, + accountId: string, +): Record | undefined { + if (!accounts) return undefined; + const normalized = normalizeAccountId(accountId); + // Direct lookup first + if (accounts[normalized]) return accounts[normalized] as Record; + // Case-insensitive fallback + for (const key of Object.keys(accounts)) { + if (normalizeAccountId(key) === normalized) { + return accounts[key] as Record; + } + } + return undefined; +} + +export function resolveMediaMaxBytes(accountId?: string): number | undefined { const cfg = getCore().config.loadConfig() as CoreConfig; + // Check account-specific config first (case-insensitive key matching) + const accountConfig = findAccountConfig( + cfg.channels?.matrix?.accounts as Record | undefined, + accountId ?? "", + ); + if (typeof accountConfig?.mediaMaxMb === "number") { + return (accountConfig.mediaMaxMb as number) * 1024 * 1024; + } + // Fall back to top-level config if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; } @@ -28,40 +52,46 @@ export function resolveMediaMaxBytes(): number | undefined { export async function resolveMatrixClient(opts: { client?: MatrixClient; timeoutMs?: number; + accountId?: string; }): Promise<{ client: MatrixClient; stopOnDone: boolean }> { ensureNodeRuntime(); if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(); + const accountId = + typeof opts.accountId === "string" && opts.accountId.trim().length > 0 + ? normalizeAccountId(opts.accountId) + : undefined; + // Try to get the client for the specific account + const active = getActiveMatrixClient(accountId); if (active) { return { client: active, stopOnDone: false }; } + // When no account is specified, try the default account first; only fall back to + // any active client as a last resort (prevents sending from an arbitrary account). + if (!accountId) { + const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID); + if (defaultClient) { + return { client: defaultClient, stopOnDone: false }; + } + const anyActive = getAnyActiveMatrixClient(); + if (anyActive) { + return { client: anyActive, stopOnDone: false }; + } + } const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); if (shouldShareClient) { const client = await resolveSharedMatrixClient({ timeoutMs: opts.timeoutMs, + accountId, }); return { client, stopOnDone: false }; } - const auth = await resolveMatrixAuth(); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - password: auth.password, - deviceId: auth.deviceId, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, + const auth = await resolveMatrixAuth({ accountId }); + const client = await createPreparedMatrixClient({ + auth, + timeoutMs: opts.timeoutMs, + accountId, }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await client.crypto.prepare(joinedRooms); - } catch { - // Ignore crypto prep failures for one-off sends; normal sync will retry. - } - } - await client.start(); return { client, stopOnDone: true }; } diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts index 1852c961c9c..eecdce3d565 100644 --- a/extensions/matrix/src/matrix/send/media.ts +++ b/extensions/matrix/src/matrix/send/media.ts @@ -1,4 +1,3 @@ -import { parseBuffer, type IFileInfo } from "music-metadata"; import type { DimensionalFileInfo, EncryptedFile, @@ -6,7 +5,7 @@ import type { MatrixClient, TimedFileInfo, VideoFileInfo, -} from "../sdk.js"; +} from "@vector-im/matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.js"; import { applyMatrixFormatting } from "./formatting.js"; import { @@ -18,6 +17,7 @@ import { } from "./types.js"; const getCore = () => getMatrixRuntime(); +type IFileInfo = import("music-metadata").IFileInfo; export function buildMatrixMediaInfo(params: { size: number; @@ -164,6 +164,7 @@ export async function resolveMediaDurationMs(params: { return undefined; } try { + const { parseBuffer } = await import("music-metadata"); const fileInfo: IFileInfo | string | undefined = params.contentType || params.fileName ? { diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts index 3e3610cd300..0bc90327cc8 100644 --- a/extensions/matrix/src/matrix/send/targets.test.ts +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -1,5 +1,5 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "../sdk.js"; import { EventType } from "./types.js"; let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index d0eb736a9b8..d4d4e2b6e0d 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "../sdk.js"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { EventType, type MatrixDirectAccountData } from "./types.js"; function normalizeTarget(raw: string): string { diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index a423294ee0e..2b91327aadb 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -6,7 +6,7 @@ import type { TextualMessageEventContent, TimedFileInfo, VideoFileInfo, -} from "../sdk.js"; +} from "@vector-im/matrix-bot-sdk"; // Message types export const MsgType = { @@ -85,7 +85,7 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { - client?: import("../sdk.js").MatrixClient; + client?: import("@vector-im/matrix-bot-sdk").MatrixClient; mediaUrl?: string; accountId?: string; replyToId?: string; diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 117e3e14f09..3ad9588c06e 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -39,9 +39,8 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { await prompter.note( [ "Matrix requires a homeserver URL.", - "Use an access token (recommended), password login, or account registration.", + "Use an access token (recommended) or a password (logs in and stores a token).", "With access token: user ID is fetched automatically.", - "Password + register mode can create an account on homeservers with open registration.", "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, ].join("\n"), @@ -190,7 +189,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { statusLines: [ `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, ], - selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", + selectionHint: !sdkReady + ? "install @vector-im/matrix-bot-sdk" + : configured + ? "configured" + : "needs auth", }; }, configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { @@ -264,7 +267,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { let accessToken = existing.accessToken ?? ""; let password = existing.password ?? ""; let userId = existing.userId ?? ""; - let register = existing.register === true; if (accessToken || password) { const keep = await prompter.confirm({ @@ -275,7 +277,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { accessToken = ""; password = ""; userId = ""; - register = false; } } @@ -286,10 +287,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { options: [ { value: "token", label: "Access token (user ID fetched automatically)" }, { value: "password", label: "Password (requires user ID)" }, - { - value: "register", - label: "Register account (open homeserver registration required)", - }, ], }); @@ -303,9 +300,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { // With access token, we can fetch the userId automatically - don't prompt for it // The client.ts will use whoami() to get it userId = ""; - register = false; } else { - // Password auth and registration mode require user ID upfront + // Password auth requires user ID upfront userId = String( await prompter.text({ message: "Matrix user ID", @@ -331,7 +327,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - register = authMode === "register"; } } @@ -359,7 +354,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { userId: userId || undefined, accessToken: accessToken || undefined, password: password || undefined, - register, deviceName: deviceName || undefined, encryption: enableEncryption || undefined, }, diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 0a37784af51..7105058a44e 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -7,28 +7,16 @@ import { readStringParam, } from "openclaw/plugin-sdk"; import { - acceptMatrixVerification, - cancelMatrixVerification, - confirmMatrixVerificationReciprocateQr, - confirmMatrixVerificationSas, deleteMatrixMessage, editMatrixMessage, - generateMatrixVerificationQr, - getMatrixEncryptionStatus, getMatrixMemberInfo, getMatrixRoomInfo, - getMatrixVerificationSas, listMatrixPins, listMatrixReactions, - listMatrixVerifications, - mismatchMatrixVerificationSas, pinMatrixMessage, readMatrixMessages, - requestMatrixVerification, removeMatrixReactions, - scanMatrixVerificationQr, sendMatrixMessage, - startMatrixVerification, unpinMatrixMessage, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; @@ -37,20 +25,6 @@ import type { CoreConfig } from "./types.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); -const verificationActions = new Set([ - "encryptionStatus", - "verificationList", - "verificationRequest", - "verificationAccept", - "verificationCancel", - "verificationStart", - "verificationGenerateQr", - "verificationScanQr", - "verificationSas", - "verificationConfirm", - "verificationMismatch", - "verificationConfirmQr", -]); function readRoomId(params: Record, required = true): string { const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); @@ -186,109 +160,5 @@ export async function handleMatrixAction( return jsonResult({ ok: true, room: result }); } - if (verificationActions.has(action)) { - if (!isActionEnabled("verification")) { - throw new Error("Matrix verification actions are disabled."); - } - - const requestId = - readStringParam(params, "requestId") ?? - readStringParam(params, "verificationId") ?? - readStringParam(params, "id"); - - if (action === "encryptionStatus") { - const includeRecoveryKey = params.includeRecoveryKey === true; - const status = await getMatrixEncryptionStatus({ includeRecoveryKey }); - return jsonResult({ ok: true, status }); - } - if (action === "verificationList") { - const verifications = await listMatrixVerifications(); - return jsonResult({ ok: true, verifications }); - } - if (action === "verificationRequest") { - const userId = readStringParam(params, "userId"); - const deviceId = readStringParam(params, "deviceId"); - const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); - const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined; - const verification = await requestMatrixVerification({ - ownUser, - userId: userId ?? undefined, - deviceId: deviceId ?? undefined, - roomId: roomId ?? undefined, - }); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationAccept") { - const verification = await acceptMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationCancel") { - const reason = readStringParam(params, "reason"); - const code = readStringParam(params, "code"); - const verification = await cancelMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - { reason: reason ?? undefined, code: code ?? undefined }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationStart") { - const methodRaw = readStringParam(params, "method"); - const method = methodRaw?.trim().toLowerCase(); - if (method && method !== "sas") { - throw new Error( - "Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.", - ); - } - const verification = await startMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - { method: "sas" }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationGenerateQr") { - const qr = await generateMatrixVerificationQr( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, ...qr }); - } - if (action === "verificationScanQr") { - const qrDataBase64 = - readStringParam(params, "qrDataBase64") ?? - readStringParam(params, "qrData") ?? - readStringParam(params, "qr"); - const verification = await scanMatrixVerificationQr( - readStringParam({ requestId }, "requestId", { required: true }), - readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }), - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationSas") { - const sas = await getMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, sas }); - } - if (action === "verificationConfirm") { - const verification = await confirmMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationMismatch") { - const verification = await mismatchMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationConfirmQr") { - const verification = await confirmMatrixVerificationReciprocateQr( - readStringParam({ requestId }, "requestId", { required: true }), - ); - return jsonResult({ ok: true, verification }); - } - } - throw new Error(`Unsupported Matrix action: ${action}`); } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index fc373d6acdb..2c12c673d17 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -37,7 +37,6 @@ export type MatrixActionConfig = { pins?: boolean; memberInfo?: boolean; channelInfo?: boolean; - verification?: boolean; }; /** Per-account Matrix config (excludes the accounts field to prevent recursion). */ @@ -58,13 +57,9 @@ export type MatrixConfig = { accessToken?: string; /** Matrix password (used only to fetch access token). */ password?: string; - /** Auto-register account when password login fails (open registration homeservers). */ - register?: boolean; - /** Optional Matrix device id (recommended when using access tokens + E2EE). */ - deviceId?: string; /** Optional device name when logging in via password. */ deviceName?: string; - /** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */ + /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */ initialSyncLimit?: number; /** Enable end-to-end encryption (E2EE). Default: false. */ encryption?: boolean; diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index be6206d71f9..b9dfe770ee1 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Mattermost channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 7628613a16b..bb0d99e5667 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index c423513a6a2..d645d563d38 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -1,4 +1,8 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + formatInboundFromLabel as formatInboundFromLabelShared, + resolveThreadSessionKeys as resolveThreadSessionKeysShared, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk"; export type ResponsePrefixContext = { @@ -15,27 +19,7 @@ export function extractShortModelName(fullModel: string): string { return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); } -export function formatInboundFromLabel(params: { - isGroup: boolean; - groupLabel?: string; - groupId?: string; - directLabel: string; - directId?: string; - groupFallback?: string; -}): string { - if (params.isGroup) { - const label = params.groupLabel?.trim() || params.groupFallback || "Group"; - const id = params.groupId?.trim(); - return id ? `${label} id:${id}` : label; - } - - const directLabel = params.directLabel.trim(); - const directId = params.directId?.trim(); - if (!directId || directId === directLabel) { - return directLabel; - } - return `${directLabel} id:${directId}`; -} +export const formatInboundFromLabel = formatInboundFromLabelShared; function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); @@ -81,13 +65,8 @@ export function resolveThreadSessionKeys(params: { parentSessionKey?: string; useSuffix?: boolean; }): { sessionKey: string; parentSessionKey?: string } { - const threadId = (params.threadId ?? "").trim(); - if (!threadId) { - return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; - } - const useSuffix = params.useSuffix ?? true; - const sessionKey = useSuffix - ? `${params.baseSessionKey}:thread:${threadId}` - : params.baseSessionKey; - return { sessionKey, parentSessionKey: params.parentSessionKey }; + return resolveThreadSessionKeysShared({ + ...params, + normalizeThreadId: (threadId) => threadId, + }); } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 2ae8388b0fb..6056c3fef15 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -15,6 +15,7 @@ import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, + isDangerousNameMatchingEnabled, resolveControlCommandGate, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -152,6 +153,7 @@ function isSenderAllowed(params: { senderId: string; senderName?: string; allowFrom: string[]; + allowNameMatching?: boolean; }): boolean { const allowFrom = params.allowFrom; if (allowFrom.length === 0) { @@ -162,10 +164,15 @@ function isSenderAllowed(params: { } const normalizedSenderId = normalizeAllowEntry(params.senderId); const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; - return allowFrom.some( - (entry) => - entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), - ); + return allowFrom.some((entry) => { + if (entry === normalizedSenderId) { + return true; + } + if (params.allowNameMatching !== true) { + return false; + } + return normalizedSenderName ? entry === normalizedSenderName : false; + }); } type MattermostMediaInfo = { @@ -206,6 +213,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, accountId: opts.accountId, }); + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const botToken = opts.botToken?.trim() || account.botToken?.trim(); if (!botToken) { throw new Error( @@ -416,11 +424,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId, senderName, allowFrom: effectiveAllowFrom, + allowNameMatching, }); const groupAllowedForCommands = isSenderAllowed({ senderId, senderName, allowFrom: effectiveGroupAllowFrom, + allowNameMatching, }); const commandGate = resolveControlCommandGate({ useAccessGroups, @@ -758,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); @@ -794,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({ @@ -892,6 +902,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId: userId, senderName, allowFrom: effectiveAllowFrom, + allowNameMatching, }); if (!allowed) { logVerboseMessage( @@ -927,6 +938,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId: userId, senderName, allowFrom: effectiveGroupAllowFrom, + allowNameMatching, }); if (!allowed) { logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 7501cca3f31..150989b7b44 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -7,6 +7,11 @@ export type MattermostAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** + * Break-glass override: allow mutable identity matching (@username/display name) in allowlists. + * Default behavior is ID-only matching. + */ + dangerouslyAllowNameMatching?: boolean; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Mattermost account. Default: true. */ diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index b577c8cfc90..98bdbe76f73 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "peerDependencies": { "openclaw": ">=2026.1.26" }, diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index dfd9b2b8030..a658940881e 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,16 +1,13 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.22", + "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" - }, - "devDependencies": { - "openclaw": "workspace:*" + "openai": "^6.25.0" }, "openclaw": { "extensions": [ diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 3913b304c6b..4a0dfc6121d 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 5859decd9ef..b6760627b46 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 3f44afa994d..efee0ce8554 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/msteams", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { "@microsoft/agents-hosting": "^1.3.1", "express": "^5.2.1" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ea8b9babd..b67289aea9d 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,38 +1,85 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, + buildMSTeamsMediaPayload, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, +} from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; +vi.mock("openclaw/plugin-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isPrivateIpAddress: () => false, + }; +}); + /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ const publicResolveFn = async () => ({ address: "13.107.136.10" }); +const GRAPH_HOST = "graph.microsoft.com"; +const SHAREPOINT_HOST = "contoso.sharepoint.com"; +const AZUREEDGE_HOST = "azureedge.net"; +const TEST_HOST = "x"; +const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`; +const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment); +const SAVED_PNG_PATH = "/tmp/saved.png"; +const SAVED_PDF_PATH = "/tmp/saved.pdf"; +const TEST_URL_IMAGE = createTestUrl("img"); +const TEST_URL_IMAGE_PNG = createTestUrl("img.png"); +const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png"); +const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg"); +const TEST_URL_PDF = createTestUrl("x.pdf"); +const TEST_URL_PDF_1 = createTestUrl("1.pdf"); +const TEST_URL_PDF_2 = createTestUrl("2.pdf"); +const TEST_URL_HTML_A = createTestUrl("a.png"); +const TEST_URL_HTML_B = createTestUrl("b.png"); +const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png"); +const TEST_URL_DOC_PDF = createTestUrl("doc.pdf"); +const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl"); +const TEST_URL_OUTSIDE_ALLOWLIST = "https://evil.test/img"; +const CONTENT_TYPE_IMAGE_PNG = "image/png"; +const CONTENT_TYPE_APPLICATION_PDF = "application/pdf"; +const CONTENT_TYPE_TEXT_HTML = "text/html"; +const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info"; +const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; +const MAX_REDIRECT_HOPS = 5; +type RemoteMediaFetchParams = { + url: string; + maxBytes?: number; + filePathHint?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; -const detectMimeMock = vi.fn(async () => "image/png"); +const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG); const saveMediaBufferMock = vi.fn(async () => ({ - path: "/tmp/saved.png", - contentType: "image/png", + path: SAVED_PNG_PATH, + contentType: CONTENT_TYPE_IMAGE_PNG, })); -const fetchRemoteMediaMock = vi.fn( - async (params: { - url: string; - maxBytes?: number; - filePathHint?: string; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - }) => { - const fetchFn = params.fetchImpl ?? fetch; - const res = await fetchFn(params.url); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const buffer = Buffer.from(await res.arrayBuffer()); - if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { - throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); - } - return { - buffer, - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; - }, -); +const readRemoteMediaResponse = async ( + res: Response, + params: Pick, +) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { + throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); + } + return { + buffer, + contentType: res.headers.get("content-type") ?? undefined, + fileName: params.filePathHint, + }; +}; +const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { + const fetchFn = params.fetchImpl ?? fetch; + const res = await fetchFn(params.url); + return readRemoteMediaResponse(res, params); +}); const runtimeStub = { media: { @@ -48,11 +95,546 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); - }; +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; +type DownloadedMedia = Awaited>; +type MSTeamsMediaPayload = ReturnType; +type DownloadAttachmentsBuildOverrides = Partial< + Omit +> & + Pick; +type DownloadAttachmentsNoFetchOverrides = Partial< + Omit< + DownloadAttachmentsParams, + "attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn" + > +> & + Pick; +type DownloadGraphMediaOverrides = Partial< + Omit +>; +type FetchFn = typeof fetch; +type MSTeamsAttachments = DownloadAttachmentsParams["attachments"]; +type AttachmentPlaceholderInput = Parameters[0]; +type GraphMessageUrlParams = Parameters[0]; +type LabeledCase = { label: string }; +type FetchCallExpectation = { expectFetchCalled?: boolean }; +type DownloadedMediaExpectation = { path?: string; placeholder?: string }; +type MSTeamsMediaPayloadExpectation = { + firstPath: string; + paths: string[]; + types: string[]; +}; +const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`; +const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`; +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_ALLOW_HOSTS = [TEST_HOST]; +const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST]; +const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file"); +const MEDIA_PLACEHOLDER_IMAGE = ""; +const MEDIA_PLACEHOLDER_DOCUMENT = ""; +const formatImagePlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE; +const formatDocumentPlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT; +const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST_URL_IMAGE }; +const PNG_BUFFER = Buffer.from("png"); +const PNG_BASE64 = PNG_BUFFER.toString("base64"); +const PDF_BUFFER = Buffer.from("pdf"); +const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") }); +const asSingleItemArray = (value: T) => [value]; +const withLabel = (label: string, fields: T): T & LabeledCase => ({ + label, + ...fields, +}); +const buildAttachment = >(contentType: string, props: T) => ({ + contentType, + ...props, +}); +const createHtmlAttachment = (content: string) => + buildAttachment(CONTENT_TYPE_TEXT_HTML, { content }); +const buildHtmlImageTag = (src: string) => ``; +const createHtmlImageAttachments = (sources: string[], prefix = "") => + asSingleItemArray(createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`)); +const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) => + contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl })); +const createImageAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls); +const createPdfAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls); +const createTeamsFileDownloadInfoAttachments = ( + downloadUrl = TEST_URL_FILE_DOWNLOAD, + fileType = "png", +) => + asSingleItemArray( + buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, { + content: { downloadUrl, fileType }, + }), + ); +const createMediaEntriesWithType = (contentType: string, ...paths: string[]) => + paths.map((path) => ({ path, contentType })); +const createHostedContentsWithType = (contentType: string, ...ids: string[]) => + ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 })); +const createImageMediaEntries = (...paths: string[]) => + createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths); +const createHostedImageContents = (...ids: string[]) => + createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids); +const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => { + return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF); +}; +const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => { + const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + return new Response(new Uint8Array(raw), { + status, + headers: { "content-type": contentType }, + }); +}; +const createJsonResponse = (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { status }); +const createTextResponse = (body: string, status = 200) => new Response(body, { status }); +const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value }); +const createNotFoundResponse = () => new Response("not found", { status: 404 }); +const createRedirectResponse = (location: string, status = 302) => + new Response(null, { status, headers: { location } }); + +const createOkFetchMock = (contentType: string, payload = "png") => + vi.fn(async () => createBufferResponse(payload, contentType)); +const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn; + +const buildDownloadParams = ( + attachments: MSTeamsAttachments, + overrides: DownloadAttachmentsBuildOverrides = {}, +): DownloadAttachmentsParams => { + return { + attachments, + maxBytes: DEFAULT_MAX_BYTES, + allowHosts: DEFAULT_ALLOW_HOSTS, + resolveFn: publicResolveFn, + ...overrides, + }; +}; + +const downloadAttachmentsWithFetch = async ( + attachments: MSTeamsAttachments, + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: FetchCallExpectation = {}, +) => { + const media = await downloadMSTeamsAttachments( + buildDownloadParams(attachments, { + ...overrides, + fetchFn: asFetchFn(fetchFn), + }), + ); + expectMockCallState(fetchFn, options.expectFetchCalled ?? true); + return media; +}; + +const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) => + vi.fn(async (_url: string, opts?: RequestInit) => { + const headers = new Headers(opts?.headers); + const hasAuth = Boolean(headers.get("Authorization")); + if (!hasAuth) { + return createTextResponse(params.unauthBody, params.unauthStatus); + } + return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG); + }); +const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => { + if (shouldCall) { + expect(mockFn).toHaveBeenCalled(); + } else { + expect(mockFn).not.toHaveBeenCalled(); + } +}; + +const DEFAULT_CHANNEL_TEAM_ID = "team-id"; +const DEFAULT_CHANNEL_ID = "chan-id"; +const createChannelGraphMessageUrlParams = (params: { + messageId: string; + replyToId?: string; + conversationId?: string; +}) => ({ + conversationType: "channel" as const, + ...params, + channelData: { + team: { id: DEFAULT_CHANNEL_TEAM_ID }, + channel: { id: DEFAULT_CHANNEL_ID }, + }, +}); +const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) => + params.replyToId + ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}` + : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`; + +const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => { + expect(media).toHaveLength(expectedLength); +}; +const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => { + expectAttachmentMediaLength(media, 1); + expectFirstMedia(media, expected); +}; +const expectMediaBufferSaved = () => { + expect(saveMediaBufferMock).toHaveBeenCalled(); +}; +const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => { + const first = media[0]; + if (expected.path !== undefined) { + expect(first?.path).toBe(expected.path); + } + if (expected.placeholder !== undefined) { + expect(first?.placeholder).toBe(expected.placeholder); + } +}; +const expectMSTeamsMediaPayload = ( + payload: MSTeamsMediaPayload, + expected: MSTeamsMediaPayloadExpectation, +) => { + expect(payload.MediaPath).toBe(expected.firstPath); + expect(payload.MediaUrl).toBe(expected.firstPath); + expect(payload.MediaPaths).toEqual(expected.paths); + expect(payload.MediaUrls).toEqual(expected.paths); + expect(payload.MediaTypes).toEqual(expected.types); +}; +type AttachmentPlaceholderCase = LabeledCase & { + attachments: AttachmentPlaceholderInput; + expected: string; +}; +type CountedAttachmentPlaceholderCaseDef = LabeledCase & { + attachments: AttachmentPlaceholderCase["attachments"]; + count: number; + formatPlaceholder: (count: number) => string; +}; +type AttachmentDownloadSuccessCase = LabeledCase & { + attachments: MSTeamsAttachments; + buildFetchFn?: () => unknown; + beforeDownload?: () => void; + assert?: (media: DownloadedMedia) => void; +}; +type AttachmentAuthRetryScenario = { + attachmentUrl: string; + unauthStatus: number; + unauthBody: string; + overrides?: Omit; +}; +type AttachmentAuthRetryCase = LabeledCase & { + scenario: AttachmentAuthRetryScenario; + expectedMediaLength: number; + expectTokenFetch: boolean; +}; +type GraphUrlExpectationCase = LabeledCase & { + params: GraphMessageUrlParams; + expectedPath: string; +}; +type ChannelGraphUrlCaseParams = { + messageId: string; + replyToId?: string; + conversationId?: string; +}; +type GraphMediaDownloadResult = { + fetchMock: ReturnType; + media: Awaited>; +}; +type GraphMediaSuccessCase = LabeledCase & { + buildOptions: () => GraphFetchMockOptions; + expectedLength: number; + assert?: (params: GraphMediaDownloadResult) => void; +}; +const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [ + withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }), + withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }), +]; +const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [ + withLabel("returns image placeholder for one image attachment", { + attachments: createImageAttachments(TEST_URL_IMAGE_PNG), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns image placeholder with count for many image attachments", { + attachments: [ + ...createImageAttachments(TEST_URL_IMAGE_1_PNG), + { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG }, + ], + count: 2, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("treats Teams file.download.info image attachments as images", { + attachments: createTeamsFileDownloadInfoAttachments(), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns document placeholder for non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF), + count: 1, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("returns document placeholder with count for many non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2), + count: 2, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("counts one inline image in html attachments", { + attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "

hi

"), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("counts many inline images in html attachments", { + attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]), + count: 2, + formatPlaceholder: formatImagePlaceholder, + }), +]; +const ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [ + ...EMPTY_ATTACHMENT_PLACEHOLDER_CASES, + ...COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS.map((testCase) => + withLabel(testCase.label, { + attachments: testCase.attachments, + expected: testCase.formatPlaceholder(testCase.count), + }), + ), +]; +const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [ + withLabel("downloads and stores image contentUrl attachments", { + attachments: asSingleItemArray(IMAGE_ATTACHMENT), + assert: (media) => { + expectFirstMedia(media, { path: SAVED_PNG_PATH }); + expectMediaBufferSaved(); + }, + }), + withLabel("supports Teams file.download.info downloadUrl attachments", { + attachments: createTeamsFileDownloadInfoAttachments(), + }), + withLabel("downloads inline image URLs from html attachments", { + attachments: createHtmlImageAttachments([TEST_URL_INLINE_IMAGE]), + }), + withLabel("downloads non-image file attachments (PDF)", { + attachments: createPdfAttachments(TEST_URL_DOC_PDF), + buildFetchFn: () => createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf"), + beforeDownload: () => { + detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF); + saveMediaBufferMock.mockResolvedValueOnce({ + path: SAVED_PDF_PATH, + contentType: CONTENT_TYPE_APPLICATION_PDF, + }); + }, + assert: (media) => { + expectSingleMedia(media, { + path: SAVED_PDF_PATH, + placeholder: formatDocumentPlaceholder(1), + }); + }, + }), +]; +const ATTACHMENT_AUTH_RETRY_CASES: AttachmentAuthRetryCase[] = [ + withLabel("retries with auth when the first request is unauthorized", { + scenario: { + attachmentUrl: IMAGE_ATTACHMENT.contentUrl, + unauthStatus: 401, + unauthBody: "unauthorized", + overrides: { authAllowHosts: [TEST_HOST] }, + }, + expectedMediaLength: 1, + expectTokenFetch: true, + }), + withLabel("skips auth retries when the host is not in auth allowlist", { + scenario: { + attachmentUrl: createUrlForHost(AZUREEDGE_HOST, "img"), + unauthStatus: 403, + unauthBody: "forbidden", + overrides: { + allowHosts: [AZUREEDGE_HOST], + authAllowHosts: [GRAPH_HOST], + }, + }, + expectedMediaLength: 0, + expectTokenFetch: false, + }), +]; +const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [ + withLabel("downloads hostedContents images", { + buildOptions: () => ({ hostedContents: createHostedImageContents("1") }), + expectedLength: 1, + assert: ({ fetchMock }) => { + expect(fetchMock).toHaveBeenCalled(); + expectMediaBufferSaved(); + }, + }), + withLabel("merges SharePoint reference attachments with hosted content", { + buildOptions: () => { + return { + hostedContents: createHostedImageContents("hosted-1"), + ...buildDefaultShareReferenceGraphFetchOptions({ + onShareRequest: () => createPdfResponse(), + }), + }; + }, + expectedLength: 2, + }), +]; +const CHANNEL_GRAPH_URL_CASES: Array = [ + withLabel("builds channel message urls", { + conversationId: "19:thread@thread.tacv2", + messageId: "123", + }), + withLabel("builds channel reply urls when replyToId is present", { + messageId: "reply-id", + replyToId: "root-id", + }), +]; +const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [ + ...CHANNEL_GRAPH_URL_CASES.map(({ label, ...params }) => + withLabel(label, { + params: createChannelGraphMessageUrlParams(params), + expectedPath: buildExpectedChannelMessagePath(params), + }), + ), + withLabel("builds chat message urls", { + params: { + conversationType: "groupChat" as const, + conversationId: "19:chat@thread.v2", + messageId: "456", + }, + expectedPath: "/chats/19%3Achat%40thread.v2/messages/456", + }), +]; + +type GraphFetchMockOptions = { + hostedContents?: unknown[]; + attachments?: unknown[]; + messageAttachments?: unknown[]; + onShareRequest?: (url: string) => Response | Promise; + onUnhandled?: (url: string) => Response | Promise | undefined; +}; + +const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({ + id: "ref-1", + contentType: "reference", + contentUrl: shareUrl, + name: "report.pdf", +}); +const buildShareReferenceGraphFetchOptions = (params: { + referenceAttachment: ReturnType; + onShareRequest?: GraphFetchMockOptions["onShareRequest"]; + onUnhandled?: GraphFetchMockOptions["onUnhandled"]; +}) => ({ + attachments: [params.referenceAttachment], + messageAttachments: [params.referenceAttachment], + ...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}), + ...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}), +}); +const buildDefaultShareReferenceGraphFetchOptions = ( + params: Omit[0], "referenceAttachment">, +) => + buildShareReferenceGraphFetchOptions({ + referenceAttachment: createReferenceAttachment(), + ...params, + }); +type GraphEndpointResponseHandler = { + suffix: string; + buildResponse: () => Response; +}; +const createGraphEndpointResponseHandlers = (params: { + hostedContents: unknown[]; + attachments: unknown[]; + messageAttachments: unknown[]; +}): GraphEndpointResponseHandler[] => [ + { + suffix: "/hostedContents", + buildResponse: () => createGraphCollectionResponse(params.hostedContents), + }, + { + suffix: "/attachments", + buildResponse: () => createGraphCollectionResponse(params.attachments), + }, + { + suffix: "/messages/123", + buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }), + }, +]; +const resolveGraphEndpointResponse = ( + url: string, + handlers: GraphEndpointResponseHandler[], +): Response | undefined => { + const handler = handlers.find((entry) => url.endsWith(entry.suffix)); + return handler ? handler.buildResponse() : undefined; +}; + +const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => { + const hostedContents = options.hostedContents ?? []; + const attachments = options.attachments ?? []; + const messageAttachments = options.messageAttachments ?? []; + const endpointHandlers = createGraphEndpointResponseHandlers({ + hostedContents, + attachments, + messageAttachments, + }); + return vi.fn(async (url: string) => { + const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers); + if (endpointResponse) { + return endpointResponse; + } + if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) { + return options.onShareRequest(url); + } + const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined; + return unhandled ?? createNotFoundResponse(); + }); +}; +const downloadGraphMediaWithMockOptions = async ( + options: GraphFetchMockOptions = {}, + overrides: DownloadGraphMediaOverrides = {}, +): Promise => { + const fetchMock = createGraphFetchMock(options); + const media = await downloadMSTeamsGraphMedia({ + messageUrl: DEFAULT_MESSAGE_URL, + tokenProvider: createTokenProvider(), + maxBytes: DEFAULT_MAX_BYTES, + fetchFn: asFetchFn(fetchMock), + ...overrides, + }); + return { fetchMock, media }; +}; +const runAttachmentDownloadSuccessCase = async ({ + attachments, + buildFetchFn, + beforeDownload, + assert, +}: AttachmentDownloadSuccessCase) => { + const fetchFn = (buildFetchFn ?? (() => createOkFetchMock(CONTENT_TYPE_IMAGE_PNG)))(); + beforeDownload?.(); + const media = await downloadAttachmentsWithFetch(attachments, fetchFn); + expectSingleMedia(media); + assert?.(media); +}; +const runAttachmentAuthRetryCase = async ({ + scenario, + expectedMediaLength, + expectTokenFetch, +}: AttachmentAuthRetryCase) => { + const tokenProvider = createTokenProvider(); + const fetchMock = createAuthAwareImageFetchMock({ + unauthStatus: scenario.unauthStatus, + unauthBody: scenario.unauthBody, + }); + const media = await downloadAttachmentsWithFetch( + createImageAttachments(scenario.attachmentUrl), + fetchMock, + { tokenProvider, ...scenario.overrides }, + ); + expectAttachmentMediaLength(media, expectedMediaLength); + expectMockCallState(tokenProvider.getAccessToken, expectTokenFetch); +}; +const runGraphMediaSuccessCase = async ({ + buildOptions, + expectedLength, + assert, +}: GraphMediaSuccessCase) => { + const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions()); + expectAttachmentMediaLength(media.media, expectedLength); + assert?.({ fetchMock, media }); +}; + +describe("msteams attachments", () => { beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -61,423 +643,70 @@ describe("msteams attachments", () => { }); describe("buildMSTeamsAttachmentPlaceholder", () => { - it("returns empty string when no attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); - expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); - }); - - it("returns image placeholder for image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "image/png", contentUrl: "https://x/img.png" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "image/png", contentUrl: "https://x/1.png" }, - { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" }, - ]), - ).toBe(" (2 images)"); - }); - - it("treats Teams file.download.info image attachments as images", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ]), - ).toBe(""); - }); - - it("returns document placeholder for non-image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/1.pdf" }, - { contentType: "application/pdf", contentUrl: "https://x/2.pdf" }, - ]), - ).toBe(" (2 files)"); - }); - - it("counts inline images in text/html attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '

hi

', - }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '', - }, - ]), - ).toBe(" (2 images)"); - }); + it.each(ATTACHMENT_PLACEHOLDER_CASES)( + "$label", + ({ attachments, expected }) => { + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); + }, + ); }); describe("downloadMSTeamsAttachments", () => { - it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.png"); - }); - - it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - }); - - it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - }); - detectMimeMock.mockResolvedValueOnce("application/pdf"); - saveMediaBufferMock.mockResolvedValueOnce({ - path: "/tmp/saved.pdf", - contentType: "application/pdf", - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.pdf"); - expect(media[0]?.placeholder).toBe(""); - }); - - it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: '', - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - }); + it.each(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)( + "$label", + runAttachmentDownloadSuccessCase, + ); it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: ``, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([ + ...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]), + ]), + ); - expect(media).toHaveLength(1); - expect(saveMediaBufferMock).toHaveBeenCalled(); + expectSingleMedia(media); + expectMediaBufferSaved(); }); - it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("unauthorized", { status: 401 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - allowHosts: ["x"], - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - }); - - it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); - const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("forbidden", { status: 403 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, - ], - maxBytes: 1024 * 1024, - tokenProvider, - allowHosts: ["azureedge.net"], - authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(media).toHaveLength(0); - expect(fetchMock).toHaveBeenCalled(); - expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); - }); + it.each(ATTACHMENT_AUTH_RETRY_CASES)( + "$label", + runAttachmentAuthRetryCase, + ); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadAttachmentsWithFetch( + createImageAttachments(TEST_URL_OUTSIDE_ALLOWLIST), + fetchMock, + { + allowHosts: [GRAPH_HOST], + resolveFn: undefined, + }, + { expectFetchCalled: false }, + ); - expect(media).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); + expectAttachmentMediaLength(media, 0); }); }); describe("buildMSTeamsGraphMessageUrls", () => { - it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - conversationId: "19:thread@thread.tacv2", - messageId: "123", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); - }); - - it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - messageId: "reply-id", - replyToId: "root-id", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain( - "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", - ); - }); - - it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "groupChat", - conversationId: "19:chat@thread.v2", - messageId: "456", - }); - expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => { + const urls = buildMSTeamsGraphMessageUrls(params); + expect(urls[0]).toContain(expectedPath); }); }); describe("downloadMSTeamsGraphMedia", () => { - it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "1", - contentType: "image/png", - contentBytes: base64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - }); - - it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const hostedBase64 = Buffer.from("png").toString("base64"); - const shareUrl = "https://contoso.sharepoint.com/site/file"; - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "hosted-1", - contentType: "image/png", - contentBytes: hostedBase64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(2); - }); + it.each(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { const fetchFn = params.fetchImpl ?? fetch; let currentUrl = params.url; - for (let i = 0; i < 5; i += 1) { + for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) { const res = await fetchFn(currentUrl, { redirect: "manual" }); - if ([301, 302, 303, 307, 308].includes(res.status)) { + if (REDIRECT_STATUS_CODES.includes(res.status)) { const location = res.headers.get("location"); if (!location) { throw new Error("redirect missing location"); @@ -485,84 +714,43 @@ describe("msteams attachments", () => { currentUrl = new URL(location, currentUrl).toString(); continue; } - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - return { - buffer: Buffer.from(await res.arrayBuffer()), - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; + return readRemoteMediaResponse(res, params); } throw new Error("too many redirects"); }); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(null, { - status: 302, - headers: { location: escapedUrl }, - }); - } - if (url === escapedUrl) { - return new Response(Buffer.from("should-not-be-fetched"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - return new Response("not found", { status: 404 }); - }); + const { fetchMock, media } = await downloadGraphMediaWithMockOptions( + { + ...buildDefaultShareReferenceGraphFetchOptions({ + onShareRequest: () => createRedirectResponse(escapedUrl), + onUnhandled: (url) => { + if (url === escapedUrl) { + return createPdfResponse("should-not-be-fetched"); + } + return undefined; + }, + }), + }, + { + allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, + }, + ); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(0); + expectAttachmentMediaLength(media.media, 0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); - expect( - calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")), - ).toBe(true); + expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true); expect(calledUrls).not.toContain(escapedUrl); }); }); describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); - const payload = buildMSTeamsMediaPayload([ - { path: "/tmp/a.png", contentType: "image/png" }, - { path: "/tmp/b.png", contentType: "image/png" }, - ]); - expect(payload.MediaPath).toBe("/tmp/a.png"); - expect(payload.MediaUrl).toBe("/tmp/a.png"); - expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaTypes).toEqual(["image/png", "image/png"]); + const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png")); + expectMSTeamsMediaPayload(payload, { + firstPath: "/tmp/a.png", + paths: ["/tmp/a.png", "/tmp/b.png"], + types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG], + }); }); }); }); diff --git a/extensions/msteams/src/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts index 3887f9ee927..2049609d894 100644 --- a/extensions/msteams/src/attachments/payload.ts +++ b/extensions/msteams/src/attachments/payload.ts @@ -1,3 +1,5 @@ +import { buildMediaPayload } from "openclaw/plugin-sdk"; + export function buildMSTeamsMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -8,15 +10,5 @@ export function buildMSTeamsMediaPayload( MediaUrls?: string[]; MediaTypes?: string[]; } { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType ?? ""); - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined, - }; + return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true }); } diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cbd562ae3ad..ba176019994 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -49,6 +49,28 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +const createNoopAdapter = (): MSTeamsAdapter => ({ + continueConversation: async () => {}, + process: async () => {}, +}); + +const createRecordedSendActivity = ( + sink: string[], + failFirstWithStatusCode?: number, +): ((activity: unknown) => Promise<{ id: string }>) => { + let attempts = 0; + return async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + sink.push(content); + attempts += 1; + if (failFirstWithStatusCode !== undefined && attempts === 1) { + throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode }); + } + return { id: `id:${content}` }; + }; +}; + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -117,17 +139,9 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - sent.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(sent), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -149,11 +163,7 @@ describe("msteams messenger", () => { continueConversation: async (_appId, reference, logic) => { seen.reference = reference; await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - seen.texts.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, + sendActivity: createRecordedSendActivity(seen.texts), }); }, process: async () => {}, @@ -192,10 +202,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -242,20 +249,9 @@ describe("msteams messenger", () => { const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("throttled"), { statusCode: 429 }); - } - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(attempts, 429), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -280,10 +276,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); await expect( sendMSTeamsMessages({ @@ -303,18 +296,7 @@ describe("msteams messenger", () => { const adapter: MSTeamsAdapter = { continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("server error"), { - statusCode: 503, - }); - } - return { id: `id:${text ?? ""}` }; - }, - }); + await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, }; 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 00000000000..124599147a8 --- /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 56f9848dd71..a87f704a340 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -6,6 +6,7 @@ import { recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, resolveDefaultGroupPolicy, + isDangerousNameMatchingEnabled, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -134,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; @@ -145,10 +147,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { if (dmPolicy !== "open") { const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; + const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveAllowFrom, senderId, senderName, + allowNameMatching, }); if (!allowMatch.allowed) { @@ -186,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; @@ -226,10 +228,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { return; } if (effectiveGroupAllowFrom.length > 0) { + const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching, }); if (!allowMatch.allowed) { log.debug?.("dropping group message (not in groupAllowFrom)", { @@ -243,23 +247,26 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom; const ownerAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const groupAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); 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/policy.test.ts b/extensions/msteams/src/policy.test.ts index 90ee1f3cd24..3c7daa58b3f 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -184,7 +184,7 @@ describe("msteams policy", () => { ).toBe(true); }); - it("allows allowlist when sender name matches", () => { + it("blocks sender-name allowlist matches by default", () => { expect( isMSTeamsGroupAllowed({ groupPolicy: "allowlist", @@ -192,6 +192,18 @@ describe("msteams policy", () => { senderId: "other", senderName: "User", }), + ).toBe(false); + }); + + it("allows sender-name allowlist matches when explicitly enabled", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user"], + senderId: "other", + senderName: "User", + allowNameMatching: true, + }), ).toBe(true); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 6bab808ce91..a3545c0594f 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): MSTeamsAllowlistMatch { return resolveAllowlistMatchSimple(params); } @@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): boolean { const { groupPolicy } = params; if (groupPolicy === "disabled") { diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 55389f2f696..36d611c39da 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 80a1f5fbd2f..cd4639b1c0f 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,11 +1,8 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 73369b1eb2e..b52522983c2 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -40,15 +41,7 @@ export const NextcloudTalkAccountSchemaBase = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); 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 00000000000..88a655ec442 --- /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 5ad02979b60..526249aa977 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,11 +1,15 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -26,32 +30,17 @@ import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./typ const CHANNEL_ID = "nextcloud-talk" as const; async function deliverNextcloudTalkReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; roomToken: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const text = payload.text ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - await sendMessageNextcloudTalk(roomToken, combined, { accountId, replyTo: payload.replyToId, @@ -133,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, @@ -318,25 +307,21 @@ export async function handleNextcloudTalkInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverNextcloudTalkReply({ + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverNextcloudTalkReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - roomToken, - accountId: account.accountId, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); }, 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 00000000000..f2b4b65054d --- /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 ca9214fa600..4b68a3c4d0b 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { + createLoggerBackedRuntime, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, @@ -91,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) { @@ -106,8 +108,6 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); - const headers = extractNextcloudTalkHeaders( req.headers as Record, ); @@ -117,6 +117,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe return; } + const body = await readBody(req, maxBodyBytes); + const isValid = verifyNextcloudTalkSignature({ signature: headers.signature, random: headers.random, @@ -212,13 +214,12 @@ export async function monitorNextcloudTalkProvider( cfg, accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.secret) { throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index ecdbe8437ae..a9fe49be36d 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 b0b7d0c81d3..3ab7bf7a136 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 27ce113e3fa..72b1a2cee62 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,15 +1,12 @@ { "name": "@openclaw/nostr", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { "nostr-tools": "^2.23.1", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d0c1c30ac8b..5e2d3c838d5 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -204,6 +204,23 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + async function expectPrivatePictureRejected(pictureUrl: string) { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: pictureUrl, + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + } + it("validates profile and publishes", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -263,37 +280,11 @@ describe("nostr-profile-http", () => { }); it("rejects private IP in picture URL (SSRF protection)", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://127.0.0.1/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg"); }); it("rejects non-https URLs", async () => { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 76bc26da176..4d28edc8e68 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.22", + "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 bca4c655cd1..1005503eff1 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/signal", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2feb30dfe95..9f3a96b6c41 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,6 @@ import { applyAccountNameToChannelSection, + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -273,18 +274,8 @@ export const signalPlugin: ChannelPlugin = { return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8c936b45e36..adbd311981f 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/slack", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 10080758806..e4474651f07 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.22", + "version": "2026.2.25", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" 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 00000000000..6005cbd923b --- /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 622c7bffaed..bc6c00a4712 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 0e205f60c3e..431dfd2cbd2 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/client.test.ts b/extensions/synology-chat/src/client.test.ts index 9aa14f3f5f3..edb48306948 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -23,14 +23,14 @@ async function settleTimers(promise: Promise): Promise { return promise; } -function mockSuccessResponse() { +function mockResponse(statusCode: number, body: string) { const httpsRequest = vi.mocked(https.request); httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; - res.statusCode = 200; + res.statusCode = statusCode; process.nextTick(() => { callback(res); - res.emit("data", Buffer.from('{"success":true}')); + res.emit("data", Buffer.from(body)); res.emit("end"); }); const req = new EventEmitter() as any; @@ -41,22 +41,12 @@ function mockSuccessResponse() { }); } +function mockSuccessResponse() { + mockResponse(200, '{"success":true}'); +} + function mockFailureResponse(statusCode = 500) { - const httpsRequest = vi.mocked(https.request); - httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = statusCode; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from("error")); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.write = vi.fn(); - req.end = vi.fn(); - req.destroy = vi.fn(); - return req; - }); + mockResponse(statusCode, "error"); } describe("sendMessage", () => { diff --git a/extensions/synology-chat/src/security.test.ts b/extensions/synology-chat/src/security.test.ts index 11330dcddc8..f77fd21ca8e 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 43ff054b077..22883babbf5 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 9248cc427e6..1c8ef393ced 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -80,6 +80,24 @@ describe("createWebhookHandler", () => { }; }); + async function expectForbiddenByPolicy(params: { + account: Partial; + bodyContains: string; + }) { + const handler = createWebhookHandler({ + account: makeAccount(params.account), + deliver: vi.fn(), + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain(params.bodyContains); + } + it("rejects non-POST methods with 405", async () => { const handler = createWebhookHandler({ account: makeAccount(), @@ -129,12 +147,23 @@ describe("createWebhookHandler", () => { }); it("returns 403 for unauthorized user with allowlist policy", async () => { + await expectForbiddenByPolicy({ + account: { + dmPolicy: "allowlist", + allowedUserIds: ["456"], + }, + bodyContains: "not authorized", + }); + }); + + it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => { + const deliver = vi.fn(); const handler = createWebhookHandler({ account: makeAccount({ dmPolicy: "allowlist", - allowedUserIds: ["456"], + allowedUserIds: [], }), - deliver: vi.fn(), + deliver, log, }); @@ -143,22 +172,15 @@ describe("createWebhookHandler", () => { await handler(req, res); expect(res._status).toBe(403); - expect(res._body).toContain("not authorized"); + expect(res._body).toContain("Allowlist is empty"); + expect(deliver).not.toHaveBeenCalled(); }); it("returns 403 when DMs are disabled", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ dmPolicy: "disabled" }), - deliver: vi.fn(), - log, + await expectForbiddenByPolicy({ + account: { dmPolicy: "disabled" }, + bodyContains: "disabled", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("disabled"); }); it("returns 429 when rate limited", async () => { diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index d1dae50a673..b077e61fc7c 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 a89802860c7..83586d5da0e 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/telegram", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index ffe4ce58fb7..0fd75ae7664 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedTelegramAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig { } as OpenClawConfig; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { cfg: OpenClawConfig; accountId: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const account = telegramPlugin.config.resolveAccount( params.cfg, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c562d12470d..0028e993fc0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -374,17 +375,7 @@ export const telegramPlugin: ChannelPlugin ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getTelegramRuntime().channel.telegram.probeTelegram( account.token, diff --git a/extensions/test-utils/runtime-env.ts b/extensions/test-utils/runtime-env.ts new file mode 100644 index 00000000000..747ad5f5f3a --- /dev/null +++ b/extensions/test-utils/runtime-env.ts @@ -0,0 +1,12 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +export function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c58a60564a4..b989fb957a8 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/tlon", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { "@urbit/aura": "^3.0.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 3dbc091ef6f..ea80212088d 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -13,7 +13,7 @@ export const TlonAuthorizationSchema = z.object({ channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(), }); -export const TlonAccountSchema = z.object({ +const tlonCommonConfigFields = { name: z.string().optional(), enabled: z.boolean().optional(), ship: ShipSchema.optional(), @@ -25,20 +25,14 @@ export const TlonAccountSchema = z.object({ autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), responsePrefix: z.string().optional(), +} satisfies z.ZodRawShape; + +export const TlonAccountSchema = z.object({ + ...tlonCommonConfigFields, }); export const TlonConfigSchema = z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - ship: ShipSchema.optional(), - url: z.string().optional(), - code: z.string().optional(), - allowPrivateNetwork: z.boolean().optional(), - groupChannels: z.array(ChannelNestSchema).optional(), - dmAllowlist: z.array(ShipSchema).optional(), - autoDiscoverChannels: z.boolean().optional(), - showModelSignature: z.boolean().optional(), - responsePrefix: z.string().optional(), + ...tlonCommonConfigFields, authorization: TlonAuthorizationSchema.optional(), defaultAuthorizedShips: z.array(ShipSchema).optional(), accounts: z.record(z.string(), TlonAccountSchema).optional(), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e9d9750537b..7d2e8dbd31f 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,6 +1,5 @@ -import { format } from "node:util"; import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; @@ -88,18 +87,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); if (!account.enabled) { @@ -422,11 +414,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { channel: "testchannel", }; + function runAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + return checkTwitchAccessControl({ + message: { + ...mockMessage, + ...params.message, + }, + account: { + ...mockAccount, + ...params.account, + }, + botUsername: "testbot", + }); + } + + function expectSingleRoleAllowed(params: { + role: NonNullable[number]; + message: Partial; + }) { + const result = runAccessCheck({ + account: { allowedRoles: [params.role] }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const message: TwitchChatMessage = { @@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => { describe("allowedRoles", () => { it("allows users with matching role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); expect(result.matchSource).toBe("role"); }); @@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => { }); it("handles moderator role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); }); it("handles subscriber role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["subscriber"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isSub: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "subscriber", + message: { isSub: true }, }); - expect(result.allowed).toBe(true); }); it("handles owner role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "owner", + message: { isOwner: true }, }); - expect(result.allowed).toBe(true); }); it("handles vip role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["vip"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isVip: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "vip", + message: { isVip: true }, }); - expect(result.allowed).toBe(true); }); }); @@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => { }); it("checks allowlist before allowedRoles", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456"], - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { + allowFrom: ["123456"], + allowedRoles: ["owner"], + }, + message: { + message: "@testbot hello", + isOwner: false, + }, }); expect(result.allowed).toBe(true); expect(result.matchSource).toBe("allowlist"); diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 0b7c63a3e43..48f4d2573a0 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index f278c22cb74..9acc9aec987 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -175,5 +175,7 @@ Actions: ## Notes - Uses webhook signature verification for Twilio/Telnyx/Plivo. +- Adds replay protection for Twilio and Plivo webhooks (valid duplicate callbacks are ignored safely). +- Twilio speech turns include a per-turn token so stale/replayed callbacks cannot complete a newer turn. - `responseModel` / `responseSystemPrompt` control AI auto-responses. - Media streaming requires `ws` and OpenAI Realtime API key. diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7d8607ea367..e09e59fef8d 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { @@ -8,9 +8,6 @@ "ws": "^8.19.0", "zod": "^4.3.6" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index eaf4e3fc0a5..83b68153021 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -81,6 +81,27 @@ function summarizeSeries(values: number[]): { }; } +function resolveCallMode(mode?: string): "notify" | "conversation" | undefined { + return mode === "notify" || mode === "conversation" ? mode : undefined; +} + +async function initiateCallAndPrintId(params: { + runtime: VoiceCallRuntime; + to: string; + message?: string; + mode?: string; +}) { + const result = await params.runtime.manager.initiateCall(params.to, undefined, { + message: params.message, + mode: resolveCallMode(params.mode), + }); + if (!result.success) { + throw new Error(result.error || "initiate failed"); + } + // eslint-disable-next-line no-console + console.log(JSON.stringify({ callId: result.callId }, null, 2)); +} + export function registerVoiceCallCli(params: { program: Command; config: VoiceCallConfig; @@ -112,16 +133,12 @@ export function registerVoiceCallCli(params: { if (!to) { throw new Error("Missing --to and no toNumber configured"); } - const result = await rt.manager.initiateCall(to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root @@ -136,16 +153,12 @@ export function registerVoiceCallCli(params: { ) .action(async (options: { to: string; message?: string; mode?: string }) => { const rt = await ensureRuntime(); - const result = await rt.manager.initiateCall(options.to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to: options.to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index d92dbc11f85..06bb380c916 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -17,12 +17,16 @@ import type { } from "./types.js"; class FakeProvider implements VoiceCallProvider { - readonly name = "plivo" as const; + readonly name: "plivo" | "twilio"; readonly playTtsCalls: PlayTtsInput[] = []; readonly hangupCalls: HangupCallInput[] = []; readonly startListeningCalls: StartListeningInput[] = []; readonly stopListeningCalls: StopListeningInput[] = []; + constructor(name: "plivo" | "twilio" = "plivo") { + this.name = name; + } + verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult { return { ok: true }; } @@ -319,6 +323,61 @@ describe("CallManager", () => { expect(provider.stopListeningCalls).toHaveLength(1); }); + it("ignores speech events with mismatched turnToken while waiting for transcript", async () => { + const { manager, provider } = createManagerHarness( + { + transcriptTimeoutMs: 5000, + }, + new FakeProvider("twilio"), + ); + + const started = await manager.initiateCall("+15550000004"); + expect(started.success).toBe(true); + + markCallAnswered(manager, started.callId, "evt-turn-token-answered"); + + const turnPromise = manager.continueCall(started.callId, "Prompt"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedTurnToken = provider.startListeningCalls[0]?.turnToken; + expect(typeof expectedTurnToken).toBe("string"); + + manager.processEvent({ + id: "evt-turn-token-bad", + type: "call.speech", + callId: started.callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + transcript: "stale replay", + isFinal: true, + turnToken: "wrong-token", + }); + + const pendingState = await Promise.race([ + turnPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(pendingState).toBe("pending"); + + manager.processEvent({ + id: "evt-turn-token-good", + type: "call.speech", + callId: started.callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + transcript: "final answer", + isFinal: true, + turnToken: expectedTurnToken, + }); + + const turnResult = await turnPromise; + expect(turnResult.success).toBe(true); + expect(turnResult.transcript).toBe("final answer"); + + const call = manager.getCall(started.callId); + expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]); + }); + it("tracks latency metadata across multiple closed-loop turns", async () => { const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, diff --git a/extensions/voice-call/src/manager/context.ts b/extensions/voice-call/src/manager/context.ts index 1af703ed327..ed14a167e12 100644 --- a/extensions/voice-call/src/manager/context.ts +++ b/extensions/voice-call/src/manager/context.ts @@ -6,6 +6,7 @@ export type TranscriptWaiter = { resolve: (text: string) => void; reject: (err: Error) => void; timeout: NodeJS.Timeout; + turnToken?: string; }; export type CallManagerRuntimeState = { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index f1d5b5d6f03..ec2a26cd051 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: { }; } +function createRejectingInboundContext(): { + ctx: CallManagerContext; + hangupCalls: HangupCallInput[]; +} { + const hangupCalls: HangupCallInput[] = []; + const provider = createProvider({ + hangupCall: async (input: HangupCallInput): Promise => { + hangupCalls.push(input); + }, + }); + const ctx = createContext({ + config: createInboundDisabledConfig(), + provider, + }); + return { ctx, hangupCalls }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event = createInboundInitiatedEvent({ id: "evt-1", providerCallId: "prov-1", @@ -118,16 +125,7 @@ describe("processEvent (functional)", () => { }); it("calls hangup only once for duplicate events for same rejected call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event1 = createInboundInitiatedEvent({ id: "evt-init", providerCallId: "prov-dup", @@ -236,4 +234,49 @@ describe("processEvent (functional)", () => { expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); }); + + it("deduplicates by dedupeKey even when event IDs differ", () => { + const now = Date.now(); + const ctx = createContext(); + ctx.activeCalls.set("call-dedupe", { + callId: "call-dedupe", + providerCallId: "provider-dedupe", + provider: "plivo", + direction: "outbound", + state: "answered", + from: "+15550000000", + to: "+15550000001", + startedAt: now, + transcript: [], + processedEventIds: [], + metadata: {}, + }); + ctx.providerCallIdMap.set("provider-dedupe", "call-dedupe"); + + processEvent(ctx, { + id: "evt-1", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 1, + transcript: "hello", + isFinal: true, + }); + + processEvent(ctx, { + id: "evt-2", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 2, + transcript: "hello", + isFinal: true, + }); + + const call = ctx.activeCalls.get("call-dedupe"); + expect(call?.transcript).toHaveLength(1); + expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]); + }); }); diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 508a8d52634..2d39a96bf74 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -92,10 +92,11 @@ function createInboundCall(params: { } export function processEvent(ctx: EventContext, event: NormalizedEvent): void { - if (ctx.processedEventIds.has(event.id)) { + const dedupeKey = event.dedupeKey || event.id; + if (ctx.processedEventIds.has(dedupeKey)) { return; } - ctx.processedEventIds.add(event.id); + ctx.processedEventIds.add(dedupeKey); let call = findCall({ activeCalls: ctx.activeCalls, @@ -158,7 +159,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { } } - call.processedEventIds.push(event.id); + call.processedEventIds.push(dedupeKey); switch (event.type) { case "call.initiated": @@ -192,8 +193,20 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { case "call.speech": if (event.isFinal) { + const hadWaiter = ctx.transcriptWaiters.has(call.callId); + const resolved = resolveTranscriptWaiter( + ctx, + call.callId, + event.transcript, + event.turnToken, + ); + if (hadWaiter && !resolved) { + console.warn( + `[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`, + ); + break; + } addTranscriptEntry(call, "user", event.transcript); - resolveTranscriptWaiter(ctx, call.callId, event.transcript); } transitionState(call, "listening"); break; diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index 38978b6791c..494d7a10b5d 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -63,6 +63,15 @@ type ConnectedCallLookup = provider: NonNullable; }; +type ConnectedCallResolution = + | { ok: false; error: string } + | { + ok: true; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { const call = ctx.activeCalls.get(callId); if (!call) { @@ -77,6 +86,22 @@ function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): Connect return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; } +function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution { + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { ok: false, error: lookup.error }; + } + if (lookup.kind === "ended") { + return { ok: false, error: "Call has ended" }; + } + return { + ok: true, + call: lookup.call, + providerCallId: lookup.providerCallId, + provider: lookup.provider, + }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -175,14 +200,11 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const lookup = lookupConnectedCall(ctx, callId); - if (lookup.kind === "error") { - return { success: false, error: lookup.error }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } - if (lookup.kind === "ended") { - return { success: false, error: "Call has ended" }; - } - const { call, providerCallId, provider } = lookup; + const { call, providerCallId, provider } = connected; try { transitionState(call, "speaking"); @@ -257,14 +279,11 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const lookup = lookupConnectedCall(ctx, callId); - if (lookup.kind === "error") { - return { success: false, error: lookup.error }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } - if (lookup.kind === "ended") { - return { success: false, error: "Call has ended" }; - } - const { call, providerCallId, provider } = lookup; + const { call, providerCallId, provider } = connected; if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; @@ -272,6 +291,7 @@ export async function continueCall( ctx.activeTurnCalls.add(callId); const turnStartedAt = Date.now(); + const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined; try { await speak(ctx, callId, prompt); @@ -280,9 +300,9 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await provider.startListening({ callId, providerCallId }); + await provider.startListening({ callId, providerCallId, turnToken }); - const transcript = await waitForFinalTranscript(ctx, callId); + const transcript = await waitForFinalTranscript(ctx, callId, turnToken); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 236ffa14354..595ddb993f4 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -77,16 +77,25 @@ export function resolveTranscriptWaiter( ctx: TranscriptWaiterContext, callId: CallId, transcript: string, -): void { + turnToken?: string, +): boolean { const waiter = ctx.transcriptWaiters.get(callId); if (!waiter) { - return; + return false; + } + if (waiter.turnToken && waiter.turnToken !== turnToken) { + return false; } clearTranscriptWaiter(ctx, callId); waiter.resolve(transcript); + return true; } -export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise { +export function waitForFinalTranscript( + ctx: TimerContext, + callId: CallId, + turnToken?: string, +): Promise { if (ctx.transcriptWaiters.has(callId)) { return Promise.reject(new Error("Already waiting for transcript")); } @@ -98,6 +107,6 @@ export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promi reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`)); }, timeoutMs); - ctx.transcriptWaiters.set(callId, { resolve, reject, timeout }); + ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken }); }); } diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 9739379cf58..5b5311acc73 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -30,6 +30,29 @@ export interface PlivoProviderOptions { type PendingSpeak = { text: string; locale?: string }; type PendingListen = { language?: string }; +function getHeader( + headers: Record, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function createPlivoRequestDedupeKey(ctx: WebhookContext): string { + const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); + if (nonceV3) { + return `plivo:v3:${nonceV3}`; + } + const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce"); + if (nonceV2) { + return `plivo:v2:${nonceV2}`; + } + return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`; +} + export class PlivoProvider implements VoiceCallProvider { readonly name = "plivo" as const; @@ -104,7 +127,7 @@ export class PlivoProvider implements VoiceCallProvider { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } - return { ok: result.ok, reason: result.reason }; + return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; } parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { @@ -173,7 +196,8 @@ export class PlivoProvider implements VoiceCallProvider { // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); - const event = this.normalizeEvent(parsed, callIdFromQuery); + const dedupeKey = createPlivoRequestDedupeKey(ctx); + const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { events: event ? [event] : [], @@ -186,7 +210,11 @@ export class PlivoProvider implements VoiceCallProvider { }; } - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + callIdOverride?: string, + dedupeKey?: string, + ): NormalizedEvent | null { const callUuid = params.get("CallUUID") || ""; const requestUuid = params.get("RequestUUID") || ""; @@ -201,6 +229,7 @@ export class PlivoProvider implements VoiceCallProvider { const baseEvent = { id: crypto.randomUUID(), + dedupeKey, callId: callIdOverride || callUuid || requestUuid, providerCallId: callUuid || requestUuid || undefined, timestamp: Date.now(), @@ -331,31 +360,40 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async playTts(input: PlayTtsInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; + private resolveCallContext(params: { + providerCallId: string; + callId: string; + operation: string; + }): { + callUuid: string; + webhookBase: string; + } { + const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId; const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); + this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId); if (!webhookBase) { throw new Error("Missing webhook URL for this call (provider state missing)"); } - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for playTts"); + throw new Error(`Missing Plivo CallUUID for ${params.operation}`); } + return { callUuid, webhookBase }; + } - const transferUrl = new URL(webhookBase); + private async transferCallLeg(params: { + callUuid: string; + webhookBase: string; + callId: string; + flow: "xml-speak" | "xml-listen"; + }): Promise { + const transferUrl = new URL(params.webhookBase); transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-speak"); - transferUrl.searchParams.set("callId", input.callId); - - this.pendingSpeakByCallId.set(input.callId, { - text: input.text, - locale: input.locale, - }); + transferUrl.searchParams.set("flow", params.flow); + transferUrl.searchParams.set("callId", params.callId); await this.apiRequest({ method: "POST", - endpoint: `/Call/${callUuid}/`, + endpoint: `/Call/${params.callUuid}/`, body: { legs: "aleg", aleg_url: transferUrl.toString(), @@ -364,35 +402,42 @@ export class PlivoProvider implements VoiceCallProvider { }); } + async playTts(input: PlayTtsInput): Promise { + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "playTts", + }); + + this.pendingSpeakByCallId.set(input.callId, { + text: input.text, + locale: input.locale, + }); + + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-speak", + }); + } + async startListening(input: StartListeningInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; - const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); - if (!webhookBase) { - throw new Error("Missing webhook URL for this call (provider state missing)"); - } - - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for startListening"); - } - - const transferUrl = new URL(webhookBase); - transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-listen"); - transferUrl.searchParams.set("callId", input.callId); + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "startListening", + }); this.pendingListenByCallId.set(input.callId, { language: input.language, }); - await this.apiRequest({ - method: "POST", - endpoint: `/Call/${callUuid}/`, - body: { - legs: "aleg", - aleg_url: transferUrl.toString(), - aleg_method: "POST", - }, + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-listen", }); } diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index e1a4524d280..7fcd756b943 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 05a750a00bb..e81844f1f65 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/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 3a5652a3563..0d5c6de03d0 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -59,4 +59,38 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain('"); }); + + it("uses a stable dedupeKey for identical request payloads", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA).events[0]; + const eventB = provider.parseWebhookEvent(ctxB).events[0]; + + expect(eventA).toBeDefined(); + expect(eventB).toBeDefined(); + expect(eventA?.id).not.toBe(eventB?.id); + expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123"); + expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey); + }); + + it("keeps turnToken from query on speech events", () => { + const provider = createProvider(); + const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", { + callId: "call-2", + turnToken: "turn-xyz", + }); + + const event = provider.parseWebhookEvent(ctx).events[0]; + expect(event?.type).toBe("call.speech"); + expect(event?.turnToken).toBe("turn-xyz"); + }); }); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 45031c35142..c1dbf6c7f4f 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -20,6 +20,33 @@ import type { VoiceCallProvider } from "./base.js"; import { twilioApiRequest } from "./twilio/api.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; +function getHeader( + headers: Record, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function createTwilioRequestDedupeKey(ctx: WebhookContext): string { + const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token"); + if (idempotencyToken) { + return `twilio:idempotency:${idempotencyToken}`; + } + + const signature = getHeader(ctx.headers, "x-twilio-signature") ?? ""; + const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : ""; + const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; + const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : ""; + return `twilio:fallback:${crypto + .createHash("sha256") + .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`) + .digest("hex")}`; +} + /** * Twilio Voice API provider implementation. * @@ -212,7 +239,16 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; - const event = this.normalizeEvent(params, callIdFromQuery); + const turnTokenFromQuery = + typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim() + ? ctx.query.turnToken.trim() + : undefined; + const dedupeKey = createTwilioRequestDedupeKey(ctx); + const event = this.normalizeEvent(params, { + callIdOverride: callIdFromQuery, + dedupeKey, + turnToken: turnTokenFromQuery, + }); // For Twilio, we must return TwiML. Most actions are driven by Calls API updates, // so the webhook response is typically a pause to keep the call alive. @@ -245,14 +281,24 @@ export class TwilioProvider implements VoiceCallProvider { /** * Convert Twilio webhook params to normalized event format. */ - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + options?: { + callIdOverride?: string; + dedupeKey?: string; + turnToken?: string; + }, + ): NormalizedEvent | null { const callSid = params.get("CallSid") || ""; + const callIdOverride = options?.callIdOverride; const baseEvent = { id: crypto.randomUUID(), + dedupeKey: options?.dedupeKey, callId: callIdOverride || callSid, providerCallId: callSid, timestamp: Date.now(), + turnToken: options?.turnToken, direction: TwilioProvider.parseDirection(params.get("Direction")), from: params.get("From") || undefined, to: params.get("To") || undefined, @@ -603,9 +649,14 @@ export class TwilioProvider implements VoiceCallProvider { throw new Error("Missing webhook URL for this call (provider state not initialized)"); } + const actionUrl = new URL(webhookUrl); + if (input.turnToken) { + actionUrl.searchParams.set("turnToken", input.turnToken); + } + const twiml = ` - + `; diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 91fdfb2dc1e..072e7f4f399 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -28,5 +28,6 @@ export function verifyTwilioProviderWebhook(params: { return { ok: result.ok, reason: result.reason, + isReplay: result.isReplay, }; } diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 38091baa4d4..835b8ad8a1d 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -74,9 +74,13 @@ export type EndReason = z.infer; const BaseEventSchema = z.object({ id: z.string(), + // Stable provider-derived key for idempotency/replay dedupe. + dedupeKey: z.string().optional(), callId: z.string(), providerCallId: z.string().optional(), timestamp: z.number(), + // Optional per-turn nonce for speech events (Twilio replay hardening). + turnToken: z.string().optional(), // Optional fields for inbound call detection direction: z.enum(["inbound", "outbound"]).optional(), from: z.string().optional(), @@ -171,6 +175,8 @@ export type CallRecord = z.infer; export type WebhookVerificationResult = { ok: boolean; reason?: string; + /** Signature is valid, but request was seen before within replay window. */ + isReplay?: boolean; }; export type WebhookContext = { @@ -226,6 +232,8 @@ export type StartListeningInput = { callId: CallId; providerCallId: ProviderCallId; language?: string; + /** Optional per-turn nonce for provider callbacks (replay hardening). */ + turnToken?: string; }; export type StopListeningInput = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 9ad662726a1..e85838a1383 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"); @@ -163,6 +167,71 @@ describe("verifyPlivoWebhook", () => { expect(result.ok).toBe(false); expect(result.reason).toMatch(/Missing Plivo signature headers/); }); + + it("marks replayed valid V3 requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const nonce = "nonce-replay-v3"; + const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc"; + const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; + const signature = plivoV3Signature({ + authToken, + urlWithQuery, + postBody, + nonce, + }); + + const ctx = { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-plivo-signature-v3": signature, + "x-plivo-signature-v3-nonce": nonce, + }, + rawBody: postBody, + url: urlWithQuery, + method: "POST" as const, + query: { flow: "answer", callId: "abc" }, + }; + + const first = verifyPlivoWebhook(ctx, authToken); + const second = verifyPlivoWebhook(ctx, authToken); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); +}); + +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", () => { @@ -197,6 +266,48 @@ describe("verifyTwilioWebhook", () => { expect(result.ok).toBe(true); }); + it("marks replayed valid requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS777&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + const headers = { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-1", + }; + + const first = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); + it("rejects invalid signatures even when attacker injects forwarded host", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 7a8eccda5ae..d190ed8f9ff 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,6 +1,68 @@ import crypto from "node:crypto"; import type { WebhookContext } from "./types.js"; +const REPLAY_WINDOW_MS = 10 * 60 * 1000; +const REPLAY_CACHE_MAX_ENTRIES = 10_000; +const REPLAY_CACHE_PRUNE_INTERVAL = 64; + +type ReplayCache = { + seenUntil: Map; + calls: number; +}; + +const twilioReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +const plivoReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +const telnyxReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +function sha256Hex(input: string): string { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function pruneReplayCache(cache: ReplayCache, now: number): void { + for (const [key, expiresAt] of cache.seenUntil) { + if (expiresAt <= now) { + cache.seenUntil.delete(key); + } + } + while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + const oldest = cache.seenUntil.keys().next().value; + if (!oldest) { + break; + } + cache.seenUntil.delete(oldest); + } +} + +function markReplay(cache: ReplayCache, replayKey: string): boolean { + const now = Date.now(); + cache.calls += 1; + if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) { + pruneReplayCache(cache, now); + } + + const existing = cache.seenUntil.get(replayKey); + if (existing && existing > now) { + return true; + } + + cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS); + if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + pruneReplayCache(cache, now); + } + return false; +} + /** * Validate Twilio webhook signature using HMAC-SHA1. * @@ -328,11 +390,29 @@ export interface TwilioVerificationResult { verificationUrl?: string; /** Whether we're running behind ngrok free tier */ isNgrokFreeTier?: boolean; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } export interface TelnyxVerificationResult { ok: boolean; reason?: string; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; +} + +function createTwilioReplayKey(params: { + ctx: WebhookContext; + signature: string; + verificationUrl: string; +}): string { + const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token"); + if (idempotencyToken) { + return `twilio:idempotency:${idempotencyToken}`; + } + return `twilio:fallback:${sha256Hex( + `${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`, + )}`; } function decodeBase64OrBase64Url(input: string): Buffer { @@ -426,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, @@ -505,7 +587,9 @@ export function verifyTwilioWebhook( const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params); if (isValid) { - return { ok: true, verificationUrl }; + const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl }); + const isReplay = markReplay(twilioReplayCache, replayKey); + return { ok: true, verificationUrl, isReplay }; } // Check if this is ngrok free tier - the URL might have different format @@ -533,6 +617,8 @@ export interface PlivoVerificationResult { verificationUrl?: string; /** Signature version used for verification */ version?: "v3" | "v2"; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } function normalizeSignatureBase64(input: string): string { @@ -753,14 +839,17 @@ export function verifyPlivoWebhook( url: verificationUrl, postParams, }); - return ok - ? { ok: true, version: "v3", verificationUrl } - : { - ok: false, - version: "v3", - verificationUrl, - reason: "Invalid Plivo V3 signature", - }; + if (!ok) { + return { + ok: false, + version: "v3", + verificationUrl, + reason: "Invalid Plivo V3 signature", + }; + } + const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v3", verificationUrl, isReplay }; } if (signatureV2 && nonceV2) { @@ -770,14 +859,17 @@ export function verifyPlivoWebhook( nonce: nonceV2, url: verificationUrl, }); - return ok - ? { ok: true, version: "v2", verificationUrl } - : { - ok: false, - version: "v2", - verificationUrl, - reason: "Invalid Plivo V2 signature", - }; + if (!ok) { + return { + ok: false, + version: "v2", + verificationUrl, + reason: "Invalid Plivo V2 signature", + }; + } + const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v2", verificationUrl, isReplay }; } return { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 51afdb7eba0..8dcf3346342 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -45,12 +45,14 @@ const createCall = (startedAt: number): CallRecord => ({ const createManager = (calls: CallRecord[]) => { const endCall = vi.fn(async () => ({ success: true })); + const processEvent = vi.fn(); const manager = { getActiveCalls: () => calls, endCall, + processEvent, } as unknown as CallManager; - return { manager, endCall }; + return { manager, endCall, processEvent }; }; describe("VoiceCallWebhookServer stale call reaper", () => { @@ -116,3 +118,51 @@ describe("VoiceCallWebhookServer stale call reaper", () => { } }); }); + +describe("VoiceCallWebhookServer replay handling", () => { + it("acknowledges replayed webhook requests and skips event side effects", async () => { + const replayProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, isReplay: true }), + parseWebhookEvent: () => ({ + events: [ + { + id: "evt-replay", + dedupeKey: "stable-replay", + type: "call.speech", + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + }), + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, replayProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(processEvent).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); +}); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index ec052342285..4b778e3a8d7 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -346,11 +346,15 @@ export class VoiceCallWebhookServer { const result = this.provider.parseWebhookEvent(ctx); // Process each event - for (const event of result.events) { - try { - this.manager.processEvent(event); - } catch (err) { - console.error(`[voice-call] Error processing event ${event.type}:`, err); + if (verification.isReplay) { + console.warn("[voice-call] Replay detected; skipping event side effects"); + } else { + for (const event of result.events) { + try { + this.manager.processEvent(event); + } catch (err) { + console.error(`[voice-call] Error processing event ${event.type}:`, err); + } } } diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 819c3c2ab30..8cabcd7bf57 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,12 +1,9 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.22", + "version": "2026.2.25", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index b122577e2e8..a5554cd4c5e 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -4,7 +4,6 @@ import { collectWhatsAppStatusIssues, createActionGate, DEFAULT_ACCOUNT_ID, - escapeRegExp, formatPairingApproveHint, getChatChannelMeta, listWhatsAppAccountIds, @@ -14,8 +13,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, + normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, - normalizeWhatsAppTarget, readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, @@ -23,8 +22,10 @@ import { resolveDefaultGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripPatterns, whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, @@ -114,12 +115,7 @@ export const whatsappPlugin: ChannelPlugin = { }), resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => { const root = cfg.channels?.whatsapp; const normalized = normalizeAccountId(accountId); @@ -211,18 +207,10 @@ export const whatsappPlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID (group participant id).", + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) { - return []; - } - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, + stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx), }, commands: { enforceOwnerForCommands: true, diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 86295a310ef..51bcd15bad3 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -71,8 +71,10 @@ vi.mock("openclaw/plugin-sdk", () => ({ readStringParam: vi.fn(), resolveDefaultWhatsAppAccountId: vi.fn(), resolveWhatsAppAccount: vi.fn(), + resolveWhatsAppGroupIntroHint: vi.fn(), resolveWhatsAppGroupRequireMention: vi.fn(), resolveWhatsAppGroupToolPolicy: vi.fn(), + resolveWhatsAppMentionStripPatterns: vi.fn(() => []), applyAccountNameToChannelSection: vi.fn(), })); diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 3be1369d623..2cf799f217f 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index f0edd3e3a76..3154002f997 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalo", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { "undici": "7.22.0" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 318220f8c16..a5fca946ca7 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -3,7 +3,7 @@ import type { ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk"; -import { jsonResult, readStringParam } from "openclaw/plugin-sdk"; +import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk"; import { listEnabledZaloAccounts } from "./accounts.js"; import { sendMessageZalo } from "./send.js"; @@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsButtons: () => false, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId }) => { if (action === "send") { const to = readStringParam(params, "to", { required: true }); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b7f9fce996d..34706e16882 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -7,6 +7,7 @@ import type { import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, @@ -15,6 +16,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -55,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, }, @@ -81,7 +84,7 @@ export const zaloPlugin: ChannelPlugin = { meta, onboarding: zaloOnboardingAdapter, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, @@ -142,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, @@ -309,17 +337,7 @@ export const zaloPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectZaloStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index db4fba27814..a38a0a1cbfd 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 00000000000..7acd1997096 --- /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 00000000000..2ce0b1be2a2 --- /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 6b253d3cd7b..76e656af7de 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,17 +1,13 @@ -import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; +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 { @@ -25,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"; @@ -53,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()) { @@ -67,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, + 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, + ); }); - 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)}`); - }); - - res.statusCode = 200; - res.end("ok"); - return true; } function startPollingLoop(params: { @@ -500,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, @@ -508,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), @@ -681,7 +529,7 @@ async function processMessageWithPipeline(params: { } async function deliverZaloReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; token: string; chatId: string; runtime: ZaloRuntimeEnv; @@ -696,24 +544,18 @@ async function deliverZaloReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Zalo photo send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } @@ -822,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 00000000000..dd2b0c65585 --- /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 bcc43138f97..c17ea0cfc61 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 4e03fa2d373..c247e93b967 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026.2.25 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.2.24 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.2.22 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index c779e291159..49cede39b76 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,14 +1,11 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.22", + "version": "2026.2.25", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { "@sinclair/typebox": "0.34.48" }, - "devDependencies": { - "openclaw": "workspace:*" - }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 17575c40128..7e2ff850d40 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,11 +1,18 @@ import type { ChildProcess } from "node:child_process"; -import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { + MarkdownTableMode, + OpenClawConfig, + OutboundReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + resolveOutboundMediaUrls, mergeAllowlist, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, + sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; @@ -392,7 +399,7 @@ async function processMessage( } async function deliverZalouserReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; profile: string; chatId: string; isGroup: boolean; @@ -408,29 +415,23 @@ async function deliverZalouserReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - logVerbose(core, runtime, `Sending media to ${chatId}`); - await sendMessageZalouser(chatId, caption ?? "", { - profile, - mediaUrl, - isGroup, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser media send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + logVerbose(core, runtime, `Sending media to ${chatId}`); + await sendMessageZalouser(chatId, caption ?? "", { + profile, + mediaUrl, + isGroup, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error(`Zalouser media send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/package.json b/package.json index 69f10411241..81a8a66cb4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.22-2", + "version": "2026.2.25", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", @@ -54,7 +54,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", @@ -93,6 +93,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", @@ -128,7 +130,7 @@ "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:ui": "pnpm --dir ui test", + "test:ui": "pnpm lint:ui:no-raw-window-open && 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", "tui": "node scripts/run-node.mjs tui", @@ -139,7 +141,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", @@ -149,14 +151,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", @@ -178,7 +181,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", @@ -201,12 +204,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 60c9d6f9fcf..36a04c9dfbc 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) @@ -147,8 +150,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 @@ -211,8 +214,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) @@ -220,20 +223,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 @@ -248,17 +251,9 @@ importers: specifier: ^0.10.0 version: 0.10.0 - extensions/bluebubbles: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/bluebubbles: {} - extensions/copilot-proxy: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/copilot-proxy: {} extensions/diagnostics-otel: dependencies: @@ -268,13 +263,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': @@ -295,16 +290,8 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.39.0 version: 1.39.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/discord: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/discord: {} extensions/feishu: dependencies: @@ -317,50 +304,23 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-antigravity-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - - extensions/google-gemini-cli-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/google-gemini-cli-auth: {} extensions/googlechat: dependencies: google-auth-library: - specifier: ^10.5.0 - version: 10.5.0 - devDependencies: + specifier: ^10.6.1 + version: 10.6.1 openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + 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: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/imessage: {} - extensions/irc: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/irc: {} - extensions/line: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/line: {} extensions/llm-task: {} @@ -371,25 +331,18 @@ importers: '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 - fake-indexeddb: - specifier: ^6.2.5 - version: 6.2.5 + '@vector-im/matrix-bot-sdk': + specifier: 0.8.0-element.3 + version: 0.8.0-element.3(@cypress/request@3.0.10) markdown-it: - specifier: 14.1.0 - version: 14.1.0 - matrix-js-sdk: - specifier: ^40.1.0 - version: 40.2.0 + specifier: 14.1.1 + version: 14.1.1 music-metadata: - specifier: ^11.11.2 + specifier: ^11.12.1 version: 11.12.1 zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/matrix-js: dependencies: @@ -416,17 +369,13 @@ importers: specifier: workspace:* version: link:../.. - extensions/mattermost: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/mattermost: {} extensions/memory-core: - devDependencies: + dependencies: openclaw: - specifier: workspace:* - version: link:../.. + specifier: '>=2026.1.26' + 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: @@ -437,18 +386,10 @@ 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) - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + specifier: ^6.25.0 + version: 6.25.0(ws@8.19.0)(zod@4.3.6) - extensions/minimax-portal-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/minimax-portal-auth: {} extensions/msteams: dependencies: @@ -458,16 +399,8 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/nextcloud-talk: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/nextcloud-talk: {} extensions/nostr: dependencies: @@ -477,50 +410,26 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/open-prose: {} - extensions/signal: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/signal: {} - extensions/slack: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/slack: {} extensions/synology-chat: dependencies: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/telegram: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/telegram: {} extensions/tlon: dependencies: '@urbit/aura': specifier: ^3.0.0 version: 3.0.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/twitch: dependencies: @@ -536,10 +445,6 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/voice-call: dependencies: @@ -552,36 +457,20 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/whatsapp: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. + extensions/whatsapp: {} extensions/zalo: dependencies: undici: specifier: 7.22.0 version: 7.22.0 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. extensions/zalouser: dependencies: '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. packages/clawdbot: dependencies: @@ -672,10 +561,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'} @@ -684,6 +581,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'} @@ -692,10 +597,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'} @@ -704,14 +621,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'} @@ -720,30 +653,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'} @@ -752,10 +713,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'} @@ -764,10 +733,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'} @@ -776,10 +753,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'} @@ -787,6 +772,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'} @@ -796,10 +784,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'} @@ -895,6 +896,16 @@ packages: '@cloudflare/workers-types@4.20260120.0': resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==} + '@cypress/request-promise@5.0.0': + resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==} + engines: {node: '>=0.10.0'} + peerDependencies: + '@cypress/request': ^3.0.0 + + '@cypress/request@3.0.10': + resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==} + engines: {node: '>= 6'} + '@d-fischer/cache-decorators@4.0.1': resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==} @@ -1510,20 +1521,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'} @@ -2061,260 +2090,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] @@ -2628,6 +2657,9 @@ packages: '@scure/bip39@2.0.1': resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} @@ -2660,6 +2692,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'} @@ -2668,42 +2704,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'} @@ -2716,6 +2796,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'} @@ -2724,18 +2812,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'} @@ -2744,22 +2852,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'} @@ -2768,6 +2900,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'} @@ -2776,10 +2916,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'} @@ -2788,14 +2940,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'} @@ -2804,30 +2968,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'} @@ -2836,10 +3032,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'} @@ -2848,10 +3052,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==} @@ -2911,6 +3210,9 @@ packages: '@types/bun@1.3.9': resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2932,9 +3234,15 @@ packages: '@types/events@3.0.3': resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -2962,6 +3270,9 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2986,58 +3297,70 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} '@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': @@ -3048,6 +3371,10 @@ packages: resolution: {integrity: sha512-N8/FHc/lmlMDCumMuTXyRHCxlov5KZY6unmJ9QR2GOw+OpROZMBsXYGwE+ZMtvN21ql9+Xb8KhGNBj08IrG3Wg==} engines: {node: '>=16', npm: '>=8'} + '@vector-im/matrix-bot-sdk@0.8.0-element.3': + resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==} + engines: {node: '>=22.0.0'} + '@vitest/browser-playwright@4.0.18': resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} peerDependencies: @@ -3125,8 +3452,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git} + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git} version: 2.0.1 abbrev@1.1.1: @@ -3136,6 +3463,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3227,6 +3558,16 @@ packages: resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} engines: {node: '>=12.17'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3242,6 +3583,9 @@ packages: ast-v8-to-istanbul@0.3.11: resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3265,6 +3609,12 @@ packages: resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==} engines: {node: '>=14'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -3278,10 +3628,17 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3291,6 +3648,13 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3339,6 +3703,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3435,6 +3802,10 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3443,6 +3814,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -3451,6 +3825,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3475,6 +3852,10 @@ packages: curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -3483,6 +3864,14 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3496,6 +3885,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -3514,6 +3907,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3564,6 +3961,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -3630,6 +4030,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -3673,6 +4077,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -3680,6 +4088,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fake-indexeddb@6.2.5: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} @@ -3722,6 +4134,10 @@ packages: resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} engines: {node: '>=16'} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -3746,6 +4162,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + form-data@2.5.4: resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} engines: {node: '>= 0.12'} @@ -3759,6 +4178,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3828,6 +4251,12 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} 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 @@ -3841,8 +4270,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: @@ -3860,10 +4289,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'} @@ -3883,6 +4308,9 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hashery@1.5.0: resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} engines: {node: '>=20'} @@ -3914,9 +4342,19 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlencode@0.0.4: + resolution: {integrity: sha512-0uDvNVpzj/E2TfvLLyyXhKBRvF1y84aZsyRxRXFsQobnHaL4pcaXk+Y9cnFlvnxrBLeXDNq/VJBD+ngdBgQG1w==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3925,6 +4363,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -3933,6 +4375,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -4003,6 +4449,13 @@ packages: resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} engines: {node: '>=16'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -4010,6 +4463,9 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -4028,6 +4484,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4053,6 +4512,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4072,6 +4534,12 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4084,6 +4552,10 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -4111,6 +4583,9 @@ packages: koffi@2.15.1: resolution: {integrity: sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA==} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -4253,6 +4728,9 @@ packages: lodash.pickby@4.6.0: resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -4271,6 +4749,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lowdb@1.0.0: + resolution: {integrity: sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==} + engines: {node: '>=4'} + lowdb@7.0.1: resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} engines: {node: '>=18'} @@ -4342,6 +4824,10 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -4349,10 +4835,17 @@ packages: memory-stream@1.0.0: resolution: {integrity: sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -4369,10 +4862,18 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.1: resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} engines: {node: 20 || >=22} @@ -4388,9 +4889,18 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + mpg123-decoder@1.0.3: resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==} @@ -4398,6 +4908,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4418,6 +4931,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -4535,10 +5052,18 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4558,8 +5083,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 @@ -4570,12 +5095,23 @@ packages: zod: optional: true + openclaw@2026.2.23: + resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@napi-rs/canvas': ^0.1.89 + node-llama-cpp: 3.15.1 + opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} 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'} @@ -4584,17 +5120,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: @@ -4653,6 +5189,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -4662,6 +5201,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4685,6 +5227,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -4695,6 +5240,12 @@ packages: resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==} engines: {node: '>=20.16.0 || >=22.3.0'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4702,6 +5253,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} @@ -4734,6 +5289,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} + engines: {node: '>=12'} + pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} @@ -4795,10 +5354,17 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qified@0.6.0: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} engines: {node: '>=20'} @@ -4817,6 +5383,9 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -4824,6 +5393,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -4850,6 +5423,12 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + request-promise-core@1.1.3: + resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + request: ^2.34 + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4862,6 +5441,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4932,10 +5514,16 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.1: + resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} + sdp-transform@3.0.0: resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==} hasBin: true + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4945,10 +5533,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -5086,6 +5682,11 @@ packages: sqlite-vec@0.1.7-alpha.2: resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5104,6 +5705,13 @@ packages: resolution: {integrity: sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==} engines: {node: '>=16.0.0'} + stealthy-require@1.1.1: + resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} + engines: {node: '>=0.10.0'} + + steno@0.4.4: + resolution: {integrity: sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==} + steno@4.0.2: resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} engines: {node: '>=18'} @@ -5203,6 +5811,10 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5254,6 +5866,16 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -5306,6 +5928,10 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5327,9 +5953,16 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -5350,6 +5983,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5547,7 +6184,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': @@ -5555,7 +6192,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 @@ -5563,7 +6200,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': @@ -5572,7 +6209,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 @@ -5628,6 +6265,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 @@ -5673,6 +6362,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 @@ -5732,6 +6466,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 @@ -5753,6 +6511,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 @@ -5772,6 +6562,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 @@ -5802,6 +6605,32 @@ snapshots: transitivePeerDependencies: - aws-crt + '@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 @@ -5811,6 +6640,19 @@ snapshots: '@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 @@ -5824,6 +6666,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 @@ -5843,6 +6697,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 @@ -5850,6 +6711,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 @@ -5857,12 +6725,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 @@ -5871,6 +6752,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 @@ -5881,6 +6770,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 @@ -5896,6 +6795,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 @@ -5982,6 +6896,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 @@ -5990,6 +6947,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 @@ -6014,11 +6979,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 @@ -6035,6 +7017,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 @@ -6042,6 +7032,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 @@ -6053,6 +7050,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 @@ -6061,12 +7065,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': @@ -6158,6 +7176,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 @@ -6190,6 +7228,37 @@ snapshots: '@cloudflare/workers-types@4.20260120.0': optional: true + '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)': + dependencies: + '@cypress/request': 3.0.10 + bluebird: 3.7.2 + request-promise-core: 1.1.3(@cypress/request@3.0.10) + stealthy-require: 1.1.1 + tough-cookie: 4.1.3 + transitivePeerDependencies: + - request + + '@cypress/request@3.0.10': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.5.4 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.2 + safe-buffer: 5.2.1 + tough-cookie: 4.1.3 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + '@d-fischer/cache-decorators@4.0.1': dependencies: '@d-fischer/shared-utils': 3.6.4 @@ -6278,6 +7347,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 @@ -6377,7 +7461,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 @@ -6732,6 +7816,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) @@ -6756,6 +7852,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 @@ -6785,6 +7905,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 @@ -6794,6 +7943,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 @@ -6925,7 +8083,6 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.94 '@napi-rs/canvas-win32-arm64-msvc': 0.1.94 '@napi-rs/canvas-win32-x64-msvc': 0.1.94 - optional: true '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -7368,136 +8525,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': {} @@ -7701,6 +8858,11 @@ snapshots: '@noble/hashes': 2.0.1 '@scure/base': 2.0.0 + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@silvia-odwyer/photon-node@0.3.4': {} '@sinclair/typebox@0.34.48': {} @@ -7770,6 +8932,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 @@ -7784,6 +8951,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 @@ -7797,6 +8973,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 @@ -7805,6 +9002,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 @@ -7812,29 +9016,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 @@ -7843,6 +9078,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 @@ -7850,6 +9092,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 @@ -7863,6 +9110,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 @@ -7880,6 +9137,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 @@ -7892,17 +9160,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 @@ -7918,27 +9216,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 @@ -7948,6 +9279,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 @@ -7969,10 +9316,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 @@ -7985,14 +9352,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 @@ -8003,10 +9384,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 @@ -8014,6 +9404,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 @@ -8024,21 +9421,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 @@ -8056,10 +9484,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 @@ -8070,10 +9513,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': @@ -8169,6 +9682,8 @@ snapshots: bun-types: 1.3.9 optional: true + '@types/caseless@0.12.5': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8188,6 +9703,13 @@ snapshots: '@types/events@3.0.3': {} + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 25.3.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 25.3.0 @@ -8195,6 +9717,13 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 @@ -8223,6 +9752,8 @@ snapshots: '@types/mime-types@2.1.4': {} + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} '@types/node@10.17.60': {} @@ -8245,53 +9776,73 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 25.3.0 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.4 + '@types/retry@0.12.0': {} + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 25.3.0 + '@types/send@1.2.1': dependencies: '@types/node': 25.3.0 + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.3.0 + '@types/send': 0.17.6 + '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 '@types/node': 25.3.0 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} '@types/ws@8.18.1': 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: @@ -8303,6 +9854,31 @@ snapshots: '@urbit/aura@3.0.0': {} + '@vector-im/matrix-bot-sdk@0.8.0-element.3(@cypress/request@3.0.10)': + dependencies: + '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 + '@types/express': 4.17.25 + '@types/request': 2.48.13 + another-json: 0.2.0 + async-lock: 1.4.1 + chalk: 4.1.2 + express: 4.22.1 + glob-to-regexp: 0.4.1 + hash.js: 1.1.7 + html-to-text: 9.0.5 + htmlencode: 0.0.4 + lowdb: 1.0.0 + lru-cache: 10.4.3 + mkdirp: 3.0.1 + morgan: 1.10.1 + postgres: 3.4.8 + request: '@cypress/request@3.0.10' + request-promise: '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)' + sanitize-html: 2.17.1 + transitivePeerDependencies: + - '@cypress/request' + - supports-color + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(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)': dependencies: '@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) @@ -8416,7 +9992,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.6 music-metadata: 11.12.1 p-queue: 9.1.0 @@ -8431,7 +10007,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -8443,6 +10019,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8523,6 +10104,14 @@ snapshots: array-back@6.2.2: {} + array-flatten@1.1.1: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -8541,6 +10130,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-lock@1.4.1: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -8571,6 +10162,10 @@ snapshots: audio-type@2.2.1: optional: true + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -8585,7 +10180,15 @@ snapshots: base64-js@1.5.1: {} - basic-ftp@5.1.0: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + basic-ftp@5.2.0: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 before-after-hook@4.0.0: {} @@ -8593,6 +10196,25 @@ snapshots: birpc@4.0.0: {} + bluebird@3.7.2: {} + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -8652,6 +10274,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + caseless@0.12.0: {} + chai@6.2.2: {} chalk-template@0.4.0: @@ -8756,14 +10380,22 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} croner@10.0.1: {} @@ -8788,16 +10420,26 @@ snapshots: curve25519-js@0.0.4: {} + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + defu@6.1.4: {} degenerator@5.0.1: @@ -8812,6 +10454,8 @@ snapshots: depd@2.0.0: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} diff@8.0.3: {} @@ -8854,6 +10498,11 @@ snapshots: eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -8926,6 +10575,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -8956,6 +10607,42 @@ snapshots: expect-type@1.3.0: {} + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -8991,6 +10678,8 @@ snapshots: extend@3.0.2: {} + extsprintf@1.3.0: {} + fake-indexeddb@6.2.5: {} fast-content-type-parse@3.0.0: {} @@ -9027,6 +10716,18 @@ snapshots: dependencies: filename-reserved-regex: 3.0.0 + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -9053,6 +10754,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + form-data@2.5.4: dependencies: asynckit: 0.4.0 @@ -9068,6 +10771,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@11.3.3: @@ -9158,12 +10863,18 @@ 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: - supports-color + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-to-regexp@0.4.1: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -9189,14 +10900,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 @@ -9217,13 +10927,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: {} @@ -9236,6 +10939,11 @@ snapshots: has-unicode@2.0.1: {} + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + hashery@1.5.0: dependencies: hookified: 1.15.1 @@ -9261,6 +10969,16 @@ snapshots: html-escaper@3.0.3: {} + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + htmlencode@0.0.4: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -9268,6 +10986,13 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -9283,6 +11008,12 @@ snapshots: transitivePeerDependencies: - supports-color + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -9298,6 +11029,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -9382,10 +11117,16 @@ snapshots: is-network-error@1.3.0: {} + is-plain-object@5.0.0: {} + + is-promise@2.2.2: {} + is-promise@4.0.0: {} is-stream@2.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -9396,6 +11137,8 @@ snapshots: isexe@3.1.5: {} + isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -9421,6 +11164,8 @@ snapshots: js-tokens@10.0.0: {} + jsbn@0.1.1: {} + jsesc@3.1.0: {} json-bigint@1.0.0: @@ -9436,6 +11181,10 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonfile@6.2.0: @@ -9457,6 +11206,13 @@ snapshots: ms: 2.1.3 semver: 7.7.4 + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -9495,6 +11251,8 @@ snapshots: koffi@2.15.1: {} + leac@0.6.0: {} + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -9609,6 +11367,8 @@ snapshots: lodash.pickby@4.6.0: {} + lodash@4.17.23: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -9625,6 +11385,14 @@ snapshots: long@5.3.2: {} + lowdb@1.0.0: + dependencies: + graceful-fs: 4.2.11 + is-promise: 2.2.2 + lodash: 4.17.23 + pify: 3.0.0 + steno: 0.4.4 + lowdb@7.0.1: dependencies: steno: 4.0.2 @@ -9713,14 +11481,20 @@ snapshots: mdurl@2.0.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} memory-stream@1.0.0: dependencies: readable-stream: 3.6.2 + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + methods@1.1.2: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -9733,8 +11507,12 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + mimic-function@5.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.1: dependencies: brace-expansion: 5.0.3 @@ -9747,8 +11525,20 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp@3.0.1: {} + module-details-from-path@1.0.4: {} + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + mpg123-decoder@1.0.3: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -9756,6 +11546,8 @@ snapshots: mrmime@2.0.1: {} + ms@2.0.0: {} + ms@2.1.3: {} music-metadata@11.12.1: @@ -9783,6 +11575,8 @@ snapshots: nanoid@5.1.6: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} netmask@2.0.2: {} @@ -9944,10 +11738,16 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + on-finished@2.4.1: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9961,11 +11761,87 @@ 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.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 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + '@clack/prompts': 1.0.1 + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + '@grammyjs/runner': 2.0.3(grammy@1.40.0) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.0) + '@homebridge/ciao': 1.3.5 + '@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(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.54.1 + '@mozilla/readability': 0.6.0 + '@napi-rs/canvas': 0.1.94 + '@sinclair/typebox': 0.34.48 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.14.1 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.18.0 + chalk: 5.6.2 + chokidar: 5.0.0 + cli-highlight: 2.1.11 + commander: 14.0.3 + croner: 10.0.1 + discord-api-types: 0.38.40 + dotenv: 17.3.1 + express: 5.2.1 + file-type: 21.3.0 + grammy: 1.40.0 + https-proxy-agent: 7.0.6 + ipaddr.js: 2.3.0 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + node-llama-cpp: 3.15.1(typescript@5.9.3) + opusscript: 0.0.8 + osc-progress: 0.3.0 + pdfjs-dist: 5.4.624 + playwright-core: 1.58.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.9 + tslog: 4.10.2 + undici: 7.22.0 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + '@discordjs/opus': 0.10.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - encoding + - ffmpeg-static + - hono + - jimp + - link-preview-js + - node-opus + - supports-color + - utf-8-validate + opus-decoder@0.7.11: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -9973,6 +11849,8 @@ snapshots: opusscript@0.0.8: {} + opusscript@0.1.1: {} + ora@8.2.0: dependencies: chalk: 5.6.2 @@ -9987,61 +11865,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 + '@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.14.2: + 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-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.49.0(oxlint-tsgolint@0.14.2): + 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: {} @@ -10096,6 +11974,8 @@ snapshots: parse-ms@4.0.0: {} + parse-srcset@1.0.2: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -10104,6 +11984,11 @@ snapshots: parse5@6.0.1: {} + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + parseurl@1.3.3: {} partial-json@0.1.7: {} @@ -10123,6 +12008,8 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 + path-to-regexp@0.1.12: {} + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -10132,10 +12019,16 @@ snapshots: '@napi-rs/canvas': 0.1.94 node-readable-to-web-readable-stream: 0.4.2 + peberminta@0.9.0: {} + + performance-now@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} + pify@3.0.0: {} + pino-abstract-transport@2.0.0: dependencies: split2: 4.2.0 @@ -10176,6 +12069,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.8: {} + pretty-bytes@6.1.1: {} pretty-ms@8.0.0: @@ -10191,6 +12086,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: {} @@ -10267,8 +12167,14 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} + qified@0.6.0: dependencies: hookified: 1.15.1 @@ -10286,10 +12192,19 @@ snapshots: quansync@1.0.0: {} + querystringify@2.2.0: {} + quick-format-unescaped@4.0.4: {} range-parser@1.2.1: {} + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -10326,6 +12241,11 @@ snapshots: reflect-metadata@0.2.2: {} + request-promise-core@1.1.3(@cypress/request@3.0.10): + dependencies: + lodash: 4.17.23 + request: '@cypress/request@3.0.10' + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -10337,6 +12257,8 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + resolve-pkg-maps@1.0.0: {} restore-cursor@5.1.0: @@ -10357,7 +12279,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 @@ -10370,7 +12292,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 @@ -10443,13 +12365,44 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.1: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.6 + sdp-transform@3.0.0: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver@6.3.1: optional: true semver@7.7.4: {} + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + send@1.2.1: dependencies: debug: 4.4.3 @@ -10466,6 +12419,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -10637,6 +12599,18 @@ snapshots: sqlite-vec-linux-x64: 0.1.7-alpha.2 sqlite-vec-windows-x64: 0.1.7-alpha.2 + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -10652,6 +12626,12 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + stealthy-require@1.1.1: {} + + steno@0.4.4: + dependencies: + graceful-fs: 4.2.11 + steno@4.0.2: {} string-width@4.2.3: @@ -10750,13 +12730,20 @@ snapshots: totalist@3.0.1: {} + tough-cookie@4.1.3: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} tree-kill@1.2.2: {} 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 @@ -10767,7 +12754,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 @@ -10796,6 +12783,17 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -10833,6 +12831,8 @@ snapshots: universal-user-agent@7.0.3: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -10843,8 +12843,15 @@ snapshots: url-join@4.0.1: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + uuid@11.1.0: {} uuid@13.0.0: {} @@ -10855,6 +12862,12 @@ snapshots: vary@1.1.2: {} + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + 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): dependencies: esbuild: 0.27.3 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000000..b69da03be53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82", "I"] + +[tool.pytest.ini_options] +testpaths = ["skills"] +python_files = ["test_*.py"] diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs new file mode 100644 index 00000000000..af7b56a371f --- /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 00000000000..930bfe60a61 --- /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 0aa0773a5de..0749fc13f2d 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 9ce1a89f2db..0963d8d8499 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/make_appcast.sh b/scripts/make_appcast.sh index 437c68e8beb..df5c249caf3 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -19,7 +19,8 @@ ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then - if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + # Accept legacy calver suffixes like -1 and prerelease forms like -beta.1 / .beta.1. + if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?)\.zip$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 7e2bd449044..0ccc3efc1de 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,7 +22,12 @@ type PackageJson = { }; function normalizePluginSyncVersion(version: string): string { - return version.replace(/[-+].*$/, ""); + const normalized = version.trim().replace(/^v/, ""); + const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; + if (base) { + return base; + } + return normalized.replace(/[-+].*$/, ""); } function runPackDry(): PackResult[] { diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 0db3fad39b0..ba1aab336b6 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 bb0641df16b..3cc5ed2bf0b 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 1a7df857c7a..f3aecc0049a 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 bed23a431fd..35afef83c3f 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", @@ -43,22 +62,15 @@ const unitIsolatedFilesRaw = [ "src/agents/subagent-announce.format.test.ts", "src/infra/archive.test.ts", "src/cli/daemon-cli.coverage.test.ts", - "test/media-understanding.auto.test.ts", // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts", "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. "src/telegram/bot.create-telegram-bot.test.ts", @@ -76,14 +88,20 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS"; const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows"; const isWindowsCi = isCI && isWindows; +const hostCpuCount = os.cpus().length; +const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); +// Keep aggressive local defaults for high-memory workstations (Mac Studio class). +const highMemLocalHost = !isCI && hostMemoryGiB >= 96; +const lowMemLocalHost = !isCI && hostMemoryGiB < 64; const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); // vmForks is a big win for transform/import heavy suites, but Node 24 had -// regressions with Vitest's vm runtime in this repo. Keep it opt-out via +// regressions with Vitest's vm runtime in this repo, and low-memory local hosts +// are more likely to hit per-worker V8 heap ceilings. Keep it opt-out via // OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor !== 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" || - (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks); + (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks && !lowMemLocalHost); const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const runs = [ ...(useVmForks @@ -164,16 +182,16 @@ const testProfile = const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; -// Keep gateway serial on Windows CI and CI by default; run in parallel locally -// for lower wall-clock time. CI can opt in via OPENCLAW_TEST_PARALLEL_GATEWAY=1. +const parallelGatewayEnabled = + process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); +// Keep gateway serial by default except when explicitly requested or on high-memory local hosts. const keepGatewaySerial = isWindowsCi || process.env.OPENCLAW_TEST_SERIAL_GATEWAY === "1" || testProfile === "serial" || - (isCI && process.env.OPENCLAW_TEST_PARALLEL_GATEWAY !== "1"); + !parallelGatewayEnabled; const parallelRuns = keepGatewaySerial ? runs.filter((entry) => entry.name !== "gateway") : runs; const serialRuns = keepGatewaySerial ? runs.filter((entry) => entry.name === "gateway") : []; -const hostCpuCount = os.cpus().length; const baseLocalWorkers = Math.max(4, Math.min(16, hostCpuCount)); const loadAwareDisabledRaw = process.env.OPENCLAW_TEST_LOAD_AWARE?.trim().toLowerCase(); const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false"; @@ -189,7 +207,7 @@ const defaultWorkerBudget = ? { unit: 2, unitIsolated: 1, - extensions: 1, + extensions: 4, gateway: 1, } : testProfile === "serial" @@ -206,15 +224,29 @@ const defaultWorkerBudget = extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), } - : { - // Local `pnpm test` runs multiple vitest groups concurrently; - // bias workers toward unit-fast (wall-clock bottleneck) while - // keeping unit-isolated low enough that both groups finish closer together. - 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))), - }; + : highMemLocalHost + ? { + // High-memory local hosts can prioritize wall-clock speed. + 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(6, Math.floor(localWorkers / 2))), + } + : lowMemLocalHost + ? { + // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. + unit: 2, + unitIsolated: 1, + extensions: 4, + gateway: 1, + } + : { + // 64-95 GiB local hosts: conservative split with some parallel headroom. + unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), + unitIsolated: 1, + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: 1, + }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. // In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. @@ -304,9 +336,15 @@ const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { const maxWorkers = maxWorkersForRun(entry.name); const reporterArgs = buildReporterArgs(entry, extraArgs); + // vmForks with a single worker has shown cross-file leakage in extension suites. + // Fall back to process forks when we intentionally clamp that lane to one worker. + const entryArgs = + entry.name === "extensions" && maxWorkers === 1 && entry.args.includes("--pool=vmForks") + ? entry.args.map((arg) => (arg === "--pool=vmForks" ? "--pool=forks" : arg)) + : entry.args; const args = maxWorkers ? [ - ...entry.args, + ...entryArgs, "--maxWorkers", String(maxWorkers), ...silentArgs, @@ -314,7 +352,7 @@ const runOnce = (entry, extraArgs = []) => ...windowsCiArgs, ...extraArgs, ] - : [...entry.args, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; + : [...entryArgs, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b019..0e106e65969 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 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 key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { + continue; + } + apiByLogin.set(key, user); + + 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/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py index 0b71f96ea0f..ea28fa0d983 100644 --- a/skills/model-usage/scripts/model_usage.py +++ b/skills/model-usage/scripts/model_usage.py @@ -17,6 +17,16 @@ from datetime import date, datetime, timedelta from typing import Any, Dict, Iterable, List, Optional, Tuple +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return parsed + + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -239,7 +249,7 @@ def main() -> int: parser.add_argument("--mode", choices=["current", "all"], default="current") parser.add_argument("--model", help="Explicit model name to report instead of auto-current.") parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).") - parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).") + parser.add_argument("--days", type=positive_int, help="Limit to last N days (based on daily rows).") parser.add_argument("--format", choices=["text", "json"], default="text") parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") diff --git a/skills/model-usage/scripts/test_model_usage.py b/skills/model-usage/scripts/test_model_usage.py new file mode 100644 index 00000000000..4d5273401de --- /dev/null +++ b/skills/model-usage/scripts/test_model_usage.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Tests for model_usage helpers. +""" + +import argparse +from datetime import date, timedelta +from unittest import TestCase, main + +from model_usage import filter_by_days, positive_int + + +class TestModelUsage(TestCase): + def test_positive_int_accepts_valid_numbers(self): + self.assertEqual(positive_int("1"), 1) + self.assertEqual(positive_int("7"), 7) + + def test_positive_int_rejects_zero_and_negative(self): + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("0") + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("-3") + + def test_filter_by_days_keeps_recent_entries(self): + today = date.today() + entries = [ + {"date": (today - timedelta(days=5)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": today.strftime("%Y-%m-%d"), "modelBreakdowns": []}, + ] + + filtered = filter_by_days(entries, 2) + + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0]["date"], (today - timedelta(days=1)).strftime("%Y-%m-%d")) + self.assertEqual(filtered[1]["date"], today.strftime("%Y-%m-%d")) + + +if __name__ == "__main__": + main() diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index 3365c20077f..8d60882c456 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -95,12 +95,13 @@ def main(): max_input_dim = 0 for img_path in args.input_images: try: - img = PILImage.open(img_path) - input_images.append(img) + with PILImage.open(img_path) as img: + copied = img.copy() + width, height = copied.size + input_images.append(copied) print(f"Loaded input image: {img_path}") # Track largest dimension for auto-resolution - width, height = img.size max_input_dim = max(max_input_dim, width, height) except Exception as e: print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py index 7bd59e36126..4043f1a8ed7 100644 --- a/skills/openai-image-gen/scripts/gen.py +++ b/skills/openai-image-gen/scripts/gen.py @@ -9,6 +9,7 @@ import re import sys import urllib.error import urllib.request +from html import escape as html_escape from pathlib import Path @@ -131,8 +132,8 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: [ f"""
- -
{it["prompt"]}
+ +
{html_escape(it["prompt"])}
""".strip() for it in items @@ -152,7 +153,7 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: code {{ color: #9cd1ff; }}

openai-image-gen

-

Output: {out_dir.as_posix()}

+

Output: {html_escape(out_dir.as_posix())}

{thumbs}
diff --git a/skills/openai-image-gen/scripts/test_gen.py b/skills/openai-image-gen/scripts/test_gen.py new file mode 100644 index 00000000000..3f0a38d978f --- /dev/null +++ b/skills/openai-image-gen/scripts/test_gen.py @@ -0,0 +1,50 @@ +"""Tests for write_gallery HTML escaping (fixes #12538 - stored XSS).""" + +import tempfile +from pathlib import Path + +from gen import write_gallery + + +def test_write_gallery_escapes_prompt_xss(): + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) + items = [{"prompt": '', "file": "001-test.png"}] + write_gallery(out, items) + html = (out / "index.html").read_text() + assert "", 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 00000000000..ed5a99c8678 --- /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/presenter.ts b/ui/src/ui/presenter.ts index dbeaa687336..6f0fdc0ad4b 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -18,7 +18,8 @@ export function formatNextRun(ms?: number | null) { if (!ms) { return "n/a"; } - return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; + const weekday = new Date(ms).toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; } export function formatSessionTokens(row: GatewaySessionRow) { diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index f64c9da6dd6..d6fda9475c4 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/types.ts b/ui/src/ui/types.ts index 4413c23a58e..3c4091479b4 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -345,6 +345,35 @@ export type AgentsListResult = { agents: GatewayAgentRow[]; }; +export type ToolCatalogProfile = { + id: "minimal" | "coding" | "messaging" | "full"; + label: string; +}; + +export type ToolCatalogEntry = { + id: string; + label: string; + description: string; + source: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">; +}; + +export type ToolCatalogGroup = { + id: string; + label: string; + source: "core" | "plugin"; + pluginId?: string; + tools: ToolCatalogEntry[]; +}; + +export type ToolsCatalogResult = { + agentId: string; + profiles: ToolCatalogProfile[]; + groups: ToolCatalogGroup[]; +}; + export type AgentIdentityResult = { agentId: string; name: string; @@ -440,7 +469,7 @@ export type { export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } - | { kind: "cron"; expr: string; tz?: string }; + | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; @@ -450,6 +479,7 @@ export type CronPayload = | { kind: "agentTurn"; message: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -493,17 +523,58 @@ export type CronStatus = { nextWakeAtMs?: number | null; }; +export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +export type CronSortDir = "asc" | "desc"; +export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped"; +export type CronRunsStatusValue = "ok" | "error" | "skipped"; +export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; +export type CronRunScope = "job" | "all"; + export type CronRunLogEntry = { ts: number; jobId: string; - status: "ok" | "error" | "skipped"; + jobName?: string; + status?: CronRunsStatusValue; durationMs?: number; error?: string; summary?: string; + deliveryStatus?: CronDeliveryStatus; + deliveryError?: string; + delivered?: boolean; + runAtMs?: number; + nextRunAtMs?: number; + model?: string; + provider?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; sessionId?: string; sessionKey?: string; }; +export type CronJobsListResult = { + jobs?: CronJob[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + +export type CronRunsResult = { + entries?: CronRunLogEntry[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + export type SkillsStatusConfigCheck = { path: string; satisfied: boolean; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 724f2a92009..f1087546c79 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -18,19 +18,27 @@ export type CronFormState = { name: string; description: string; agentId: string; + clearAgent: boolean; enabled: boolean; + deleteAfterRun: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; everyUnit: "minutes" | "hours" | "days"; cronExpr: string; cronTz: string; + scheduleExact: boolean; + staggerAmount: string; + staggerUnit: "seconds" | "minutes"; sessionTarget: "main" | "isolated"; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; + payloadModel: string; + payloadThinking: string; deliveryMode: "none" | "announce" | "webhook"; deliveryChannel: string; deliveryTo: string; + deliveryBestEffort: boolean; timeoutSeconds: string; }; diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts new file mode 100644 index 00000000000..1917e982e44 --- /dev/null +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -0,0 +1,102 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderAgentTools } from "./agents-panels-tools-skills.ts"; + +function createBaseParams(overrides: Partial[0]> = {}) { + return { + agentId: "main", + configForm: { + agents: { + list: [{ id: "main", tools: { profile: "full" } }], + }, + } as Record, + configLoading: false, + configSaving: false, + configDirty: false, + toolsCatalogLoading: false, + toolsCatalogError: null, + toolsCatalogResult: null, + onProfileChange: () => undefined, + onOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + ...overrides, + }; +} + +describe("agents tools panel (browser)", () => { + it("renders per-tool provenance badges and optional marker", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogResult: { + agentId: "main", + profiles: [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, + ], + groups: [ + { + id: "media", + label: "Media", + source: "core", + tools: [ + { + id: "tts", + label: "tts", + description: "Text-to-speech conversion", + source: "core", + defaultProfiles: [], + }, + ], + }, + { + id: "plugin:voice-call", + label: "voice-call", + source: "plugin", + pluginId: "voice-call", + tools: [ + { + id: "voice_call", + label: "voice_call", + description: "Voice call tool", + source: "plugin", + pluginId: "voice-call", + optional: true, + defaultProfiles: [], + }, + ], + }, + ], + }, + }), + ), + container, + ); + await Promise.resolve(); + + const text = container.textContent ?? ""; + expect(text).toContain("core"); + expect(text).toContain("plugin:voice-call"); + expect(text).toContain("optional"); + }); + + it("shows fallback warning when runtime catalog fails", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogError: "unavailable", + toolsCatalogResult: null, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); + }); +}); diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 687ec749a62..4e25aaefc31 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; +import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; import { isAllowedByPolicy, matchesList, @@ -23,6 +23,9 @@ export function renderAgentTools(params: { configLoading: boolean; configSaving: boolean; configDirty: boolean; + toolsCatalogLoading: boolean; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; onConfigReload: () => void; @@ -50,7 +53,17 @@ export function renderAgentTools(params: { const basePolicy = hasAgentAllow ? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] } : (resolveToolProfile(profile) ?? undefined); - const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id)); + const sections = + params.toolsCatalogResult?.groups?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.groups + : TOOL_SECTIONS; + const profileOptions = + params.toolsCatalogResult?.profiles?.length && + params.toolsCatalogResult.agentId === params.agentId + ? params.toolsCatalogResult.profiles + : PROFILE_OPTIONS; + const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id)); const resolveAllowed = (toolId: string) => { const baseAllowed = isAllowedByPolicy(toolId, basePolicy); @@ -139,6 +152,15 @@ export function renderAgentTools(params: { + ${ + params.toolsCatalogError + ? html` +
+ Could not load runtime tool catalog. Showing fallback list. +
+ ` + : nothing + } ${ !params.configForm ? html` @@ -191,7 +213,7 @@ export function renderAgentTools(params: {
Quick Presets
- ${PROFILE_OPTIONS.map( + ${profileOptions.map( (option) => html` +
+
+ + + `; +} + +function renderSuggestionList(id: string, options: string[]) { + const clean = Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))); + if (clean.length === 0) { + return nothing; + } + return html` + ${clean.map((value) => html` `)} + `; +} + +type BlockingField = { + key: CronFieldKey; + label: string; + message: string; + inputId: string; +}; + +function errorIdForField(key: CronFieldKey) { + return `cron-error-${key}`; +} + +function inputIdForField(key: CronFieldKey) { + if (key === "name") { + return "cron-name"; + } + if (key === "scheduleAt") { + return "cron-schedule-at"; + } + if (key === "everyAmount") { + return "cron-every-amount"; + } + if (key === "cronExpr") { + return "cron-cron-expr"; + } + if (key === "staggerAmount") { + return "cron-stagger-amount"; + } + if (key === "payloadText") { + return "cron-payload-text"; + } + if (key === "payloadModel") { + return "cron-payload-model"; + } + if (key === "payloadThinking") { + return "cron-payload-thinking"; + } + if (key === "timeoutSeconds") { + return "cron-timeout-seconds"; + } + return "cron-delivery-to"; +} + +function fieldLabelForKey( + key: CronFieldKey, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +) { + if (key === "payloadText") { + return form.payloadKind === "systemEvent" ? "Main timeline message" : "Assistant task prompt"; + } + if (key === "deliveryTo") { + return deliveryMode === "webhook" ? "Webhook URL" : "To"; + } + const labels: Record = { + name: "Name", + scheduleAt: "Run at", + everyAmount: "Every", + cronExpr: "Expression", + staggerAmount: "Stagger window", + payloadText: "Payload text", + payloadModel: "Model", + payloadThinking: "Thinking", + timeoutSeconds: "Timeout (seconds)", + deliveryTo: "To", + }; + return labels[key]; +} + +function collectBlockingFields( + errors: CronFieldErrors, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +): BlockingField[] { + const orderedKeys: CronFieldKey[] = [ + "name", + "scheduleAt", + "everyAmount", + "cronExpr", + "staggerAmount", + "payloadText", + "payloadModel", + "payloadThinking", + "timeoutSeconds", + "deliveryTo", + ]; + const fields: BlockingField[] = []; + for (const key of orderedKeys) { + const message = errors[key]; + if (!message) { + continue; + } + fields.push({ + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }); + } + return fields; +} + +function focusFormField(id: string) { + const el = document.getElementById(id); + if (!(el instanceof HTMLElement)) { + return; + } + if (typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + el.focus(); +} + +function renderFieldLabel(text: string, required = false) { + return html` + ${text} + ${ + required + ? html` + + required + ` + : nothing + } + `; +} + export function renderCron(props: CronProps) { + const isEditing = Boolean(props.editingJobId); + const isAgentTurn = props.form.payloadKind === "agentTurn"; + const isCronSchedule = props.form.scheduleKind === "cron"; const channelOptions = buildChannelOptions(props); const selectedJob = props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); - const selectedRunTitle = selectedJob?.name ?? props.runsJobId ?? "(select a job)"; - const orderedRuns = props.runs.toSorted((a, b) => b.ts - a.ts); + const selectedRunTitle = + props.runsScope === "all" + ? "all jobs" + : (selectedJob?.name ?? props.runsJobId ?? "(select a job)"); + const runs = props.runs; + const selectedStatusLabels = RUN_STATUS_OPTIONS.filter((option) => + props.runsStatuses.includes(option.value), + ).map((option) => option.label); + const selectedDeliveryLabels = RUN_DELIVERY_OPTIONS.filter((option) => + props.runsDeliveryStatuses.includes(option.value), + ).map((option) => option.label); + const statusSummary = summarizeSelection(selectedStatusLabels, "All statuses"); + const deliverySummary = summarizeSelection(selectedDeliveryLabels, "All delivery"); const supportsAnnounce = props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; + const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); + const blockedByValidation = !props.busy && blockingFields.length > 0; + const submitDisabledReason = + blockedByValidation && !props.canSubmit + ? `Fix ${blockingFields.length} ${blockingFields.length === 1 ? "field" : "fields"} to continue.` + : ""; return html` -
-
-
Scheduler
-
Gateway-owned cron scheduler status.
-
-
-
Enabled
-
+
+
+
+
Enabled
+
+ ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} -
-
-
-
Jobs
-
${props.status?.jobs ?? "n/a"}
-
-
-
Next wake
-
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
-
- - ${props.error ? html`${props.error}` : nothing} +
+
Jobs
+
${props.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
+ + ${props.error ? html`${props.error}` : nothing} +
+
-
-
New Job
-
Create a scheduled wakeup or agent run.
-
- - - - - -
- ${renderScheduleFields(props)} -
- - - -
- -
- + + + +
+
+ +
+
Schedule
+
Control when this job runs.
+
+ +
+ ${renderScheduleFields(props)} +
+ +
+
Execution
+
Choose when to wake, and what this job should do.
+
+ + + ${ - supportsAnnounce + isAgentTurn ? html` - + ` : nothing } - - - - - ${ - props.form.payloadKind === "agentTurn" - ? html` - - ` - : nothing - } - ${ - selectedDeliveryMode !== "none" - ? html` - +
+ +
+ +
+
Delivery
+
Choose where run summaries are sent.
+
+ + ` : nothing } + + + +
Announce posts a summary to chat. None keeps execution internal.
+ + ${ + selectedDeliveryMode !== "none" + ? html` + + ${ + selectedDeliveryMode === "announce" + ? html` + + ` + : nothing + } + ${ + selectedDeliveryMode === "webhook" + ? renderFieldError( + props.fieldErrors.deliveryTo, + errorIdForField("deliveryTo"), + ) + : nothing + } + ` + : nothing + } +
+
+ +
+ Advanced +
+ Optional overrides for delivery guarantees, schedule jitter, and model controls. +
+
+ + + ${ + isCronSchedule + ? html` + +
+ + +
+ ` + : nothing + } + ${ + isAgentTurn + ? html` + + + ` + : nothing + } + ${ + selectedDeliveryMode !== "none" + ? html` + + ` + : nothing + } +
+
+ + ${ + blockedByValidation + ? html` +
+
Can't add job yet
+
Fill the required fields below to enable submit.
+
    + ${blockingFields.map( + (field) => html` +
  • + +
  • + `, + )} +
+
+ ` + : nothing + } +
+ + ${ + submitDisabledReason + ? html`
${submitDisabledReason}
` + : nothing + } + ${ + isEditing + ? html` + ` : nothing }
-
- -
- + -
-
Jobs
-
All scheduled jobs stored in the gateway.
- ${ - props.jobs.length === 0 - ? html` -
No jobs yet.
- ` - : html` -
- ${props.jobs.map((job) => renderJob(job, props))} -
- ` - } -
- -
-
Run history
-
Latest runs for ${selectedRunTitle}.
- ${ - props.runsJobId == null - ? html` -
Select a job to inspect run history.
- ` - : orderedRuns.length === 0 - ? html` -
No runs yet.
- ` - : html` -
- ${orderedRuns.map((entry) => renderRun(entry, props.basePath))} -
- ` - } -
+ ${renderSuggestionList("cron-agent-suggestions", props.agentSuggestions)} + ${renderSuggestionList("cron-model-suggestions", props.modelSuggestions)} + ${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)} + ${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)} + ${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)} `; } @@ -346,31 +1117,44 @@ function renderScheduleFields(props: CronProps) { const form = props.form; if (form.scheduleKind === "at") { return html` -