diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index fc0d97d4091..eff0993b466 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -172,6 +172,9 @@ jobs:
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
+ if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
+ tags+=("${IMAGE}:latest")
+ fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6efa7d35cb3..cfb74303243 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,31 +2,76 @@
Docs: https://docs.openclaw.ai
-## 2026.2.25 (Unreleased)
+## 2026.2.25
### 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.
+- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
+- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
+- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics.
+- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
- 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.
+- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
+
+### Breaking
+
+- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
### 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)
+- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
+- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
+- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl.
+- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
+- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
- 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.
+- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
+- LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
+- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
+- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
+- 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.
+- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
-- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
-- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
+- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
- 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.
+- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
+- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
+- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007)
+- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
+- 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)
+- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
+- 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.
+- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed.
+- 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.
+- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
+- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
+- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
+- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
+- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
+- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
+- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
+- 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/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) 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/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting.
- 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
diff --git a/PR_STATUS.md b/PR_STATUS.md
deleted file mode 100644
index 1887eca27d9..00000000000
--- a/PR_STATUS.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# 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/appcast.xml b/appcast.xml
index 902d60972fd..f5eb1699934 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -209,106 +209,84 @@
-
-
2026.2.24
- Wed, 25 Feb 2026 02:59:30 +0000
+ 2026.2.25
+ Thu, 26 Feb 2026 05:14:17 +0100
https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 14728
- 2026.2.24
+ 14883
+ 2026.2.25
15.0
- OpenClaw 2026.2.24
+ OpenClaw 2026.2.25
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.
+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.
+Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
+UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
+Heartbeat/Config: replace heartbeat DM toggle with agents.defaults.heartbeat.directPolicy (allow | block; also supported per-agent via agents.list[].heartbeat.directPolicy) for clearer delivery semantics.
+Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
+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.
+Dependencies: update workspace dependency pins and lockfile (Bedrock SDK 3.998.0, @mariozechner/pi-* 0.55.1, TypeScript native preview 7.0.0-dev.20260225.1) while 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.
+BREAKING: Heartbeat direct/DM delivery default is now allow again. To keep DM-blocked behavior from 2026.2.24, set agents.defaults.heartbeat.directPolicy: "block" (or per-agent override).
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.
-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.
+Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without message_id as delivery failures (instead of false-success "unknown" IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
+Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
+Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable session.parentForkMaxTokens (default 100000, 0 disables). (#26912) Thanks @markshields-tl.
+Cron/Message multi-account routing: honor explicit delivery.accountId for isolated cron delivery resolution, and when message.send omits accountId, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
+Gateway/Message media roots: thread agentId through gateway send RPC and prefer explicit agentId over session/default resolution so non-default agent workspace media sends no longer fail with LocalMediaAccessError; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
+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.
+Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed delivered, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
+LINE/Lifecycle: keep LINE startAccount pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
+Discord/Gateway: capture and drain startup-time gateway error events before lifecycle listeners attach so early Fatal Gateway error: 4014 closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
+Discord/Inbound text: preserve embed title + description fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
+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.
+Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
+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.
+Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example c0abc12345) correctly match Slack runtime IDs (C0ABC12345) under groupPolicy: "allowlist", preventing silent channel-event drops. (#26878) Thanks @lbo728.
+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.
+Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including NO_REPLY, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
+Voice-call/TTS tools: hide the tts tool when the message provider is voice, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
+Agents/Tools: normalize non-standard plugin tool results that omit content so embedded runs no longer crash with Cannot read properties of undefined (reading 'filter') after tool completion (including tesseramemo_query). (#27007)
+Cron/Model overrides: when isolated payload.model is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
+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)
+Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of disabledReason only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for rate_limit. (#23816) thanks @ramezgaberiel.
+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.
+Models/Auth probes: map permanent auth failover reasons (auth_permanent, for example revoked keys) into probe auth status instead of unknown, so openclaw models status --probe reports actionable auth failures. (#25754) thanks @rrenamed.
+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.
+Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
+Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (2026.2.25). Thanks @luz-oasis for reporting.
+Security/Gateway trusted proxy: require operator role for the Control UI trusted-proxy pairing bypass so unpaired node sessions can no longer connect via client.id=control-ui and invoke node event methods. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy oauth.json onboarding path that exposed the PKCE verifier via OAuth state; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (2026.2.25). Thanks @zdi-disclosures for reporting.
+Security/Microsoft Teams file consent: bind fileConsent/invoke upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked uploadId values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Gateway: harden agents.files path handling to block out-of-workspace symlink targets for agents.files.get/agents.files.set, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
+Security/Workspace FS: reject hardlinked workspace file aliases in tools.fs.workspaceOnly and tools.exec.applyPatch.workspaceOnly boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before setFiles, with regression coverage for strict missing-path handling.
+Security/Exec approvals: bind system.run approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Exec approvals: harden approval-bound system.run execution on node hosts by rejecting symlink cwd paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under dmPolicy/groupPolicy; reaction notifications now require channel access checks first. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild groupPolicy channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Slack reactions + pins: gate reaction_* and pin_* system-event enqueue through shared sender authorization so DM dmPolicy/allowFrom and channel users allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Telegram reactions: enforce dmPolicy/allowFrom and group allowlist authorization on message_reaction events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Slack interactions: enforce channel/DM authorization and modal actor binding (private_metadata.userId) before enqueueing block_action/view_submission/view_closed system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
+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/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) 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/SSRF guard: classify IPv6 multicast literals (ff00::/8) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (2026.2.25). Thanks @zpbrent for reporting.
+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.
View full changelog
]]>
-
+
\ No newline at end of file
diff --git a/apps/android/README.md b/apps/android/README.md
index 799109c0a0f..4a9951e6441 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -34,6 +34,40 @@ cd apps/android
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
+## Macrobenchmark (Startup + Frame Timing)
+
+```bash
+cd apps/android
+./gradlew :benchmark:connectedDebugAndroidTest
+```
+
+Reports are written under:
+
+- `apps/android/benchmark/build/reports/androidTests/connected/`
+
+## Perf CLI (low-noise)
+
+Deterministic startup measurement + hotspot extraction with compact CLI output:
+
+```bash
+cd apps/android
+./scripts/perf-startup-benchmark.sh
+./scripts/perf-startup-hotspots.sh
+```
+
+Benchmark script behavior:
+
+- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
+- Prints median/min/max/COV in one line.
+- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
+- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline `).
+
+Hotspot script behavior:
+
+- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
+- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
+- Writes raw `perf.data` path for deeper follow-up if needed.
+
## Run on a Real Android Phone (USB)
1) On phone, enable **Developer options** + **USB debugging**.
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 21d0f15ff7a..b90427672c6 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt
@@ -1,9 +1,7 @@
package ai.openclaw.android
-import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.view.WindowManager
-import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@@ -25,9 +23,6 @@ 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)
- NodeForegroundService.start(this)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
@@ -55,6 +50,9 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ // Keep startup path lean: start foreground service after first frame.
+ window.decorView.post { NodeForegroundService.start(this) }
}
override fun onStart() {
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
index 2be9ee71a2c..ab5e159cf47 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
@@ -2,23 +2,12 @@ package ai.openclaw.android
import android.app.Application
import android.os.StrictMode
-import android.util.Log
-import java.security.Security
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
- // Register Bouncy Castle as highest-priority provider for Ed25519 support
- try {
- val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
- .getDeclaredConstructor().newInstance() as java.security.Provider
- Security.removeProvider("BC")
- Security.insertProviderAt(bcProvider, 1)
- } catch (it: Throwable) {
- Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
- }
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
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 15d99ffb931..02e9b136091 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
@@ -450,6 +450,10 @@ class NodeRuntime(context: Context) {
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
}
+ scope.launch {
+ prefs.loadGatewayToken()
+ }
+
scope.launch {
prefs.talkEnabled.collect { enabled ->
micCapture.setMicEnabled(enabled)
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 f03e2b56e0b..96e4572955e 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
@@ -20,19 +20,21 @@ class SecurePrefs(context: Context) {
val defaultWakeWords: List = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val voiceWakeModeKey = "voiceWake.mode"
+ private const val plainPrefsName = "openclaw.node"
+ private const val securePrefsName = "openclaw.node.secure"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
+ private val plainPrefs: SharedPreferences =
+ appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
- private val masterKey =
- MasterKey.Builder(context)
+ private val masterKey by lazy {
+ MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
-
- private val prefs: SharedPreferences by lazy {
- createPrefs(appContext, "openclaw.node.secure")
}
+ private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow = _instanceId
@@ -41,52 +43,51 @@ class SecurePrefs(context: Context) {
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow = _displayName
- private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
+ private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow = _cameraEnabled
private val _locationMode =
- MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
+ MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow = _locationMode
private val _locationPreciseEnabled =
- MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
+ MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow = _locationPreciseEnabled
- private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
+ private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow = _preventSleep
private val _manualEnabled =
- MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
+ MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow = _manualEnabled
private val _manualHost =
- MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
+ MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow = _manualHost
private val _manualPort =
- MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
+ MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow = _manualPort
private val _manualTls =
- MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
+ MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow = _manualTls
- private val _gatewayToken =
- MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
+ private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow = _gatewayToken
private val _onboardingCompleted =
- MutableStateFlow(prefs.getBoolean("onboarding.completed", false))
+ MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
- prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
+ plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
- MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
+ MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
@@ -95,65 +96,65 @@ class SecurePrefs(context: Context) {
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow = _voiceWakeMode
- private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
+ private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow = _talkEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
- prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
+ plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
- prefs.edit { putString(displayNameKey, trimmed) }
+ plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
- prefs.edit { putBoolean("camera.enabled", value) }
+ plainPrefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
- prefs.edit { putString("location.enabledMode", mode.rawValue) }
+ plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
- prefs.edit { putBoolean("location.preciseEnabled", value) }
+ plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
- prefs.edit { putBoolean("screen.preventSleep", value) }
+ plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
- prefs.edit { putBoolean("gateway.manual.enabled", value) }
+ plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
- prefs.edit { putString("gateway.manual.host", trimmed) }
+ plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
- prefs.edit { putInt("gateway.manual.port", value) }
+ plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
- prefs.edit { putBoolean("gateway.manual.tls", value) }
+ plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setGatewayToken(value: String) {
val trimmed = value.trim()
- prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) }
+ securePrefs.edit { putString("gateway.manual.token", trimmed) }
_gatewayToken.value = trimmed
}
@@ -162,62 +163,67 @@ class SecurePrefs(context: Context) {
}
fun setOnboardingCompleted(value: Boolean) {
- prefs.edit { putBoolean("onboarding.completed", value) }
+ plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
- prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
+ plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadGatewayToken(): String? {
- val manual = _gatewayToken.value.trim()
+ val manual =
+ _gatewayToken.value.trim().ifEmpty {
+ val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
+ if (stored.isNotEmpty()) _gatewayToken.value = stored
+ stored
+ }
if (manual.isNotEmpty()) return manual
val key = "gateway.token.${_instanceId.value}"
- val stored = prefs.getString(key, null)?.trim()
+ val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
- prefs.edit { putString(key, token.trim()) }
+ securePrefs.edit { putString(key, token.trim()) }
}
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
- val stored = prefs.getString(key, null)?.trim()
+ val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
- prefs.edit { putString(key, password.trim()) }
+ securePrefs.edit { putString(key, password.trim()) }
}
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
- return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
+ return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
- prefs.edit { putString(key, fingerprint.trim()) }
+ plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
- return prefs.getString(key, null)
+ return securePrefs.getString(key, null)
}
fun putString(key: String, value: String) {
- prefs.edit { putString(key, value) }
+ securePrefs.edit { putString(key, value) }
}
fun remove(key: String) {
- prefs.edit { remove(key) }
+ securePrefs.edit { remove(key) }
}
- private fun createPrefs(context: Context, name: String): SharedPreferences {
+ private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
name,
@@ -228,21 +234,21 @@ class SecurePrefs(context: Context) {
}
private fun loadOrCreateInstanceId(): String {
- val existing = prefs.getString("node.instanceId", null)?.trim()
+ val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
- prefs.edit { putString("node.instanceId", fresh) }
+ plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
- val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
+ val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
- prefs.edit { putString(displayNameKey, resolved) }
+ plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -250,34 +256,34 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
- prefs.edit { putString("voiceWake.triggerWords", encoded) }
+ plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
- prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
+ plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
fun setTalkEnabled(value: Boolean) {
- prefs.edit { putBoolean("talk.enabled", value) }
+ plainPrefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
- val raw = prefs.getString(voiceWakeModeKey, null)
+ val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
- prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
+ plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List {
- val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
+ val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords
return try {
val element = json.parseToJsonElement(raw)
@@ -295,5 +301,4 @@ class SecurePrefs(context: Context) {
defaultWakeWords
}
}
-
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
index ff651c6c17b..68830772f9a 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
@@ -3,11 +3,7 @@ package ai.openclaw.android.gateway
import android.content.Context
import android.util.Base64
import java.io.File
-import java.security.KeyFactory
-import java.security.KeyPairGenerator
import java.security.MessageDigest
-import java.security.Signature
-import java.security.spec.PKCS8EncodedKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -22,21 +18,26 @@ data class DeviceIdentity(
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
+ @Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
fun loadOrCreate(): DeviceIdentity {
+ cachedIdentity?.let { return it }
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
+ cachedIdentity = updated
return updated
}
+ cachedIdentity = existing
return existing
}
val fresh = generate()
save(fresh)
+ cachedIdentity = fresh
return fresh
}
@@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) {
}
}
- private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
- if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
- spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
- ) {
- return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
- }
- return spki
- }
-
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
- val out = StringBuilder(digest.size * 2)
+ val out = CharArray(digest.size * 2)
+ var i = 0
for (byte in digest) {
- out.append(String.format("%02x", byte))
+ val v = byte.toInt() and 0xff
+ out[i++] = HEX[v ushr 4]
+ out[i++] = HEX[v and 0x0f]
}
- return out.toString()
+ return String(out)
}
private fun base64UrlEncode(data: ByteArray): String {
@@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) {
}
companion object {
- private val ED25519_SPKI_PREFIX =
- byteArrayOf(
- 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
- )
+ private val HEX = "0123456789abcdef".toCharArray()
}
}
diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts
new file mode 100644
index 00000000000..99d1d8e4c60
--- /dev/null
+++ b/apps/android/benchmark/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ id("com.android.test")
+}
+
+android {
+ namespace = "ai.openclaw.android.benchmark"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 31
+ targetSdk = 36
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
+ }
+
+ targetProjectPath = ":app"
+ experimentalProperties["android.experimental.self-instrumenting"] = true
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
+}
+
+dependencies {
+ implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
+ implementation("androidx.test.ext:junit:1.2.1")
+ implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06")
+}
diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt
new file mode 100644
index 00000000000..46181f6a9a1
--- /dev/null
+++ b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt
@@ -0,0 +1,76 @@
+package ai.openclaw.android.benchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import org.junit.Assume.assumeTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class StartupMacrobenchmark {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ private val packageName = "ai.openclaw.android"
+
+ @Test
+ fun coldStartup() {
+ runBenchmarkOrSkip {
+ benchmarkRule.measureRepeated(
+ packageName = packageName,
+ metrics = listOf(StartupTimingMetric()),
+ startupMode = StartupMode.COLD,
+ compilationMode = CompilationMode.None(),
+ iterations = 10,
+ ) {
+ pressHome()
+ startActivityAndWait()
+ }
+ }
+ }
+
+ @Test
+ fun startupAndScrollFrameTiming() {
+ runBenchmarkOrSkip {
+ benchmarkRule.measureRepeated(
+ packageName = packageName,
+ metrics = listOf(FrameTimingMetric()),
+ startupMode = StartupMode.WARM,
+ compilationMode = CompilationMode.None(),
+ iterations = 10,
+ ) {
+ startActivityAndWait()
+ val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ val x = device.displayWidth / 2
+ val yStart = (device.displayHeight * 0.8f).toInt()
+ val yEnd = (device.displayHeight * 0.25f).toInt()
+ repeat(4) {
+ device.swipe(x, yStart, x, yEnd, 24)
+ device.waitForIdle()
+ }
+ }
+ }
+ }
+
+ private fun runBenchmarkOrSkip(run: () -> Unit) {
+ try {
+ run()
+ } catch (err: IllegalStateException) {
+ val message = err.message.orEmpty()
+ val knownDeviceIssue =
+ message.contains("Unable to confirm activity launch completion") ||
+ message.contains("no renderthread slices", ignoreCase = true)
+ if (knownDeviceIssue) {
+ assumeTrue("Skipping benchmark on this device: $message", false)
+ }
+ throw err
+ }
+ }
+}
diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts
index bea7b46b2c2..1d191c9e375 100644
--- a/apps/android/build.gradle.kts
+++ b/apps/android/build.gradle.kts
@@ -1,5 +1,6 @@
plugins {
id("com.android.application") version "9.0.1" apply false
+ id("com.android.test") 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/scripts/perf-startup-benchmark.sh b/apps/android/scripts/perf-startup-benchmark.sh
new file mode 100755
index 00000000000..70342d3cba4
--- /dev/null
+++ b/apps/android/scripts/perf-startup-benchmark.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+RESULTS_DIR="$ANDROID_DIR/benchmark/results"
+CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup"
+BASELINE_JSON=""
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/perf-startup-benchmark.sh [--baseline ]
+
+Runs cold-start macrobenchmark only, then prints a compact summary.
+Also saves a timestamped snapshot JSON under benchmark/results/.
+If --baseline is omitted, compares against latest previous snapshot when available.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --baseline)
+ BASELINE_JSON="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown arg: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "jq required but missing." >&2
+ exit 1
+fi
+
+if ! command -v adb >/dev/null 2>&1; then
+ echo "adb required but missing." >&2
+ exit 1
+fi
+
+device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
+if [[ "$device_count" -lt 1 ]]; then
+ echo "No connected Android device (adb state=device)." >&2
+ exit 1
+fi
+
+mkdir -p "$RESULTS_DIR"
+
+run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)"
+trap 'rm -f "$run_log"' EXIT
+
+cd "$ANDROID_DIR"
+
+./gradlew :benchmark:connectedDebugAndroidTest \
+ -Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \
+ --console=plain \
+ >"$run_log" 2>&1
+
+latest_json="$(
+ find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \
+ -name '*benchmarkData.json' -type f \
+ | while IFS= read -r file; do
+ printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
+ done \
+ | sort -nr \
+ | head -n1 \
+ | cut -f2-
+)"
+
+if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then
+ echo "benchmarkData.json not found after run." >&2
+ tail -n 120 "$run_log" >&2
+ exit 1
+fi
+
+timestamp="$(date +%Y%m%d-%H%M%S)"
+snapshot_json="$RESULTS_DIR/startup-$timestamp.json"
+cp "$latest_json" "$snapshot_json"
+
+median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")"
+min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")"
+max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")"
+cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")"
+device="$(jq -r '.context.build.model' "$snapshot_json")"
+sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")"
+runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")"
+
+printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \
+ "$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk"
+echo "snapshot_json=$snapshot_json"
+
+if [[ -z "$BASELINE_JSON" ]]; then
+ BASELINE_JSON="$(
+ find "$RESULTS_DIR" -name 'startup-*.json' -type f \
+ | while IFS= read -r file; do
+ if [[ "$file" == "$snapshot_json" ]]; then
+ continue
+ fi
+ printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
+ done \
+ | sort -nr \
+ | head -n1 \
+ | cut -f2-
+ )"
+fi
+
+if [[ -n "$BASELINE_JSON" ]]; then
+ if [[ ! -f "$BASELINE_JSON" ]]; then
+ echo "Baseline file missing: $BASELINE_JSON" >&2
+ exit 1
+ fi
+ base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")"
+ delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')"
+ delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')"
+ echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%"
+fi
diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh
new file mode 100755
index 00000000000..787d5fac300
--- /dev/null
+++ b/apps/android/scripts/perf-startup-hotspots.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+
+PACKAGE="ai.openclaw.android"
+ACTIVITY=".MainActivity"
+DURATION_SECONDS="10"
+OUTPUT_PERF_DATA=""
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/perf-startup-hotspots.sh [--package ] [--activity ] [--duration ] [--out ]
+
+Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries.
+Default package/activity target OpenClaw Android startup.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --package)
+ PACKAGE="${2:-}"
+ shift 2
+ ;;
+ --activity)
+ ACTIVITY="${2:-}"
+ shift 2
+ ;;
+ --duration)
+ DURATION_SECONDS="${2:-}"
+ shift 2
+ ;;
+ --out)
+ OUTPUT_PERF_DATA="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown arg: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if ! command -v uv >/dev/null 2>&1; then
+ echo "uv required but missing." >&2
+ exit 1
+fi
+
+if ! command -v adb >/dev/null 2>&1; then
+ echo "adb required but missing." >&2
+ exit 1
+fi
+
+if [[ -z "$OUTPUT_PERF_DATA" ]]; then
+ OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data"
+fi
+
+device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
+if [[ "$device_count" -lt 1 ]]; then
+ echo "No connected Android device (adb state=device)." >&2
+ exit 1
+fi
+
+simpleperf_dir=""
+if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf"
+elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf"
+else
+ latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)"
+ if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="$latest_simpleperf"
+ fi
+fi
+
+if [[ -z "$simpleperf_dir" ]]; then
+ echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2
+ exit 1
+fi
+
+app_profiler="$simpleperf_dir/app_profiler.py"
+report_py="$simpleperf_dir/report.py"
+ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)"
+
+tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+capture_log="$tmp_dir/capture.log"
+dso_csv="$tmp_dir/dso.csv"
+symbols_csv="$tmp_dir/symbols.csv"
+children_txt="$tmp_dir/children.txt"
+
+cd "$ANDROID_DIR"
+./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1
+
+if ! uv run --no-project python3 "$app_profiler" \
+ -p "$PACKAGE" \
+ -a "$ACTIVITY" \
+ -o "$OUTPUT_PERF_DATA" \
+ --ndk_path "$ndk_path" \
+ -r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \
+ >"$capture_log" 2>&1; then
+ echo "simpleperf capture failed. tail(capture_log):" >&2
+ tail -n 120 "$capture_log" >&2
+ exit 1
+fi
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --sort dso \
+ --csv \
+ --csv-separator "|" \
+ --include-process-name "$PACKAGE" \
+ >"$dso_csv" 2>"$tmp_dir/report-dso.err"
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --sort dso,symbol \
+ --csv \
+ --csv-separator "|" \
+ --include-process-name "$PACKAGE" \
+ >"$symbols_csv" 2>"$tmp_dir/report-symbols.err"
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --children \
+ --sort dso,symbol \
+ -n \
+ --percent-limit 0.2 \
+ --include-process-name "$PACKAGE" \
+ >"$children_txt" 2>"$tmp_dir/report-children.err"
+
+clean_csv() {
+ awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1"
+}
+
+echo "perf_data=$OUTPUT_PERF_DATA"
+echo
+echo "top_dso_self:"
+clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}'
+echo
+echo "top_symbols_self:"
+clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}'
+echo
+echo "app_path_clues_children:"
+rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true
diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts
index b3b43a44550..25e5d09bbe1 100644
--- a/apps/android/settings.gradle.kts
+++ b/apps/android/settings.gradle.kts
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawNodeAndroid"
include(":app")
+include(":benchmark")
diff --git a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift b/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
deleted file mode 100644
index 06f107d6c6e..00000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
+++ /dev/null
@@ -1,234 +0,0 @@
-import AppKit
-import Combine
-import SwiftUI
-
-@MainActor
-struct AnthropicAuthControls: View {
- let connectionMode: AppState.ConnectionMode
-
- @State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
- @State private var pkce: AnthropicOAuth.PKCE?
- @State private var code: String = ""
- @State private var busy = false
- @State private var statusText: String?
- @State private var autoDetectClipboard = true
- @State private var autoConnectClipboard = true
- @State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
-
- private static let clipboardPoll: AnyPublisher = {
- if ProcessInfo.processInfo.isRunningTests {
- return Empty(completeImmediately: false).eraseToAnyPublisher()
- }
- return Timer.publish(every: 0.4, on: .main, in: .common)
- .autoconnect()
- .eraseToAnyPublisher()
- }()
-
- var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- if self.connectionMode != .local {
- Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- HStack(spacing: 10) {
- Circle()
- .fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
- .frame(width: 8, height: 8)
- Text(self.oauthStatus.shortDescription)
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
- Spacer()
- Button("Reveal") {
- NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
- }
- .buttonStyle(.bordered)
- .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
-
- Button("Refresh") {
- self.refresh()
- }
- .buttonStyle(.bordered)
- }
-
- Text(OpenClawOAuthStore.oauthURL().path)
- .font(.caption.monospaced())
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .textSelection(.enabled)
-
- HStack(spacing: 12) {
- Button {
- self.startOAuth()
- } label: {
- if self.busy {
- ProgressView().controlSize(.small)
- } else {
- Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.connectionMode != .local || self.busy)
-
- if self.pkce != nil {
- Button("Cancel") {
- self.pkce = nil
- self.code = ""
- self.statusText = nil
- }
- .buttonStyle(.bordered)
- .disabled(self.busy)
- }
- }
-
- if self.pkce != nil {
- VStack(alignment: .leading, spacing: 8) {
- Text("Paste `code#state`")
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
-
- TextField("code#state", text: self.$code)
- .textFieldStyle(.roundedBorder)
- .disabled(self.busy)
-
- Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .disabled(self.busy)
-
- Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .disabled(self.busy)
-
- Button("Connect") {
- Task { await self.finishOAuth() }
- }
- .buttonStyle(.bordered)
- .disabled(self.busy || self.connectionMode != .local || self.code
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .isEmpty)
- }
- }
-
- if let statusText, !statusText.isEmpty {
- Text(statusText)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- .onAppear {
- self.refresh()
- }
- .onReceive(Self.clipboardPoll) { _ in
- self.pollClipboardIfNeeded()
- }
- }
-
- private func refresh() {
- let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
- self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
- if imported != nil {
- self.statusText = "Imported existing OAuth credentials."
- }
- }
-
- private func startOAuth() {
- guard self.connectionMode == .local else { return }
- guard !self.busy else { return }
- self.busy = true
- defer { self.busy = false }
-
- do {
- let pkce = try AnthropicOAuth.generatePKCE()
- self.pkce = pkce
- let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
- NSWorkspace.shared.open(url)
- self.statusText = "Browser opened. After approving, paste the `code#state` value here."
- } catch {
- self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
- }
- }
-
- @MainActor
- private func finishOAuth() async {
- guard self.connectionMode == .local else { return }
- guard !self.busy else { return }
- guard let pkce = self.pkce else { return }
- self.busy = true
- defer { self.busy = false }
-
- guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
- self.statusText = "OAuth failed: missing or invalid code/state."
- return
- }
-
- do {
- let creds = try await AnthropicOAuth.exchangeCode(
- code: parsed.code,
- state: parsed.state,
- verifier: pkce.verifier)
- try OpenClawOAuthStore.saveAnthropicOAuth(creds)
- self.refresh()
- self.pkce = nil
- self.code = ""
- self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
- } catch {
- self.statusText = "OAuth failed: \(error.localizedDescription)"
- }
- }
-
- private func pollClipboardIfNeeded() {
- guard self.connectionMode == .local else { return }
- guard self.pkce != nil else { return }
- guard !self.busy else { return }
- guard self.autoDetectClipboard else { return }
-
- let pb = NSPasteboard.general
- let changeCount = pb.changeCount
- guard changeCount != self.lastPasteboardChangeCount else { return }
- self.lastPasteboardChangeCount = changeCount
-
- guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
- guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
- guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
-
- let next = "\(parsed.code)#\(parsed.state)"
- if self.code != next {
- self.code = next
- self.statusText = "Detected `code#state` from clipboard."
- }
-
- guard self.autoConnectClipboard else { return }
- Task { await self.finishOAuth() }
- }
-}
-
-#if DEBUG
-extension AnthropicAuthControls {
- init(
- connectionMode: AppState.ConnectionMode,
- oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
- pkce: AnthropicOAuth.PKCE? = nil,
- code: String = "",
- busy: Bool = false,
- statusText: String? = nil,
- autoDetectClipboard: Bool = true,
- autoConnectClipboard: Bool = true)
- {
- self.connectionMode = connectionMode
- self._oauthStatus = State(initialValue: oauthStatus)
- self._pkce = State(initialValue: pkce)
- self._code = State(initialValue: code)
- self._busy = State(initialValue: busy)
- self._statusText = State(initialValue: statusText)
- self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
- self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
- self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
- }
-}
-#endif
diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
deleted file mode 100644
index f594cc04c31..00000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
+++ /dev/null
@@ -1,383 +0,0 @@
-import CryptoKit
-import Foundation
-import OSLog
-import Security
-
-struct AnthropicOAuthCredentials: Codable {
- let type: String
- let refresh: String
- let access: String
- let expires: Int64
-}
-
-enum AnthropicAuthMode: Equatable {
- case oauthFile
- case oauthEnv
- case apiKeyEnv
- case missing
-
- var shortLabel: String {
- switch self {
- case .oauthFile: "OAuth (OpenClaw token file)"
- case .oauthEnv: "OAuth (env var)"
- case .apiKeyEnv: "API key (env var)"
- case .missing: "Missing credentials"
- }
- }
-
- var isConfigured: Bool {
- switch self {
- case .missing: false
- case .oauthFile, .oauthEnv, .apiKeyEnv: true
- }
- }
-}
-
-enum AnthropicAuthResolver {
- static func resolve(
- environment: [String: String] = ProcessInfo.processInfo.environment,
- oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore
- .anthropicOAuthStatus()) -> AnthropicAuthMode
- {
- if oauthStatus.isConnected { return .oauthFile }
-
- if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !token.isEmpty
- {
- return .oauthEnv
- }
-
- if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !key.isEmpty
- {
- return .apiKeyEnv
- }
-
- return .missing
- }
-}
-
-enum AnthropicOAuth {
- private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth")
-
- private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
- private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
- private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
- private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
- private static let scopes = "org:create_api_key user:profile user:inference"
-
- struct PKCE {
- let verifier: String
- let challenge: String
- }
-
- static func generatePKCE() throws -> PKCE {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- guard status == errSecSuccess else {
- throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
- }
- let verifier = Data(bytes).base64URLEncodedString()
- let hash = SHA256.hash(data: Data(verifier.utf8))
- let challenge = Data(hash).base64URLEncodedString()
- return PKCE(verifier: verifier, challenge: challenge)
- }
-
- static func buildAuthorizeURL(pkce: PKCE) -> URL {
- var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
- components.queryItems = [
- URLQueryItem(name: "code", value: "true"),
- URLQueryItem(name: "client_id", value: self.clientId),
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "redirect_uri", value: self.redirectURI),
- URLQueryItem(name: "scope", value: self.scopes),
- URLQueryItem(name: "code_challenge", value: pkce.challenge),
- URLQueryItem(name: "code_challenge_method", value: "S256"),
- // Match legacy flow: state is the verifier.
- URLQueryItem(name: "state", value: pkce.verifier),
- ]
- return components.url!
- }
-
- static func exchangeCode(
- code: String,
- state: String,
- verifier: String) async throws -> AnthropicOAuthCredentials
- {
- let payload: [String: Any] = [
- "grant_type": "authorization_code",
- "client_id": self.clientId,
- "code": code,
- "state": state,
- "redirect_uri": self.redirectURI,
- "code_verifier": verifier,
- ]
- let body = try JSONSerialization.data(withJSONObject: payload, options: [])
-
- var request = URLRequest(url: self.tokenURL)
- request.httpMethod = "POST"
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw URLError(.badServerResponse)
- }
- guard (200..<300).contains(http.statusCode) else {
- let text = String(data: data, encoding: .utf8) ?? ""
- throw NSError(
- domain: "AnthropicOAuth",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
- }
-
- let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
- let access = decoded?["access_token"] as? String
- let refresh = decoded?["refresh_token"] as? String
- let expiresIn = decoded?["expires_in"] as? Double
- guard let access, let refresh, let expiresIn else {
- throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
- NSLocalizedDescriptionKey: "Unexpected token response.",
- ])
- }
-
- // Match legacy flow: expiresAt = now + expires_in - 5 minutes.
- let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
- + Int64(expiresIn * 1000)
- - Int64(5 * 60 * 1000)
-
- self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
- return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
- }
-
- static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
- let payload: [String: Any] = [
- "grant_type": "refresh_token",
- "client_id": self.clientId,
- "refresh_token": refreshToken,
- ]
- let body = try JSONSerialization.data(withJSONObject: payload, options: [])
-
- var request = URLRequest(url: self.tokenURL)
- request.httpMethod = "POST"
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw URLError(.badServerResponse)
- }
- guard (200..<300).contains(http.statusCode) else {
- let text = String(data: data, encoding: .utf8) ?? ""
- throw NSError(
- domain: "AnthropicOAuth",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
- }
-
- let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
- let access = decoded?["access_token"] as? String
- let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
- let expiresIn = decoded?["expires_in"] as? Double
- guard let access, let expiresIn else {
- throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
- NSLocalizedDescriptionKey: "Unexpected token response.",
- ])
- }
-
- let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
- + Int64(expiresIn * 1000)
- - Int64(5 * 60 * 1000)
-
- self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
- return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
- }
-}
-
-enum OpenClawOAuthStore {
- static let oauthFilename = "oauth.json"
- private static let providerKey = "anthropic"
- private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR"
- private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
-
- enum AnthropicOAuthStatus: Equatable {
- case missingFile
- case unreadableFile
- case invalidJSON
- case missingProviderEntry
- case missingTokens
- case connected(expiresAtMs: Int64?)
-
- var isConnected: Bool {
- if case .connected = self { return true }
- return false
- }
-
- var shortDescription: String {
- switch self {
- case .missingFile: "OpenClaw OAuth token file not found"
- case .unreadableFile: "OpenClaw OAuth token file not readable"
- case .invalidJSON: "OpenClaw OAuth token file invalid"
- case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file"
- case .missingTokens: "Anthropic entry missing tokens"
- case .connected: "OpenClaw OAuth credentials found"
- }
- }
- }
-
- static func oauthDir() -> URL {
- if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]?
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !override.isEmpty
- {
- let expanded = NSString(string: override).expandingTildeInPath
- return URL(fileURLWithPath: expanded, isDirectory: true)
- }
- let home = FileManager().homeDirectoryForCurrentUser
- return home.appendingPathComponent(".openclaw", isDirectory: true)
- .appendingPathComponent("credentials", isDirectory: true)
- }
-
- static func oauthURL() -> URL {
- self.oauthDir().appendingPathComponent(self.oauthFilename)
- }
-
- static func legacyOAuthURLs() -> [URL] {
- var urls: [URL] = []
- let env = ProcessInfo.processInfo.environment
- if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !override.isEmpty
- {
- let expanded = NSString(string: override).expandingTildeInPath
- urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
- }
-
- let home = FileManager().homeDirectoryForCurrentUser
- urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
-
- var seen = Set()
- return urls.filter { url in
- let path = url.standardizedFileURL.path
- if seen.contains(path) { return false }
- seen.insert(path)
- return true
- }
- }
-
- static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
- let dest = self.oauthURL()
- guard !FileManager().fileExists(atPath: dest.path) else { return nil }
-
- for url in self.legacyOAuthURLs() {
- guard FileManager().fileExists(atPath: url.path) else { continue }
- guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
- guard let storage = self.loadStorage(at: url) else { continue }
- do {
- try self.saveStorage(storage)
- return url
- } catch {
- continue
- }
- }
-
- return nil
- }
-
- static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
- self.anthropicOAuthStatus(at: self.oauthURL())
- }
-
- static func hasAnthropicOAuth() -> Bool {
- self.anthropicOAuthStatus().isConnected
- }
-
- static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
- guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
-
- guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
- guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
- guard let storage = json as? [String: Any] else { return .invalidJSON }
- guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
- guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
-
- let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
- let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
- guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
-
- let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
- let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
- ms
- } else if let number = expiresAny as? NSNumber {
- number.int64Value
- } else if let ms = expiresAny as? Double {
- Int64(ms)
- } else {
- nil
- }
-
- return .connected(expiresAtMs: expiresAtMs)
- }
-
- static func loadAnthropicOAuthRefreshToken() -> String? {
- let url = self.oauthURL()
- guard let storage = self.loadStorage(at: url) else { return nil }
- guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
- let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
- return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
- for key in keys {
- if let value = dict[key] as? String { return value }
- }
- return nil
- }
-
- private static func loadStorage(at url: URL) -> [String: Any]? {
- guard let data = try? Data(contentsOf: url) else { return nil }
- guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
- return json as? [String: Any]
- }
-
- static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
- let url = self.oauthURL()
- let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
-
- var updated = existing
- updated[self.providerKey] = [
- "type": creds.type,
- "refresh": creds.refresh,
- "access": creds.access,
- "expires": creds.expires,
- ]
-
- try self.saveStorage(updated)
- }
-
- private static func saveStorage(_ storage: [String: Any]) throws {
- let dir = self.oauthDir()
- try FileManager().createDirectory(
- at: dir,
- withIntermediateDirectories: true,
- attributes: [.posixPermissions: 0o700])
-
- let url = self.oauthURL()
- let data = try JSONSerialization.data(
- withJSONObject: storage,
- options: [.prettyPrinted, .sortedKeys])
- try data.write(to: url, options: [.atomic])
- try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
- }
-}
-
-extension Data {
- fileprivate func base64URLEncodedString() -> String {
- self.base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
- }
-}
diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
deleted file mode 100644
index 2a88898c34d..00000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
+++ /dev/null
@@ -1,59 +0,0 @@
-import Foundation
-
-enum AnthropicOAuthCodeState {
- struct Parsed: Equatable {
- let code: String
- let state: String
- }
-
- /// Extracts a `code#state` payload from arbitrary text.
- ///
- /// Supports:
- /// - raw `code#state`
- /// - OAuth callback URLs containing `code=` and `state=` query params
- /// - surrounding text/backticks from instructions pages
- static func extract(from raw: String) -> String? {
- let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "`"))
- if text.isEmpty { return nil }
-
- if let fromURL = self.extractFromURL(text) { return fromURL }
- if let fromToken = self.extractFromToken(text) { return fromToken }
- return nil
- }
-
- static func parse(from raw: String) -> Parsed? {
- guard let extracted = self.extract(from: raw) else { return nil }
- let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
- let code = parts.first ?? ""
- let state = parts.count > 1 ? parts[1] : ""
- guard !code.isEmpty, !state.isEmpty else { return nil }
- return Parsed(code: code, state: state)
- }
-
- private static func extractFromURL(_ text: String) -> String? {
- // Users might copy the callback URL from the browser address bar.
- guard let components = URLComponents(string: text),
- let items = components.queryItems,
- let code = items.first(where: { $0.name == "code" })?.value,
- let state = items.first(where: { $0.name == "state" })?.value,
- !code.isEmpty, !state.isEmpty
- else { return nil }
-
- return "\(code)#\(state)"
- }
-
- private static func extractFromToken(_ text: String) -> String? {
- // Base64url-ish tokens; keep this fairly strict to avoid false positives.
- let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
- guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
-
- let range = NSRange(text.startIndex..?
@State var needsBootstrap = false
@State var didAutoKickoff = false
@State var showAdvancedConnection = false
@@ -104,19 +87,9 @@ struct OnboardingView: View {
let pageWidth: CGFloat = Self.windowWidth
let contentHeight: CGFloat = 460
let connectionPageIndex = 1
- let anthropicAuthPageIndex = 2
let wizardPageIndex = 3
let onboardingChatPageIndex = 8
- static let clipboardPoll: AnyPublisher = {
- if ProcessInfo.processInfo.isRunningTests {
- return Empty(completeImmediately: false).eraseToAnyPublisher()
- }
- return Timer.publish(every: 0.4, on: .main, in: .common)
- .autoconnect()
- .eraseToAnyPublisher()
- }()
-
let permissionsPageIndex = 5
static func pageOrder(
for mode: AppState.ConnectionMode,
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
index bcd5bd6d44d..a521926ddb9 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
@@ -78,70 +78,4 @@ extension OnboardingView {
self.copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
}
-
- func startAnthropicOAuth() {
- guard !self.anthropicAuthBusy else { return }
- self.anthropicAuthBusy = true
- defer { self.anthropicAuthBusy = false }
-
- do {
- let pkce = try AnthropicOAuth.generatePKCE()
- self.anthropicAuthPKCE = pkce
- let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
- NSWorkspace.shared.open(url)
- self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
- } catch {
- self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
- }
- }
-
- @MainActor
- func finishAnthropicOAuth() async {
- guard !self.anthropicAuthBusy else { return }
- guard let pkce = self.anthropicAuthPKCE else { return }
- self.anthropicAuthBusy = true
- defer { self.anthropicAuthBusy = false }
-
- guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
- self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
- return
- }
-
- do {
- let creds = try await AnthropicOAuth.exchangeCode(
- code: parsed.code,
- state: parsed.state,
- verifier: pkce.verifier)
- try OpenClawOAuthStore.saveAnthropicOAuth(creds)
- self.refreshAnthropicOAuthStatus()
- self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude."
- } catch {
- self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
- }
- }
-
- func pollAnthropicClipboardIfNeeded() {
- guard self.currentPage == self.anthropicAuthPageIndex else { return }
- guard self.anthropicAuthPKCE != nil else { return }
- guard !self.anthropicAuthBusy else { return }
- guard self.anthropicAuthAutoDetectClipboard else { return }
-
- let pb = NSPasteboard.general
- let changeCount = pb.changeCount
- guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
- self.anthropicAuthLastPasteboardChangeCount = changeCount
-
- guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
- guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
- guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
-
- let next = "\(parsed.code)#\(parsed.state)"
- if self.anthropicAuthCode != next {
- self.anthropicAuthCode = next
- self.anthropicAuthStatus = "Detected `code#state` from clipboard."
- }
-
- guard self.anthropicAuthAutoConnectClipboard else { return }
- Task { await self.finishAnthropicOAuth() }
- }
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
index ce87e211ce4..9b0e45e205c 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
@@ -53,7 +53,6 @@ extension OnboardingView {
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
- self.stopAuthMonitoring()
Task { await self.onboardingWizard.cancelIfRunning() }
}
.task {
@@ -61,7 +60,6 @@ extension OnboardingView {
self.refreshCLIStatus()
await self.loadWorkspaceDefaults()
await self.ensureDefaultWorkspace()
- self.refreshAnthropicOAuthStatus()
self.refreshBootstrapStatus()
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
index dfbdf91d44d..efe37f31673 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
@@ -47,7 +47,6 @@ extension OnboardingView {
func updateMonitoring(for pageIndex: Int) {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
- self.updateAuthMonitoring(for: pageIndex)
self.maybeKickoffOnboardingChat(for: pageIndex)
}
@@ -63,33 +62,6 @@ extension OnboardingView {
self.gatewayDiscovery.stop()
}
- func updateAuthMonitoring(for pageIndex: Int) {
- let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
- if shouldMonitor, !self.monitoringAuth {
- self.monitoringAuth = true
- self.startAuthMonitoring()
- } else if !shouldMonitor, self.monitoringAuth {
- self.stopAuthMonitoring()
- }
- }
-
- func startAuthMonitoring() {
- self.refreshAnthropicOAuthStatus()
- self.authMonitorTask?.cancel()
- self.authMonitorTask = Task {
- while !Task.isCancelled {
- await MainActor.run { self.refreshAnthropicOAuthStatus() }
- try? await Task.sleep(nanoseconds: 1_000_000_000)
- }
- }
- }
-
- func stopAuthMonitoring() {
- self.monitoringAuth = false
- self.authMonitorTask?.cancel()
- self.authMonitorTask = nil
- }
-
func installCLI() async {
guard !self.installingCLI else { return }
self.installingCLI = true
@@ -125,54 +97,4 @@ extension OnboardingView {
expected: expected)
}
}
-
- func refreshAnthropicOAuthStatus() {
- _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
- let previous = self.anthropicAuthDetectedStatus
- let status = OpenClawOAuthStore.anthropicOAuthStatus()
- self.anthropicAuthDetectedStatus = status
- self.anthropicAuthConnected = status.isConnected
-
- if previous != status {
- self.anthropicAuthVerified = false
- self.anthropicAuthVerificationAttempted = false
- self.anthropicAuthVerificationFailed = false
- self.anthropicAuthVerifiedAt = nil
- }
- }
-
- @MainActor
- func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
- guard self.state.connectionMode == .local else { return }
- guard self.anthropicAuthDetectedStatus.isConnected else { return }
- if self.anthropicAuthVerified, !force { return }
- if self.anthropicAuthVerifying { return }
- if self.anthropicAuthVerificationAttempted, !force { return }
-
- self.anthropicAuthVerificationAttempted = true
- self.anthropicAuthVerifying = true
- self.anthropicAuthVerificationFailed = false
- defer { self.anthropicAuthVerifying = false }
-
- guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
- self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
- self.anthropicAuthVerificationFailed = true
- return
- }
-
- do {
- let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
- try OpenClawOAuthStore.saveAnthropicOAuth(updated)
- self.refreshAnthropicOAuthStatus()
- self.anthropicAuthVerified = true
- self.anthropicAuthVerifiedAt = Date()
- self.anthropicAuthVerificationFailed = false
- self.anthropicAuthStatus = "OAuth detected and verified."
- } catch {
- self.anthropicAuthVerified = false
- self.anthropicAuthVerifiedAt = nil
- self.anthropicAuthVerificationFailed = true
- self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
- }
- }
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index ed40bd2ed58..4f942dfe8a4 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -12,8 +12,6 @@ extension OnboardingView {
self.welcomePage()
case 1:
self.connectionPage()
- case 2:
- self.anthropicAuthPage()
case 3:
self.wizardPage()
case 5:
@@ -340,170 +338,6 @@ extension OnboardingView {
.buttonStyle(.plain)
}
- func anthropicAuthPage() -> some View {
- self.onboardingPage {
- Text("Connect Claude")
- .font(.largeTitle.weight(.semibold))
- Text("Give your model the token it needs!")
- .font(.body)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 540)
- .fixedSize(horizontal: false, vertical: true)
- Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
- .font(.callout)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 540)
- .fixedSize(horizontal: false, vertical: true)
-
- self.onboardingCard(spacing: 12, padding: 16) {
- HStack(alignment: .center, spacing: 10) {
- Circle()
- .fill(self.anthropicAuthVerified ? Color.green : Color.orange)
- .frame(width: 10, height: 10)
- Text(
- self.anthropicAuthConnected
- ? (self.anthropicAuthVerified
- ? "Claude connected (OAuth) — verified"
- : "Claude connected (OAuth)")
- : "Not connected yet")
- .font(.headline)
- Spacer()
- }
-
- if self.anthropicAuthConnected, self.anthropicAuthVerifying {
- Text("Verifying OAuth…")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- } else if !self.anthropicAuthConnected {
- Text(self.anthropicAuthDetectedStatus.shortDescription)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
- Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- Text(
- "This lets OpenClaw use Claude immediately. Credentials are stored at " +
- "`~/.openclaw/credentials/oauth.json` (owner-only).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
-
- HStack(spacing: 12) {
- Text(OpenClawOAuthStore.oauthURL().path)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
-
- Spacer()
-
- Button("Reveal") {
- NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
- }
- .buttonStyle(.bordered)
-
- Button("Refresh") {
- self.refreshAnthropicOAuthStatus()
- }
- .buttonStyle(.bordered)
- }
-
- Divider().padding(.vertical, 2)
-
- HStack(spacing: 12) {
- if !self.anthropicAuthVerified {
- if self.anthropicAuthConnected {
- Button("Verify") {
- Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
-
- if self.anthropicAuthVerificationFailed {
- Button("Re-auth (OAuth)") {
- self.startAnthropicOAuth()
- }
- .buttonStyle(.bordered)
- .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
- }
- } else {
- Button {
- self.startAnthropicOAuth()
- } label: {
- if self.anthropicAuthBusy {
- ProgressView()
- } else {
- Text("Open Claude sign-in (OAuth)")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.anthropicAuthBusy)
- }
- }
- }
-
- if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
- VStack(alignment: .leading, spacing: 8) {
- Text("Paste the `code#state` value")
- .font(.headline)
- TextField("code#state", text: self.$anthropicAuthCode)
- .textFieldStyle(.roundedBorder)
-
- Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
- .font(.caption)
- .foregroundStyle(.secondary)
- .disabled(self.anthropicAuthBusy)
-
- Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
- .font(.caption)
- .foregroundStyle(.secondary)
- .disabled(self.anthropicAuthBusy)
-
- Button("Connect") {
- Task { await self.finishAnthropicOAuth() }
- }
- .buttonStyle(.bordered)
- .disabled(
- self.anthropicAuthBusy ||
- self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- .onReceive(Self.clipboardPoll) { _ in
- self.pollAnthropicClipboardIfNeeded()
- }
- }
-
- self.onboardingCard(spacing: 8, padding: 12) {
- Text("API key (advanced)")
- .font(.headline)
- Text(
- "You can also use an Anthropic API key, but this UI is instructions-only for now " +
- "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- .shadow(color: .clear, radius: 0)
- .background(Color.clear)
-
- if let status = self.anthropicAuthStatus {
- Text(status)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- }
- .task { await self.verifyAnthropicOAuthIfNeeded() }
- }
-
func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
index cf8c3d0c78f..2bd9c525ad4 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
@@ -37,18 +37,9 @@ extension OnboardingView {
view.cliStatus = "Installed"
view.workspacePath = "/tmp/openclaw"
view.workspaceStatus = "Saved workspace"
- view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
- view.anthropicAuthCode = "code#state"
- view.anthropicAuthStatus = "Connected"
- view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
- view.anthropicAuthConnected = true
- view.anthropicAuthAutoDetectClipboard = false
- view.anthropicAuthAutoConnectClipboard = false
-
view.state.connectionMode = .local
_ = view.welcomePage()
_ = view.connectionPage()
- _ = view.anthropicAuthPage()
_ = view.wizardPage()
_ = view.permissionsPage()
_ = view.cliPage()
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index 4e766514def..60b44d4545c 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
+ public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
+ agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
+ self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
+ case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
@@ -2805,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
+ public let commandargv: [String]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
+ commandargv: [String]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2832,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
{
self.id = id
self.command = command
+ self.commandargv = commandargv
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2847,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case command
+ case commandargv = "commandArgv"
case cwd
case nodeid = "nodeId"
case host
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift
deleted file mode 100644
index 84c61833932..00000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-import Testing
-@testable import OpenClaw
-
-@Suite(.serialized)
-@MainActor
-struct AnthropicAuthControlsSmokeTests {
- @Test func anthropicAuthControlsBuildsBodyLocal() {
- let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
- let view = AnthropicAuthControls(
- connectionMode: .local,
- oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
- pkce: pkce,
- code: "code#state",
- statusText: "Detected code",
- autoDetectClipboard: false,
- autoConnectClipboard: false)
- _ = view.body
- }
-
- @Test func anthropicAuthControlsBuildsBodyRemote() {
- let view = AnthropicAuthControls(
- connectionMode: .remote,
- oauthStatus: .missingFile,
- pkce: nil,
- code: "",
- statusText: nil)
- _ = view.body
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift
deleted file mode 100644
index c41b7f64be4..00000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import Foundation
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct AnthropicAuthResolverTests {
- @Test
- func prefersOAuthFileOverEnv() throws {
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
- let oauthFile = dir.appendingPathComponent("oauth.json")
- let payload = [
- "anthropic": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- "expires": 1_234_567_890,
- ],
- ]
- let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
- try data.write(to: oauthFile, options: [.atomic])
-
- let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile)
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_API_KEY": "sk-ant-ignored",
- ], oauthStatus: status)
- #expect(mode == .oauthFile)
- }
-
- @Test
- func reportsOAuthEnvWhenPresent() {
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_OAUTH_TOKEN": "token",
- ], oauthStatus: .missingFile)
- #expect(mode == .oauthEnv)
- }
-
- @Test
- func reportsAPIKeyEnvWhenPresent() {
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_API_KEY": "sk-ant-key",
- ], oauthStatus: .missingFile)
- #expect(mode == .apiKeyEnv)
- }
-
- @Test
- func reportsMissingWhenNothingConfigured() {
- let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
- #expect(mode == .missing)
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift
deleted file mode 100644
index 3d337c2b279..00000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct AnthropicOAuthCodeStateTests {
- @Test
- func parsesRawToken() {
- let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func parsesBacktickedToken() {
- let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func parsesCallbackURL() {
- let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
- let parsed = AnthropicOAuthCodeState.parse(from: raw)
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func extractsFromSurroundingText() {
- let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
- let parsed = AnthropicOAuthCodeState.parse(from: raw)
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift
deleted file mode 100644
index b34e9c3008a..00000000000
--- a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift
+++ /dev/null
@@ -1,97 +0,0 @@
-import Foundation
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct OpenClawOAuthStoreTests {
- @Test
- func returnsMissingWhenFileAbsent() {
- let url = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)")
- .appendingPathComponent("oauth.json")
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
- }
-
- @Test
- func usesEnvOverrideForOpenClawOAuthDir() throws {
- let key = "OPENCLAW_OAUTH_DIR"
- let previous = ProcessInfo.processInfo.environment[key]
- defer {
- if let previous {
- setenv(key, previous, 1)
- } else {
- unsetenv(key)
- }
- }
-
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- setenv(key, dir.path, 1)
-
- #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
- }
-
- @Test
- func acceptsPiFormatTokens() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- "expires": 1_234_567_890,
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
- }
-
- @Test
- func acceptsTokenKeyVariants() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh_token": "r1",
- "access_token": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
- }
-
- @Test
- func reportsMissingProviderEntry() throws {
- let url = try self.writeOAuthFile([
- "other": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
- }
-
- @Test
- func reportsMissingTokens() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh": "",
- "access": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
- }
-
- private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
-
- let url = dir.appendingPathComponent("oauth.json")
- let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
- try data.write(to: url, options: [.atomic])
- return url
- }
-}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 4e766514def..60b44d4545c 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
+ public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
+ agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
+ self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
+ case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
@@ -2805,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
+ public let commandargv: [String]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
+ commandargv: [String]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2832,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
{
self.id = id
self.command = command
+ self.commandargv = commandargv
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2847,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case command
+ case commandargv = "commandArgv"
case cwd
case nodeid = "nodeId"
case host
diff --git a/changelog/fragments/README.md b/changelog/fragments/README.md
new file mode 100644
index 00000000000..93bb5b65d70
--- /dev/null
+++ b/changelog/fragments/README.md
@@ -0,0 +1,13 @@
+# Changelog Fragments
+
+Use this directory when a PR should not edit `CHANGELOG.md` directly.
+
+- One fragment file per PR.
+- File name recommendation: `pr-.md`.
+- Include at least one line with both `#` and `thanks @`.
+
+Example:
+
+```md
+- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice)
+```
diff --git a/docker-setup.sh b/docker-setup.sh
index 8c67dc0962d..c0cd925c4c3 100755
--- a/docker-setup.sh
+++ b/docker-setup.sh
@@ -247,12 +247,20 @@ upsert_env "$ENV_FILE" \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES
-echo "==> Building Docker image: $IMAGE_NAME"
-docker build \
- --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
- -t "$IMAGE_NAME" \
- -f "$ROOT_DIR/Dockerfile" \
- "$ROOT_DIR"
+if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
+ echo "==> Building Docker image: $IMAGE_NAME"
+ docker build \
+ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
+ -t "$IMAGE_NAME" \
+ -f "$ROOT_DIR/Dockerfile" \
+ "$ROOT_DIR"
+else
+ echo "==> Pulling Docker image: $IMAGE_NAME"
+ if ! docker pull "$IMAGE_NAME"; then
+ echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
+ exit 1
+ fi
+fi
echo ""
echo "==> Onboarding (interactive)"
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 6a454bd8dcf..46db95202b4 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -553,6 +553,7 @@ curl "https://api.telegram.org/bot/getUpdates"
Notes:
- `own` means user reactions to bot-sent messages only (best-effort via sent-message cache).
+ - Reaction events still respect Telegram access controls (`dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`); unauthorized senders are dropped.
- Telegram does not provide thread IDs in reaction updates.
- non-forum groups route to group chat session
- forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic
diff --git a/docs/cli/security.md b/docs/cli/security.md
index fe8af41ec25..cc705b31a30 100644
--- a/docs/cli/security.md
+++ b/docs/cli/security.md
@@ -29,7 +29,7 @@ It also emits `security.trust_model.multi_user_heuristic` when config suggests l
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 warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), 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).
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index d3838bbdae6..abc010ce8fe 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -273,6 +273,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
every: "30m",
model: "anthropic/claude-sonnet-4-5",
target: "last",
+ directPolicy: "allow", // allow (default) | block
to: "+15555550123",
prompt: "HEARTBEAT",
ackMaxChars: 300,
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 01ad82b6098..8d147b23fd7 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -800,6 +800,7 @@ Periodic heartbeat runs.
includeReasoning: false,
session: "main",
to: "+15555550123",
+ directPolicy: "allow", // allow (default) | block
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
prompt: "Read HEARTBEAT.md if it exists...",
ackMaxChars: 300,
@@ -812,7 +813,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.
+- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- 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.
@@ -1250,6 +1251,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
},
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
+ parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables)
maintenance: {
mode: "warn", // warn | enforce
pruneAfter: "30d",
@@ -1283,6 +1285,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing.
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins.
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
+- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`).
+ - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history.
+ - Set `0` to disable this guard and always allow parent forking.
- **`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`**: session-store cleanup + retention controls.
@@ -2141,8 +2146,9 @@ See [Plugins](/tools/plugin).
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
- `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).
+- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
- `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.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `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.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 3f7403d4647..ff3179d28e2 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -239,7 +239,8 @@ When validation fails:
```
- `every`: duration string (`30m`, `2h`). Set `0m` to disable.
- - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked)
+ - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none`
+ - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets
- See [Heartbeat](/gateway/heartbeat) for the full guide.
diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md
index cf7ea489c40..a4f4aa64ea9 100644
--- a/docs/gateway/heartbeat.md
+++ b/docs/gateway/heartbeat.md
@@ -32,6 +32,7 @@ Example config:
heartbeat: {
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
+ directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
},
@@ -215,7 +216,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `last`: deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
- `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).
+- `directPolicy`: controls direct/DM delivery behavior:
+ - `allow` (default): allow direct/DM heartbeat delivery.
+ - `block`: suppress direct/DM delivery (`reason=dm-blocked`).
- `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).
@@ -236,7 +239,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.
+- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
- 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/security/index.md b/docs/gateway/security/index.md
index 3824d1d283e..a61a81eab1e 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -188,7 +188,7 @@ If more than one person can DM your bot:
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
- **Plugins** (extensions exist without an explicit allowlist).
-- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
+- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns because matching is exact command-name only (for example `system.run`) and does not inspect shell text; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
- **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host).
- **Model hygiene** (warn when configured models look legacy; not a hard block).
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 23483076102..45963f15579 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -174,7 +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).
+- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`.
Related:
diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md
index aff09a303e8..d258eeb6722 100644
--- a/docs/reference/session-management-compaction.md
+++ b/docs/reference/session-management-compaction.md
@@ -128,6 +128,7 @@ Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
+- **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md
index ab9289b8a11..dfa058af545 100644
--- a/docs/start/onboarding.md
+++ b/docs/start/onboarding.md
@@ -29,6 +29,12 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb
+
+Security trust model:
+
+- By default, OpenClaw is a personal agent: one trusted operator boundary.
+- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
+
@@ -37,17 +43,19 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb
Where does the **Gateway** run?
-- **This Mac (Local only):** onboarding can run OAuth flows and write credentials
+- **This Mac (Local only):** onboarding can configure auth and write credentials
locally.
-- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally;
+- **Remote (over SSH/Tailnet):** onboarding does **not** configure local auth;
credentials must exist on the gateway host.
- **Configure later:** skip setup and leave the app unconfigured.
**Gateway auth tip:**
+
- The wizard now generates a **token** even for loopback, so local WS clients must authenticate.
- If you disable auth, any local process can connect; use that only on fully trusted machines.
- Use a **token** for multi‑machine access or non‑loopback binds.
+
diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md
index 058f2fa67fe..671efe420c7 100644
--- a/docs/start/openclaw.md
+++ b/docs/start/openclaw.md
@@ -164,7 +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.
+- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
```json5
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
index 67fb50a78c6..f25e47d50e6 100644
--- a/extensions/bluebubbles/src/monitor-processing.ts
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -7,8 +7,7 @@ import {
logTypingFailure,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
- resolveDmGroupAccessDecision,
- resolveEffectiveAllowFromLists,
+ resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
@@ -504,24 +503,13 @@ export async function processMessage(
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
- allowFrom: account.config.allowFrom,
- groupAllowFrom: account.config.groupAllowFrom,
- storeAllowFrom,
- dmPolicy,
- });
- const groupAllowEntry = formatGroupAllowlistEntry({
- chatGuid: message.chatGuid,
- chatId: message.chatId ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- });
- const groupName = message.chatName?.trim() || undefined;
- const accessDecision = resolveDmGroupAccessDecision({
+ const accessDecision = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy,
- effectiveAllowFrom,
- effectiveGroupAllowFrom,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isAllowedBlueBubblesSender({
allowFrom,
@@ -531,6 +519,14 @@ export async function processMessage(
chatIdentifier: message.chatIdentifier ?? undefined,
}),
});
+ const effectiveAllowFrom = accessDecision.effectiveAllowFrom;
+ const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
+ const groupAllowEntry = formatGroupAllowlistEntry({
+ chatGuid: message.chatGuid,
+ chatId: message.chatId ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ });
+ const groupName = message.chatName?.trim() || undefined;
if (accessDecision.decision !== "allow") {
if (isGroup) {
@@ -1389,18 +1385,13 @@ export async function processReaction(
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
- allowFrom: account.config.allowFrom,
- groupAllowFrom: account.config.groupAllowFrom,
- storeAllowFrom,
- dmPolicy,
- });
- const accessDecision = resolveDmGroupAccessDecision({
+ const accessDecision = resolveDmGroupAccessWithLists({
isGroup: reaction.isGroup,
dmPolicy,
groupPolicy,
- effectiveAllowFrom,
- effectiveGroupAllowFrom,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isAllowedBlueBubblesSender({
allowFrom,
diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts
index e5b0ce333f5..812636113cb 100644
--- a/extensions/line/src/channel.startup.test.ts
+++ b/extensions/line/src/channel.startup.test.ts
@@ -37,6 +37,7 @@ function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: ReturnType;
+ abortSignal?: AbortSignal;
}): ChannelGatewayContext {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",
@@ -56,7 +57,7 @@ function createStartAccountCtx(params: {
},
cfg: {} as OpenClawConfig,
runtime: params.runtime,
- abortSignal: new AbortController().signal,
+ abortSignal: params.abortSignal ?? new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
@@ -104,14 +105,19 @@ describe("linePlugin gateway.startAccount", () => {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
- await linePlugin.gateway!.startAccount!(
+ const abort = new AbortController();
+ const task = linePlugin.gateway!.startAccount!(
createStartAccountCtx({
token: "token",
secret: "secret",
runtime: createRuntimeEnv(),
+ abortSignal: abort.signal,
}),
);
+ // Allow async internals (probeLineBot await) to flush
+ await new Promise((r) => setTimeout(r, 20));
+
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
@@ -119,5 +125,8 @@ describe("linePlugin gateway.startAccount", () => {
accountId: "default",
}),
);
+
+ abort.abort();
+ await task;
});
});
diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts
index a260d96c961..1c87ad8e2f3 100644
--- a/extensions/line/src/channel.ts
+++ b/extensions/line/src/channel.ts
@@ -651,7 +651,7 @@ export const linePlugin: ChannelPlugin = {
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
- return getLineRuntime().channel.line.monitorLineProvider({
+ const monitor = await getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
@@ -660,6 +660,8 @@ export const linePlugin: ChannelPlugin = {
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
+
+ return monitor;
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
index 6056c3fef15..af8d8e07e60 100644
--- a/extensions/mattermost/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -17,6 +17,7 @@ import {
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
resolveControlCommandGate,
+ resolveDmGroupAccessWithLists,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveChannelMediaMaxBytes,
@@ -883,68 +884,38 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const kind = channelKind(channelInfo.type);
// Enforce DM/group policy and allowlist checks (same as normal messages)
- if (kind === "direct") {
- const dmPolicy = account.config.dmPolicy ?? "pairing";
- if (dmPolicy === "disabled") {
- logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`);
- return;
- }
- // For pairing/allowlist modes, only allow reactions from approved senders
- if (dmPolicy !== "open") {
- const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
- const storeAllowFrom = normalizeAllowList(
- dmPolicy === "allowlist"
- ? []
- : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
- );
- const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
- const allowed = isSenderAllowed({
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
+ const storeAllowFrom = normalizeAllowList(
+ dmPolicy === "allowlist"
+ ? []
+ : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
+ );
+ const reactionAccess = resolveDmGroupAccessWithLists({
+ isGroup: kind !== "direct",
+ dmPolicy,
+ groupPolicy,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
+ isSenderAllowed: (allowFrom) =>
+ isSenderAllowed({
senderId: userId,
senderName,
- allowFrom: effectiveAllowFrom,
+ allowFrom: normalizeAllowList(allowFrom),
allowNameMatching,
- });
- if (!allowed) {
- logVerboseMessage(
- `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`,
- );
- return;
- }
- }
- } else if (kind) {
- if (groupPolicy === "disabled") {
- logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`);
- return;
- }
- if (groupPolicy === "allowlist") {
- const dmPolicyForStore = account.config.dmPolicy ?? "pairing";
- const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
- const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
- const storeAllowFrom = normalizeAllowList(
- dmPolicyForStore === "allowlist"
- ? []
- : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
+ }),
+ });
+ if (reactionAccess.decision !== "allow") {
+ if (kind === "direct") {
+ logVerboseMessage(
+ `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`,
);
- const effectiveGroupAllowFrom = Array.from(
- new Set([
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
- ...storeAllowFrom,
- ]),
+ } else {
+ logVerboseMessage(
+ `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`,
);
- // Drop when allowlist is empty (same as normal message handler)
- const allowed =
- effectiveGroupAllowFrom.length > 0 &&
- isSenderAllowed({
- senderId: userId,
- senderName,
- allowFrom: effectiveGroupAllowFrom,
- allowNameMatching,
- });
- if (!allowed) {
- logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
- return;
- }
}
+ return;
}
const teamId = channelInfo?.team_id ?? undefined;
diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts
new file mode 100644
index 00000000000..804ce58107c
--- /dev/null
+++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts
@@ -0,0 +1,220 @@
+import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { MSTeamsConversationStore } from "./conversation-store.js";
+import type { MSTeamsAdapter } from "./messenger.js";
+import {
+ type MSTeamsActivityHandler,
+ type MSTeamsMessageHandlerDeps,
+ registerMSTeamsHandlers,
+} from "./monitor-handler.js";
+import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
+import type { MSTeamsPollStore } from "./polls.js";
+import { setMSTeamsRuntime } from "./runtime.js";
+import type { MSTeamsTurnContext } from "./sdk-types.js";
+
+const fileConsentMockState = vi.hoisted(() => ({
+ uploadToConsentUrl: vi.fn(),
+}));
+
+vi.mock("./file-consent.js", async () => {
+ const actual = await vi.importActual("./file-consent.js");
+ return {
+ ...actual,
+ uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl,
+ };
+});
+
+const runtimeStub: PluginRuntime = {
+ logging: {
+ shouldLogVerbose: () => false,
+ },
+ channel: {
+ debounce: {
+ resolveInboundDebounceMs: () => 0,
+ createInboundDebouncer: () => ({
+ enqueue: async () => {},
+ }),
+ },
+ },
+} as unknown as PluginRuntime;
+
+function createDeps(): MSTeamsMessageHandlerDeps {
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async () => {},
+ process: async () => {},
+ };
+ const conversationStore: MSTeamsConversationStore = {
+ upsert: async () => {},
+ get: async () => null,
+ list: async () => [],
+ remove: async () => false,
+ findByUserId: async () => null,
+ };
+ const pollStore: MSTeamsPollStore = {
+ createPoll: async () => {},
+ getPoll: async () => null,
+ recordVote: async () => null,
+ };
+ return {
+ cfg: {} as OpenClawConfig,
+ runtime: {
+ error: vi.fn(),
+ } as unknown as RuntimeEnv,
+ appId: "test-app-id",
+ adapter,
+ tokenProvider: {
+ getAccessToken: async () => "token",
+ },
+ textLimit: 4000,
+ mediaMaxBytes: 8 * 1024 * 1024,
+ conversationStore,
+ pollStore,
+ log: {
+ info: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+ };
+}
+
+function createActivityHandler(): MSTeamsActivityHandler {
+ let handler: MSTeamsActivityHandler;
+ handler = {
+ onMessage: () => handler,
+ onMembersAdded: () => handler,
+ run: async () => {},
+ };
+ return handler;
+}
+
+function createInvokeContext(params: {
+ conversationId: string;
+ uploadId: string;
+ action: "accept" | "decline";
+}): { context: MSTeamsTurnContext; sendActivity: ReturnType } {
+ const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
+ const uploadInfo =
+ params.action === "accept"
+ ? {
+ name: "secret.txt",
+ uploadUrl: "https://upload.example.com/put",
+ contentUrl: "https://content.example.com/file",
+ uniqueId: "unique-id",
+ fileType: "txt",
+ }
+ : undefined;
+ return {
+ context: {
+ activity: {
+ type: "invoke",
+ name: "fileConsent/invoke",
+ conversation: { id: params.conversationId },
+ value: {
+ type: "fileUpload",
+ action: params.action,
+ uploadInfo,
+ context: { uploadId: params.uploadId },
+ },
+ },
+ sendActivity,
+ sendActivities: async () => [],
+ } as unknown as MSTeamsTurnContext,
+ sendActivity,
+ };
+}
+
+describe("msteams file consent invoke authz", () => {
+ beforeEach(() => {
+ setMSTeamsRuntime(runtimeStub);
+ clearPendingUploads();
+ fileConsentMockState.uploadToConsentUrl.mockReset();
+ fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
+ });
+
+ it("uploads when invoke conversation matches pending upload conversation", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:victim@thread.v2;messageid=abc123",
+ uploadId,
+ action: "accept",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: "https://upload.example.com/put",
+ }),
+ );
+ expect(getPendingUpload(uploadId)).toBeUndefined();
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+
+ it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:attacker@thread.v2",
+ uploadId,
+ action: "accept",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
+ expect(getPendingUpload(uploadId)).toBeDefined();
+ expect(sendActivity).toHaveBeenCalledWith(
+ "The file upload request has expired. Please try sending the file again.",
+ );
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+
+ it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:attacker@thread.v2",
+ uploadId,
+ action: "decline",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
+ expect(getPendingUpload(uploadId)).toBeDefined();
+ expect(sendActivity).toHaveBeenCalledTimes(1);
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+});
diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts
index d4b848fde5a..086b82d496a 100644
--- a/extensions/msteams/src/monitor-handler.ts
+++ b/extensions/msteams/src/monitor-handler.ts
@@ -1,6 +1,7 @@
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
+import { normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
@@ -42,6 +43,8 @@ async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise {
+ const expiredUploadMessage =
+ "The file upload request has expired. Please try sending the file again.";
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
@@ -57,9 +60,24 @@ async function handleFileConsentInvoke(
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
+ const pendingFile = getPendingUpload(uploadId);
+ if (pendingFile) {
+ const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
+ const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
+ if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
+ log.info("file consent conversation mismatch", {
+ uploadId,
+ expectedConversationId: pendingConversationId,
+ receivedConversationId: invokeConversationId || undefined,
+ });
+ if (consentResponse.action === "accept") {
+ await context.sendActivity(expiredUploadMessage);
+ }
+ return true;
+ }
+ }
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
- const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
log.debug?.("user accepted file consent, uploading", {
uploadId,
@@ -101,9 +119,7 @@ async function handleFileConsentInvoke(
}
} else {
log.debug?.("pending file not found for consent", { uploadId });
- await context.sendActivity(
- "The file upload request has expired. Please try sending the file again.",
- );
+ await context.sendActivity(expiredUploadMessage);
}
} else {
// User declined
diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
index f2b4b65054d..6cc149dde47 100644
--- a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
+++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
@@ -1,50 +1,5 @@
-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());
- }),
- };
-}
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
describe("createNextcloudTalkWebhookServer auth order", () => {
it("rejects missing signature headers before reading request body", async () => {
@@ -55,8 +10,8 @@ describe("createNextcloudTalkWebhookServer auth order", () => {
path: "/nextcloud-auth-order",
maxBodyBytes: 128,
readBody,
+ onMessage: vi.fn(),
});
- cleanupFns.push(harness.stop);
const response = await fetch(harness.webhookUrl, {
method: "POST",
diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts
new file mode 100644
index 00000000000..aaf9a30a9c8
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
+import { generateNextcloudTalkSignature } from "./signature.js";
+
+describe("createNextcloudTalkWebhookServer backend allowlist", () => {
+ it("rejects requests from unexpected backend origins", async () => {
+ const onMessage = vi.fn(async () => {});
+ const harness = await startWebhookServer({
+ path: "/nextcloud-backend-check",
+ isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
+ onMessage,
+ });
+
+ const payload = {
+ type: "Create",
+ actor: { type: "Person", id: "alice", name: "Alice" },
+ object: {
+ type: "Note",
+ id: "msg-1",
+ name: "hello",
+ content: "hello",
+ mediaType: "text/plain",
+ },
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
+ };
+ const body = JSON.stringify(payload);
+ const { random, signature } = generateNextcloudTalkSignature({
+ body,
+ secret: "nextcloud-secret",
+ });
+ const response = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-nextcloud-talk-random": random,
+ "x-nextcloud-talk-signature": signature,
+ "x-nextcloud-talk-backend": "https://nextcloud.unexpected",
+ },
+ body,
+ });
+
+ expect(response.status).toBe(401);
+ expect(await response.json()).toEqual({ error: "Invalid backend" });
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts
new file mode 100644
index 00000000000..387e7a8304f
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
+import { generateNextcloudTalkSignature } from "./signature.js";
+import type { NextcloudTalkInboundMessage } from "./types.js";
+
+function createSignedRequest(body: string): { random: string; signature: string } {
+ return generateNextcloudTalkSignature({
+ body,
+ secret: "nextcloud-secret",
+ });
+}
+
+describe("createNextcloudTalkWebhookServer replay handling", () => {
+ it("acknowledges replayed requests and skips onMessage side effects", async () => {
+ const seen = new Set();
+ const onMessage = vi.fn(async () => {});
+ const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
+ if (seen.has(message.messageId)) {
+ return false;
+ }
+ seen.add(message.messageId);
+ return true;
+ });
+ const harness = await startWebhookServer({
+ path: "/nextcloud-replay",
+ shouldProcessMessage,
+ onMessage,
+ });
+
+ const payload = {
+ type: "Create",
+ actor: { type: "Person", id: "alice", name: "Alice" },
+ object: {
+ type: "Note",
+ id: "msg-1",
+ name: "hello",
+ content: "hello",
+ mediaType: "text/plain",
+ },
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
+ };
+ const body = JSON.stringify(payload);
+ const { random, signature } = createSignedRequest(body);
+ const headers = {
+ "content-type": "application/json",
+ "x-nextcloud-talk-random": random,
+ "x-nextcloud-talk-signature": signature,
+ "x-nextcloud-talk-backend": "https://nextcloud.example",
+ };
+
+ const first = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers,
+ body,
+ });
+ const second = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ expect(first.status).toBe(200);
+ expect(second.status).toBe(200);
+ expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
+ expect(onMessage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/extensions/nextcloud-talk/src/monitor.test-harness.ts b/extensions/nextcloud-talk/src/monitor.test-harness.ts
new file mode 100644
index 00000000000..f0daf42e8d5
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.test-harness.ts
@@ -0,0 +1,59 @@
+import { type AddressInfo } from "node:net";
+import { afterEach } from "vitest";
+import { createNextcloudTalkWebhookServer } from "./monitor.js";
+import type { NextcloudTalkWebhookServerOptions } from "./types.js";
+
+export type WebhookHarness = {
+ webhookUrl: string;
+ stop: () => Promise;
+};
+
+const cleanupFns: Array<() => Promise> = [];
+
+afterEach(async () => {
+ while (cleanupFns.length > 0) {
+ const cleanup = cleanupFns.pop();
+ if (cleanup) {
+ await cleanup();
+ }
+ }
+});
+
+export type StartWebhookServerParams = Omit<
+ NextcloudTalkWebhookServerOptions,
+ "port" | "host" | "path" | "secret"
+> & {
+ path: string;
+ secret?: string;
+ host?: string;
+ port?: number;
+};
+
+export async function startWebhookServer(
+ params: StartWebhookServerParams,
+): Promise {
+ const host = params.host ?? "127.0.0.1";
+ const port = params.port ?? 0;
+ const secret = params.secret ?? "nextcloud-secret";
+ const { server, start } = createNextcloudTalkWebhookServer({
+ ...params,
+ port,
+ host,
+ secret,
+ });
+ await start();
+ const address = server.address() as AddressInfo | null;
+ if (!address) {
+ throw new Error("missing server address");
+ }
+
+ const harness: WebhookHarness = {
+ webhookUrl: `http://${host}:${address.port}${params.path}`,
+ stop: () =>
+ new Promise((resolve) => {
+ server.close(() => resolve());
+ }),
+ };
+ cleanupFns.push(harness.stop);
+ return harness;
+}
diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts
index 4b68a3c4d0b..3fb3da3e75b 100644
--- a/extensions/nextcloud-talk/src/monitor.ts
+++ b/extensions/nextcloud-talk/src/monitor.ts
@@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import os from "node:os";
import {
createLoggerBackedRuntime,
type RuntimeEnv,
@@ -8,11 +9,13 @@ import {
} from "openclaw/plugin-sdk";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
+import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {
CoreConfig,
NextcloudTalkInboundMessage,
+ NextcloudTalkWebhookHeaders,
NextcloudTalkWebhookPayload,
NextcloudTalkWebhookServerOptions,
} from "./types.js";
@@ -23,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const HEALTH_PATH = "/healthz";
+const WEBHOOK_ERRORS = {
+ missingSignatureHeaders: "Missing signature headers",
+ invalidBackend: "Invalid backend",
+ invalidSignature: "Invalid signature",
+ invalidPayloadFormat: "Invalid payload format",
+ payloadTooLarge: "Payload too large",
+ internalServerError: "Internal server error",
+} as const;
function formatError(err: unknown): string {
if (err instanceof Error) {
@@ -31,6 +42,14 @@ function formatError(err: unknown): string {
return typeof err === "string" ? err : JSON.stringify(err);
}
+function normalizeOrigin(value: string): string | null {
+ try {
+ return new URL(value).origin.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
try {
const data = JSON.parse(body);
@@ -51,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
}
}
+function writeJsonResponse(
+ res: ServerResponse,
+ status: number,
+ body?: Record,
+): void {
+ if (body) {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(body));
+ return;
+ }
+ res.writeHead(status);
+ res.end();
+}
+
+function writeWebhookError(res: ServerResponse, status: number, error: string): void {
+ if (res.headersSent) {
+ return;
+ }
+ writeJsonResponse(res, status, { error });
+}
+
+function validateWebhookHeaders(params: {
+ req: IncomingMessage;
+ res: ServerResponse;
+ isBackendAllowed?: (backend: string) => boolean;
+}): NextcloudTalkWebhookHeaders | null {
+ const headers = extractNextcloudTalkHeaders(
+ params.req.headers as Record,
+ );
+ if (!headers) {
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
+ return null;
+ }
+ if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
+ return null;
+ }
+ return headers;
+}
+
+function verifyWebhookSignature(params: {
+ headers: NextcloudTalkWebhookHeaders;
+ body: string;
+ secret: string;
+ res: ServerResponse;
+}): boolean {
+ const isValid = verifyNextcloudTalkSignature({
+ signature: params.headers.signature,
+ random: params.headers.random,
+ body: params.body,
+ secret: params.secret,
+ });
+ if (!isValid) {
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
+ return false;
+ }
+ return true;
+}
+
+function decodeWebhookCreateMessage(params: {
+ body: string;
+ res: ServerResponse;
+}):
+ | { kind: "message"; message: NextcloudTalkInboundMessage }
+ | { kind: "ignore" }
+ | { kind: "invalid" } {
+ const payload = parseWebhookPayload(params.body);
+ if (!payload) {
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
+ return { kind: "invalid" };
+ }
+ if (payload.type !== "Create") {
+ return { kind: "ignore" };
+ }
+ return { kind: "message", message: payloadToInboundMessage(payload) };
+}
+
function payloadToInboundMessage(
payload: NextcloudTalkWebhookPayload,
): NextcloudTalkInboundMessage {
@@ -93,6 +189,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
? Math.floor(opts.maxBodyBytes)
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
+ const isBackendAllowed = opts.isBackendAllowed;
+ const shouldProcessMessage = opts.shouldProcessMessage;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === HEALTH_PATH) {
@@ -108,47 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
}
try {
- const headers = extractNextcloudTalkHeaders(
- req.headers as Record,
- );
+ const headers = validateWebhookHeaders({
+ req,
+ res,
+ isBackendAllowed,
+ });
if (!headers) {
- res.writeHead(400, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "Missing signature headers" }));
return;
}
const body = await readBody(req, maxBodyBytes);
- const isValid = verifyNextcloudTalkSignature({
- signature: headers.signature,
- random: headers.random,
+ const hasValidSignature = verifyWebhookSignature({
+ headers,
body,
secret,
+ res,
});
-
- if (!isValid) {
- res.writeHead(401, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "Invalid signature" }));
+ if (!hasValidSignature) {
return;
}
- const payload = parseWebhookPayload(body);
- if (!payload) {
- res.writeHead(400, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "Invalid payload format" }));
+ const decoded = decodeWebhookCreateMessage({
+ body,
+ res,
+ });
+ if (decoded.kind === "invalid") {
+ return;
+ }
+ if (decoded.kind === "ignore") {
+ writeJsonResponse(res, 200);
return;
}
- if (payload.type !== "Create") {
- res.writeHead(200);
- res.end();
- return;
+ const message = decoded.message;
+ if (shouldProcessMessage) {
+ const shouldProcess = await shouldProcessMessage(message);
+ if (!shouldProcess) {
+ writeJsonResponse(res, 200);
+ return;
+ }
}
- const message = payloadToInboundMessage(payload);
-
- res.writeHead(200);
- res.end();
+ writeJsonResponse(res, 200);
try {
await onMessage(message);
@@ -157,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
}
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
- if (!res.headersSent) {
- res.writeHead(413, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "Payload too large" }));
- }
+ writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
- if (!res.headersSent) {
- res.writeHead(408, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
- }
+ writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
return;
}
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
- if (!res.headersSent) {
- res.writeHead(500, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "Internal server error" }));
- }
+ writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
}
});
@@ -233,12 +324,41 @@ export async function monitorNextcloudTalkProvider(
channel: "nextcloud-talk",
accountId: account.accountId,
});
+ const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
+ const replayGuard = createNextcloudTalkReplayGuard({
+ stateDir: core.state.resolveStateDir(process.env, os.homedir),
+ onDiskError: (error) => {
+ logger.warn(
+ `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
+ );
+ },
+ });
const { start, stop } = createNextcloudTalkWebhookServer({
port,
host,
path,
secret: account.secret,
+ isBackendAllowed: (backend) => {
+ if (!expectedBackendOrigin) {
+ return true;
+ }
+ const backendOrigin = normalizeOrigin(backend);
+ return backendOrigin === expectedBackendOrigin;
+ },
+ shouldProcessMessage: async (message) => {
+ const shouldProcess = await replayGuard.shouldProcessMessage({
+ accountId: account.accountId,
+ roomToken: message.roomToken,
+ messageId: message.messageId,
+ });
+ if (!shouldProcess) {
+ logger.warn(
+ `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
+ );
+ }
+ return shouldProcess;
+ },
onMessage: async (message) => {
core.channel.activity.record({
channel: "nextcloud-talk",
diff --git a/extensions/nextcloud-talk/src/replay-guard.test.ts b/extensions/nextcloud-talk/src/replay-guard.test.ts
new file mode 100644
index 00000000000..0bf18acb600
--- /dev/null
+++ b/extensions/nextcloud-talk/src/replay-guard.test.ts
@@ -0,0 +1,70 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
+
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ while (tempDirs.length > 0) {
+ const dir = tempDirs.pop();
+ if (dir) {
+ await rm(dir, { recursive: true, force: true });
+ }
+ }
+});
+
+async function makeTempDir(): Promise {
+ const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+describe("createNextcloudTalkReplayGuard", () => {
+ it("persists replay decisions across guard instances", async () => {
+ const stateDir = await makeTempDir();
+
+ const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
+ const firstAttempt = await firstGuard.shouldProcessMessage({
+ accountId: "account-a",
+ roomToken: "room-1",
+ messageId: "msg-1",
+ });
+ const replayAttempt = await firstGuard.shouldProcessMessage({
+ accountId: "account-a",
+ roomToken: "room-1",
+ messageId: "msg-1",
+ });
+
+ const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
+ const restartReplayAttempt = await secondGuard.shouldProcessMessage({
+ accountId: "account-a",
+ roomToken: "room-1",
+ messageId: "msg-1",
+ });
+
+ expect(firstAttempt).toBe(true);
+ expect(replayAttempt).toBe(false);
+ expect(restartReplayAttempt).toBe(false);
+ });
+
+ it("scopes replay state by account namespace", async () => {
+ const stateDir = await makeTempDir();
+ const guard = createNextcloudTalkReplayGuard({ stateDir });
+
+ const accountAFirst = await guard.shouldProcessMessage({
+ accountId: "account-a",
+ roomToken: "room-1",
+ messageId: "msg-9",
+ });
+ const accountBFirst = await guard.shouldProcessMessage({
+ accountId: "account-b",
+ roomToken: "room-1",
+ messageId: "msg-9",
+ });
+
+ expect(accountAFirst).toBe(true);
+ expect(accountBFirst).toBe(true);
+ });
+});
diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts
new file mode 100644
index 00000000000..14b074ed2ab
--- /dev/null
+++ b/extensions/nextcloud-talk/src/replay-guard.ts
@@ -0,0 +1,65 @@
+import path from "node:path";
+import { createPersistentDedupe } from "openclaw/plugin-sdk";
+
+const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
+const DEFAULT_MEMORY_MAX_SIZE = 1_000;
+const DEFAULT_FILE_MAX_ENTRIES = 10_000;
+
+function sanitizeSegment(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return "default";
+ }
+ return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
+}
+
+function buildReplayKey(params: { roomToken: string; messageId: string }): string | null {
+ const roomToken = params.roomToken.trim();
+ const messageId = params.messageId.trim();
+ if (!roomToken || !messageId) {
+ return null;
+ }
+ return `${roomToken}:${messageId}`;
+}
+
+export type NextcloudTalkReplayGuardOptions = {
+ stateDir: string;
+ ttlMs?: number;
+ memoryMaxSize?: number;
+ fileMaxEntries?: number;
+ onDiskError?: (error: unknown) => void;
+};
+
+export type NextcloudTalkReplayGuard = {
+ shouldProcessMessage: (params: {
+ accountId: string;
+ roomToken: string;
+ messageId: string;
+ }) => Promise;
+};
+
+export function createNextcloudTalkReplayGuard(
+ options: NextcloudTalkReplayGuardOptions,
+): NextcloudTalkReplayGuard {
+ const stateDir = options.stateDir.trim();
+ const persistentDedupe = createPersistentDedupe({
+ ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
+ memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
+ fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
+ resolveFilePath: (namespace) =>
+ path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
+ });
+
+ return {
+ shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
+ const replayKey = buildReplayKey({ roomToken, messageId });
+ if (!replayKey) {
+ return true;
+ }
+ return await persistentDedupe.checkAndRecord(replayKey, {
+ namespace: accountId,
+ onDiskError: options.onDiskError,
+ });
+ },
+ };
+}
diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts
index a9fe49be36d..e7af64a965c 100644
--- a/extensions/nextcloud-talk/src/types.ts
+++ b/extensions/nextcloud-talk/src/types.ts
@@ -170,6 +170,8 @@ export type NextcloudTalkWebhookServerOptions = {
secret: string;
maxBodyBytes?: number;
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise;
+ isBackendAllowed?: (backend: string) => boolean;
+ shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise;
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise;
onError?: (error: Error) => void;
abortSignal?: AbortSignal;
diff --git a/package.json b/package.json
index 81a8a66cb4b..5f6443b64c8 100644
--- a/package.json
+++ b/package.json
@@ -141,7 +141,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
- "@aws-sdk/client-bedrock": "^3.997.0",
+ "@aws-sdk/client-bedrock": "^3.998.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.0.1",
"@discordjs/voice": "^0.19.0",
@@ -151,10 +151,10 @@
"@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.55.0",
- "@mariozechner/pi-ai": "0.55.0",
- "@mariozechner/pi-coding-agent": "0.55.0",
- "@mariozechner/pi-tui": "0.55.0",
+ "@mariozechner/pi-agent-core": "0.55.1",
+ "@mariozechner/pi-ai": "0.55.1",
+ "@mariozechner/pi-coding-agent": "0.55.1",
+ "@mariozechner/pi-tui": "0.55.1",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.48",
"@slack/bolt": "^4.6.0",
@@ -204,7 +204,7 @@
"@types/node": "^25.3.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
- "@typescript/native-preview": "7.0.0-dev.20260224.1",
+ "@typescript/native-preview": "7.0.0-dev.20260225.1",
"@vitest/coverage-v8": "^4.0.18",
"lit": "^3.3.2",
"oxfmt": "0.35.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 36a04c9dfbc..f6f7eb8d54c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -24,8 +24,8 @@ importers:
specifier: 0.14.1
version: 0.14.1(zod@4.3.6)
'@aws-sdk/client-bedrock':
- specifier: ^3.997.0
- version: 3.997.0
+ specifier: ^3.998.0
+ version: 3.998.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.1.1)
@@ -54,23 +54,23 @@ importers:
specifier: 1.2.0-beta.3
version: 1.2.0-beta.3
'@mariozechner/pi-agent-core':
- specifier: 0.55.0
- version: 0.55.0(ws@8.19.0)(zod@4.3.6)
+ specifier: 0.55.1
+ version: 0.55.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai':
- specifier: 0.55.0
- version: 0.55.0(ws@8.19.0)(zod@4.3.6)
+ specifier: 0.55.1
+ version: 0.55.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
- specifier: 0.55.0
- version: 0.55.0(ws@8.19.0)(zod@4.3.6)
+ specifier: 0.55.1
+ version: 0.55.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui':
- specifier: 0.55.0
- version: 0.55.0
+ specifier: 0.55.1
+ version: 0.55.1
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
'@napi-rs/canvas':
specifier: ^0.1.89
- version: 0.1.92
+ version: 0.1.95
'@sinclair/typebox':
specifier: 0.34.48
version: 0.34.48
@@ -214,8 +214,8 @@ importers:
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
- specifier: 7.0.0-dev.20260224.1
- version: 7.0.0-dev.20260224.1
+ specifier: 7.0.0-dev.20260225.1
+ version: 7.0.0-dev.20260225.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)
@@ -236,7 +236,7 @@ importers:
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.20260224.1)(typescript@5.9.3)
+ version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -314,7 +314,7 @@ importers:
version: 10.6.1
openclaw:
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))
+ version: 2026.2.24(@napi-rs/canvas@0.1.95)(@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: {}
@@ -375,7 +375,7 @@ importers:
dependencies:
openclaw:
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))
+ version: 2026.2.24(@napi-rs/canvas@0.1.95)(@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:
@@ -557,226 +557,111 @@ packages:
'@aws-crypto/util@5.2.0':
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
- '@aws-sdk/client-bedrock-runtime@3.995.0':
- resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==}
+ '@aws-sdk/client-bedrock-runtime@3.998.0':
+ resolution: {integrity: sha512-orRgpdNmdRLik+en3xDxlGuT5AxQU+GFUTMn97ZdRuPLnAiY7Y6/8VTsod6y97/3NB8xuTZbH9wNXzW97IWNMA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-bedrock-runtime@3.997.0':
- resolution: {integrity: sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==}
+ '@aws-sdk/client-bedrock@3.998.0':
+ resolution: {integrity: sha512-NeSBIdsJwVtACGHXVoguJOsKhq6oR5Q2B6BUU7LWGqIl1skwPors77aLpOa2240ZFtX3Br/0lJYfxAhB8692KA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-bedrock@3.995.0':
- resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==}
+ '@aws-sdk/core@3.973.14':
+ resolution: {integrity: sha512-iAQ1jIGESTVjoqNNY9VlsE9FnCz+Hc8s+dgurF6WrgFyVIw+uggH+V102RFhwjRv4dLSSLfzjDwvQnLszov7TQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-bedrock@3.997.0':
- resolution: {integrity: sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==}
+ '@aws-sdk/credential-provider-env@3.972.12':
+ resolution: {integrity: sha512-WPtj/iAYHHd+NDM6AZoilZwUz0nMaPxbTPGLA7nhyIYRZN2L8trqfbNvm7g/Jr3gzfKp1LpO6AtBTnrhz9WW2g==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-sso@3.993.0':
- resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==}
+ '@aws-sdk/credential-provider-http@3.972.14':
+ resolution: {integrity: sha512-umtjCicH2o/Fcc8Fu1562UkDyt6gql4czTYVlUfHfAM8S4QEKggzmtHYYYpPfQcjFj1ajyy68ahYSuF67x4ptQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/core@3.973.11':
- resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==}
+ '@aws-sdk/credential-provider-ini@3.972.12':
+ resolution: {integrity: sha512-qjzgnMl6GIBbVeK74jBqSF07+s6kyeZl5R88qjMs302JlqkxE57jkvflDmZ9I017ffEWqIUa9/M4Hfp28qyu1g==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/core@3.973.13':
- resolution: {integrity: sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==}
+ '@aws-sdk/credential-provider-login@3.972.12':
+ resolution: {integrity: sha512-AO57y46PzG24bJzxWLk+FYJG6MzxvXoFXnOKnmKUGV43ub4/FS/4Rz7zCC6ThqUotgqEFd30l5LTAd65RP65pg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-env@3.972.11':
- resolution: {integrity: sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==}
+ '@aws-sdk/credential-provider-node@3.972.13':
+ resolution: {integrity: sha512-ME2sgus+gFRtiudy5Xqj9iT/tj8lHOIGrFgktuO5skJU4EngOvTZ1Hpj8mknrW4FgWXmpWhc88NtEscUuuDpKw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-env@3.972.9':
- resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==}
+ '@aws-sdk/credential-provider-process@3.972.12':
+ resolution: {integrity: sha512-msxrHBpVP5AOIDohNPCINUtL47f7XI1TEru3N13uM3nWUMvIRA1vFa8Tlxbxm1EntPPvLAxRmvE5EbjDjOZkbw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-http@3.972.11':
- resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==}
+ '@aws-sdk/credential-provider-sso@3.972.12':
+ resolution: {integrity: sha512-D5iC5546hJyhobJN0szOT4KVeJQ8z/meZq2B3lEDZFcvHONKw+tzq36DAJUy3qLTueeB2geSxiHXngQlA11eoA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-http@3.972.13':
- resolution: {integrity: sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==}
+ '@aws-sdk/credential-provider-web-identity@3.972.12':
+ resolution: {integrity: sha512-yluBahBVsduoA/zgV0NAXtwwXvQ6tNn95dNA3Hg+vISdiPWA46QY0d9PLO2KpNbjtm+1oGcWxemS4fYTwJ0W1w==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-ini@3.972.11':
- resolution: {integrity: sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==}
+ '@aws-sdk/eventstream-handler-node@3.972.8':
+ resolution: {integrity: sha512-tVrf8X7hKnqv3HyVraUbsQW5mfHlD++S5NSIbfQEx0sCRvIwUbTPDl/lJCxhNmZ2zjgUyBIXIKrWilFWBxzv+w==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-ini@3.972.9':
- resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==}
+ '@aws-sdk/middleware-eventstream@3.972.5':
+ resolution: {integrity: sha512-j8sFerTrzS9tEJhiW2k+T9hsELE+13D5H+mqMjTRyPSgAOebkiK9d4t8vjbLOXuk7yi5lop40x15MubgcjpLmQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-login@3.972.11':
- resolution: {integrity: sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==}
+ '@aws-sdk/middleware-host-header@3.972.5':
+ resolution: {integrity: sha512-dVA0m1cEQ2iA6yB19aHvWNeUVTuvTt3AXzT0aiIu2uxk0S7AcmwDCDaRgYa/v+eFHcJVxEnpYTozqA7X62xinw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-login@3.972.9':
- resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==}
+ '@aws-sdk/middleware-logger@3.972.5':
+ resolution: {integrity: sha512-03RqplLZjUTkYi0dDPR/bbOLnDLFNdaVvNENgA3XK7Ph1MhEBhUYlgoGfOyRAKApDZ+WG4ykOoA8jI8J04jmFA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-node@3.972.10':
- resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==}
+ '@aws-sdk/middleware-recursion-detection@3.972.5':
+ resolution: {integrity: sha512-2QSuuVkpHTe84+mDdnFjHX8rAP3g0yYwLVAhS3lQN1rW5Z/zNsf8/pYQrLjLO4n4sPCsUAkTa0Vrod0lk+o1Tg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-node@3.972.12':
- resolution: {integrity: sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==}
+ '@aws-sdk/middleware-user-agent@3.972.14':
+ resolution: {integrity: sha512-PzDz+yRAQuIzd+4ZY3s6/TYRzlNKAn4Gae3E5uLV7NnYHqrZHFoAfKE4beXcu3C51pA2/FQ3X2qOGSYqUoN1WQ==}
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'}
-
- '@aws-sdk/eventstream-handler-node@3.972.5':
- 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==}
+ '@aws-sdk/middleware-websocket@3.972.9':
+ resolution: {integrity: sha512-O+FSwU9UvKd+QNuGLHqvmP33kkH4jh8pAgdMo3wbFLf+u30fS9/2gbSSWWtNCcWkSNFyG6RUlKU7jPSLApFfGw==}
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==}
+ '@aws-sdk/nested-clients@3.996.2':
+ resolution: {integrity: sha512-W+u6EM8WRxOIhAhR2mXMHSaUygqItpTehkgxLwJngXqr9RlAR4t6CtECH7o7QK0ct3oyi5Z8ViDHtPbel+D2Rg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/nested-clients@3.995.0':
- resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==}
+ '@aws-sdk/region-config-resolver@3.972.5':
+ resolution: {integrity: sha512-AOitrygDwfTNCLCW7L+GScDy1p49FZ6WutTUFWROouoPetfVNmpL4q8TWD3MhfY/ynhoGhleUQENrBH374EU8w==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/nested-clients@3.996.1':
- resolution: {integrity: sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==}
+ '@aws-sdk/token-providers@3.998.0':
+ resolution: {integrity: sha512-JFzi44tQnENZQ+1DYcHfoa/wTRKkccz0VsNMow0rvsxZtqUEkeV2pYFbir35mHTyUKju9995ay1MAGxLt1dpRA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/region-config-resolver@3.972.3':
- resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==}
+ '@aws-sdk/types@3.973.3':
+ resolution: {integrity: sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/region-config-resolver@3.972.4':
- resolution: {integrity: sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==}
+ '@aws-sdk/util-endpoints@3.996.2':
+ resolution: {integrity: sha512-83E6T1CKi0/IozPzqRBKqduW0mS4UQdI3soBH6CG7UgupTADWunqEMOTuPWCs9XGjpJJ4ujj+yu7pn8svhp5yg==}
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'}
-
- '@aws-sdk/token-providers@3.995.0':
- 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'}
-
- '@aws-sdk/util-endpoints@3.995.0':
- 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==}
+ '@aws-sdk/util-format-url@3.972.5':
+ resolution: {integrity: sha512-PccfrPQVOEQSL8xaSvu988ESMlqdH1Qfk3AWPZksCOYPHyzYeUV988E+DBachXNV7tBVTUvK85cZYEZu7JtPxQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-locate-window@3.965.4':
resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/util-user-agent-browser@3.972.3':
- resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==}
+ '@aws-sdk/util-user-agent-browser@3.972.5':
+ resolution: {integrity: sha512-2ja1WqtuBaEAMgVoHYuWx393DF6ULqdt3OozeO7BosqouYaoU47Adtp9vEF+GImSG/Q8A+dqfwDULTTdMkHGUQ==}
- '@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==}
+ '@aws-sdk/util-user-agent-node@3.972.13':
+ resolution: {integrity: sha512-PHErmuu+v6iAST48zcsB2cYwDKW45gk6qCp49t1p0NGZ4EaFPr/tA5jl0X/ekDwvWbuT0LTj++fjjdVQAbuh0Q==}
engines: {node: '>=20.0.0'}
peerDependencies:
aws-crt: '>=1.0.0'
@@ -784,21 +669,8 @@ 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==}
+ '@aws-sdk/xml-builder@3.972.7':
+ resolution: {integrity: sha512-9GF86s6mHuc1TYCbuKatMDWl2PyK3KIkpRaI7ul2/gYZPfaLzKZ+ISHhxzVb9KVeakf75tUQe6CXW2gugSCXNw==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.2.3':
@@ -817,12 +689,12 @@ packages:
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
engines: {node: '>=20.0.0'}
- '@azure/msal-common@16.0.4':
- resolution: {integrity: sha512-0KZ9/wbUyZN65JLAx5bGNfWjkD0kRMUgM99oSpZFg7wEOb3XcKIiHrFnIpgyc8zZ70fHodyh8JKEOel1oN24Gw==}
+ '@azure/msal-common@16.1.0':
+ resolution: {integrity: sha512-uiX0ChrRFbreXlPlDR8LwHKmZpJudDAr124iNWJKJ+b7MJUWXmvVU3idSi/c5lk1FwLVZeMxhQir3BGdV09I+g==}
engines: {node: '>=0.8.0'}
- '@azure/msal-node@5.0.4':
- resolution: {integrity: sha512-WbA77m68noCw4qV+1tMm5nodll34JCDF0KmrSrp9LskS0bGbgHt98ZRxq69BQK5mjMqDD5ThHJOrrGSfzPybxw==}
+ '@azure/msal-node@5.0.5':
+ resolution: {integrity: sha512-CxUYSZgFiviUC3d8Hc+tT7uxre6QkPEWYEHWXmyEBzaO6tfFY4hs5KbXWU6s4q9Zv1NP/04qiR3mcujYLRuYuw==}
engines: {node: '>=20'}
'@babel/generator@8.0.0-rc.1':
@@ -1517,26 +1389,21 @@ packages:
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
hasBin: true
- '@mariozechner/pi-agent-core@0.54.1':
- 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==}
+ '@mariozechner/pi-agent-core@0.55.1':
+ resolution: {integrity: sha512-t9FAb4ouy8HJSIa8gSRC7j8oeUOb2XDdhvBiHj7FhfpYafj1vRPrvGIEXUV8fPJDCI07vhK9iztP27EPk+yEWw==}
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==}
+ '@mariozechner/pi-ai@0.55.1':
+ resolution: {integrity: sha512-JJX1LrVWPUPMExu0f89XR4nMNP37+FNLjEE4cIHq9Hi6xQtOiiEi7OjDFMx58hWsq81xH1CwmQXqGTWBjbXKTw==}
engines: {node: '>=20.0.0'}
hasBin: true
@@ -1545,14 +1412,19 @@ packages:
engines: {node: '>=20.0.0'}
hasBin: true
- '@mariozechner/pi-tui@0.54.1':
- resolution: {integrity: sha512-FY8QcLlr9T276oZAwMSSPo1drg+J9Y7B+A0S9g8Jh6IFJxymKZZq29/Vit6XDziJfZIgJDraC6lpobtxgTEoFQ==}
+ '@mariozechner/pi-coding-agent@0.55.1':
+ resolution: {integrity: sha512-H2M8mbBNyDqhON6+3m4H8CjqJ9taGq/CM3B8dG73+VJJIXFm5SExhU9bdgcw2xh0wWj8yEumsj0of6Tu+F7Ffg==}
engines: {node: '>=20.0.0'}
+ hasBin: true
'@mariozechner/pi-tui@0.55.0':
resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==}
engines: {node: '>=20.0.0'}
+ '@mariozechner/pi-tui@0.55.1':
+ resolution: {integrity: sha512-rnqDUp2fm/ySevC0Ltj/ZFRbEc1kZ1A4qHESejj9hA8NVrb/pX9g82XwTE762JOieEGrRWAtmHLNOm7/e4dJMw==}
+ engines: {node: '>=20.0.0'}
+
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
engines: {node: '>= 22'}
@@ -1576,144 +1448,74 @@ packages:
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
engines: {node: '>=14.0.0'}
- '@napi-rs/canvas-android-arm64@0.1.92':
- resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==}
+ '@napi-rs/canvas-android-arm64@0.1.95':
+ resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
- '@napi-rs/canvas-android-arm64@0.1.94':
- resolution: {integrity: sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [android]
-
- '@napi-rs/canvas-darwin-arm64@0.1.92':
- resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==}
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
+ resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@napi-rs/canvas-darwin-arm64@0.1.94':
- resolution: {integrity: sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [darwin]
-
- '@napi-rs/canvas-darwin-x64@0.1.92':
- resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==}
+ '@napi-rs/canvas-darwin-x64@0.1.95':
+ resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@napi-rs/canvas-darwin-x64@0.1.94':
- resolution: {integrity: sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [darwin]
-
- '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92':
- resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==}
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
+ resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
- '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94':
- resolution: {integrity: sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==}
- engines: {node: '>= 10'}
- cpu: [arm]
- os: [linux]
-
- '@napi-rs/canvas-linux-arm64-gnu@0.1.92':
- resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==}
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
+ resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@napi-rs/canvas-linux-arm64-gnu@0.1.94':
- resolution: {integrity: sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==}
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
+ resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@napi-rs/canvas-linux-arm64-musl@0.1.92':
- resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
-
- '@napi-rs/canvas-linux-arm64-musl@0.1.94':
- resolution: {integrity: sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
-
- '@napi-rs/canvas-linux-riscv64-gnu@0.1.92':
- resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==}
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
+ resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
- '@napi-rs/canvas-linux-riscv64-gnu@0.1.94':
- resolution: {integrity: sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==}
- engines: {node: '>= 10'}
- cpu: [riscv64]
- os: [linux]
-
- '@napi-rs/canvas-linux-x64-gnu@0.1.92':
- resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==}
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
+ resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@napi-rs/canvas-linux-x64-gnu@0.1.94':
- resolution: {integrity: sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==}
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
+ resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@napi-rs/canvas-linux-x64-musl@0.1.92':
- resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
-
- '@napi-rs/canvas-linux-x64-musl@0.1.94':
- resolution: {integrity: sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
-
- '@napi-rs/canvas-win32-arm64-msvc@0.1.92':
- resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==}
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
+ resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@napi-rs/canvas-win32-arm64-msvc@0.1.94':
- resolution: {integrity: sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [win32]
-
- '@napi-rs/canvas-win32-x64-msvc@0.1.92':
- resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==}
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
+ resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
- '@napi-rs/canvas-win32-x64-msvc@0.1.94':
- resolution: {integrity: sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [win32]
-
- '@napi-rs/canvas@0.1.92':
- resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==}
- engines: {node: '>= 10'}
-
- '@napi-rs/canvas@0.1.94':
- resolution: {integrity: sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==}
+ '@napi-rs/canvas@0.1.95':
+ resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.1.1':
@@ -1844,8 +1646,8 @@ packages:
resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==}
engines: {node: '>= 20'}
- '@octokit/endpoint@11.0.2':
- resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==}
+ '@octokit/endpoint@11.0.3':
+ resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==}
engines: {node: '>= 20'}
'@octokit/graphql@9.0.3':
@@ -1888,8 +1690,8 @@ packages:
peerDependencies:
'@octokit/core': '>=6'
- '@octokit/plugin-retry@8.0.3':
- resolution: {integrity: sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==}
+ '@octokit/plugin-retry@8.1.0':
+ resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': '>=7'
@@ -1904,8 +1706,8 @@ packages:
resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==}
engines: {node: '>= 20'}
- '@octokit/request@10.0.7':
- resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==}
+ '@octokit/request@10.0.8':
+ resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==}
engines: {node: '>= 20'}
'@octokit/types@16.0.0':
@@ -2696,22 +2498,10 @@ packages:
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'}
-
- '@smithy/config-resolver@4.4.6':
- 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'}
@@ -2720,82 +2510,42 @@ packages:
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'}
-
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'}
- '@smithy/is-array-buffer@4.2.0':
- 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'}
@@ -2804,22 +2554,10 @@ packages:
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'}
-
- '@smithy/middleware-endpoint@4.4.16':
- 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'}
@@ -2828,30 +2566,14 @@ packages:
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'}
-
- '@smithy/node-http-handler@4.4.10':
- 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'}
@@ -2860,46 +2582,22 @@ packages:
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'}
-
- '@smithy/shared-ini-file-loader@4.4.3':
- 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'}
@@ -2908,22 +2606,10 @@ packages:
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'}
-
- '@smithy/smithy-client@4.11.5':
- 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'}
@@ -2932,30 +2618,14 @@ packages:
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'}
-
- '@smithy/util-base64@4.3.0':
- 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'}
@@ -2964,50 +2634,26 @@ packages:
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
engines: {node: '>=14.0.0'}
- '@smithy/util-buffer-from@4.2.0':
- 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'}
@@ -3016,30 +2662,14 @@ packages:
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'}
-
- '@smithy/util-stream@4.5.12':
- 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'}
@@ -3048,18 +2678,10 @@ packages:
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
engines: {node: '>=14.0.0'}
- '@smithy/util-utf8@4.2.0':
- 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'}
@@ -3324,43 +2946,46 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
- '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
+ '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-3qSsqv7FmM4z09wEpEXdhmgMfiJF/OMOZa41AdgMsXTTRpX2/38hDg2KGhi3fc24M2T3MnLPLTqw6HyTOBaV1Q==}
cpu: [arm64]
os: [darwin]
- '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ==}
+ '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-F8ZCCX2UESHcbxvnkd1Dn5PTnOOgpGddFHYgn4usyWRMzNZLPP+YjyGALZe9zdR/D8L0uraND0Haok+TPq8xYg==}
cpu: [x64]
os: [darwin]
- '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg==}
+ '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-Up8Z/QNcwce5C4rWnbLNW5w7lRARdyKZcNbB1NMnaswaGOBdeDmdP0wbVsOgJMoDp6vnun+EkvrSft8hWLLhIg==}
cpu: [arm64]
os: [linux]
- '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ==}
+ '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-Iu5rnCmqwGIMUu//BXkl9VQaxAAsqVvFhU4mJoNexNkMxPqVcu9quqYAouY7tN/95WcKzUsPpyRfkThdbNFO/g==}
cpu: [arm]
os: [linux]
- '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w==}
+ '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-WWjIfHCWlcriempYYc/sPJ3HFt6znNZKp60nvDNih0+wmxNqEfT5Yzu5zAY0awIe7XLelFSY+bolkpzMYVWEIQ==}
cpu: [x64]
os: [linux]
- '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w==}
+ '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-lmfQO+HdmPMk0dtPoNo8dZereTUYNQuapsAI7nFHCP8F25I8eGKKXY2nD1R8W1hp/LmVtske1pqKFNN6IOCt5g==}
cpu: [arm64]
os: [win32]
- '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ==}
+ '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-e4eJyzR9ne0XreqYgQNqfX7SNuaePxggnUtVrLERgBv25QKwdQl72GnSXDhdxZHzrb97YwumiXWMQQJj9h8NCg==}
cpu: [x64]
os: [win32]
- '@typescript/native-preview@7.0.0-dev.20260224.1':
- resolution: {integrity: sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA==}
+ '@typescript/native-preview@7.0.0-dev.20260225.1':
+ resolution: {integrity: sha512-mUf1aON+eZLupLorX4214n4W6uWIz/lvNv81ErzjJylD/GyJPEJkvDLmgIK3bbvLpMwTRWdVJLhpLCah5Qe8iA==}
hasBin: true
'@typespec/ts-http-runtime@0.3.3':
@@ -3675,6 +3300,9 @@ packages:
bs58@6.0.0:
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -3987,6 +3615,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -4088,6 +3719,11 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
extsprintf@1.3.0:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
@@ -4109,6 +3745,9 @@ packages:
resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==}
hasBin: true
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -4228,10 +3867,6 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
- get-east-asian-width@1.4.0:
- resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
- engines: {node: '>=18'}
-
get-east-asian-width@1.5.0:
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
engines: {node: '>=18'}
@@ -4244,6 +3879,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
@@ -4422,8 +4061,8 @@ packages:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
- ipull@3.9.3:
- resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==}
+ ipull@3.9.5:
+ resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -4540,6 +4179,9 @@ packages:
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
+ json-with-bigint@3.5.3:
+ resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -4592,8 +4234,8 @@ packages:
lifecycle-utils@2.1.0:
resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==}
- lifecycle-utils@3.1.0:
- resolution: {integrity: sha512-kVvegv+r/icjIo1dkHv1hznVQi4FzEVglJD2IU4w07HzevIyH3BAYsFZzEIbBk/nNZjXHGgclJ5g9rz9QdBCLw==}
+ lifecycle-utils@3.1.1:
+ resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==}
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
@@ -5095,8 +4737,8 @@ packages:
zod:
optional: true
- openclaw@2026.2.23:
- resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==}
+ openclaw@2026.2.24:
+ resolution: {integrity: sha512-a6zrcS6v5tUWqzsFh5cNtyu5+Tra1UW5yvPtYhRYCKSS/q6lXrLu+dj0ylJPOHRPAho2alZZL1gw1Qd2hAd2sQ==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
@@ -5106,9 +4748,6 @@ packages:
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==}
@@ -5243,6 +4882,9 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
@@ -5357,6 +4999,9 @@ packages:
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
@@ -5604,8 +5249,8 @@ packages:
peerDependencies:
signal-polyfill: ^0.2.0
- simple-git@3.31.1:
- resolution: {integrity: sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==}
+ simple-git@3.32.2:
+ resolution: {integrity: sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==}
simple-yenc@1.0.4:
resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==}
@@ -6151,6 +5796,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
@@ -6184,7 +5832,7 @@ snapshots:
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
tslib: 2.8.1
'@aws-crypto/sha256-browser@5.2.0':
@@ -6192,7 +5840,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.2
+ '@aws-sdk/types': 3.973.3
'@aws-sdk/util-locate-window': 3.965.4
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
@@ -6200,7 +5848,7 @@ snapshots:
'@aws-crypto/sha256-js@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
tslib: 2.8.1
'@aws-crypto/supports-web-crypto@5.2.0':
@@ -6209,81 +5857,29 @@ snapshots:
'@aws-crypto/util@5.2.0':
dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
- '@aws-sdk/client-bedrock-runtime@3.995.0':
+ '@aws-sdk/client-bedrock-runtime@3.998.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/credential-provider-node': 3.972.10
- '@aws-sdk/eventstream-handler-node': 3.972.5
- '@aws-sdk/middleware-eventstream': 3.972.3
- '@aws-sdk/middleware-host-header': 3.972.3
- '@aws-sdk/middleware-logger': 3.972.3
- '@aws-sdk/middleware-recursion-detection': 3.972.3
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/middleware-websocket': 3.972.6
- '@aws-sdk/region-config-resolver': 3.972.3
- '@aws-sdk/token-providers': 3.995.0
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.995.0
- '@aws-sdk/util-user-agent-browser': 3.972.3
- '@aws-sdk/util-user-agent-node': 3.972.10
- '@smithy/config-resolver': 4.4.6
- '@smithy/core': 3.23.2
- '@smithy/eventstream-serde-browser': 4.2.8
- '@smithy/eventstream-serde-config-resolver': 4.3.8
- '@smithy/eventstream-serde-node': 4.2.8
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/hash-node': 4.2.8
- '@smithy/invalid-dependency': 4.2.8
- '@smithy/middleware-content-length': 4.2.8
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-retry': 4.4.33
- '@smithy/middleware-serde': 4.2.9
- '@smithy/middleware-stack': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/node-http-handler': 4.4.10
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-body-length-node': 4.2.1
- '@smithy/util-defaults-mode-browser': 4.3.32
- '@smithy/util-defaults-mode-node': 4.2.35
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/util-stream': 4.5.12
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/credential-provider-node': 3.972.13
+ '@aws-sdk/eventstream-handler-node': 3.972.8
+ '@aws-sdk/middleware-eventstream': 3.972.5
+ '@aws-sdk/middleware-host-header': 3.972.5
+ '@aws-sdk/middleware-logger': 3.972.5
+ '@aws-sdk/middleware-recursion-detection': 3.972.5
+ '@aws-sdk/middleware-user-agent': 3.972.14
+ '@aws-sdk/middleware-websocket': 3.972.9
+ '@aws-sdk/region-config-resolver': 3.972.5
+ '@aws-sdk/token-providers': 3.998.0
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/util-endpoints': 3.996.2
+ '@aws-sdk/util-user-agent-browser': 3.972.5
+ '@aws-sdk/util-user-agent-node': 3.972.13
'@smithy/config-resolver': 4.4.9
'@smithy/core': 3.23.6
'@smithy/eventstream-serde-browser': 4.2.10
@@ -6317,67 +5913,22 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/client-bedrock@3.995.0':
+ '@aws-sdk/client-bedrock@3.998.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/credential-provider-node': 3.972.10
- '@aws-sdk/middleware-host-header': 3.972.3
- '@aws-sdk/middleware-logger': 3.972.3
- '@aws-sdk/middleware-recursion-detection': 3.972.3
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/region-config-resolver': 3.972.3
- '@aws-sdk/token-providers': 3.995.0
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.995.0
- '@aws-sdk/util-user-agent-browser': 3.972.3
- '@aws-sdk/util-user-agent-node': 3.972.10
- '@smithy/config-resolver': 4.4.6
- '@smithy/core': 3.23.2
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/hash-node': 4.2.8
- '@smithy/invalid-dependency': 4.2.8
- '@smithy/middleware-content-length': 4.2.8
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-retry': 4.4.33
- '@smithy/middleware-serde': 4.2.9
- '@smithy/middleware-stack': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/node-http-handler': 4.4.10
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-body-length-node': 4.2.1
- '@smithy/util-defaults-mode-browser': 4.3.32
- '@smithy/util-defaults-mode-node': 4.2.35
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/credential-provider-node': 3.972.13
+ '@aws-sdk/middleware-host-header': 3.972.5
+ '@aws-sdk/middleware-logger': 3.972.5
+ '@aws-sdk/middleware-recursion-detection': 3.972.5
+ '@aws-sdk/middleware-user-agent': 3.972.14
+ '@aws-sdk/region-config-resolver': 3.972.5
+ '@aws-sdk/token-providers': 3.998.0
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/util-endpoints': 3.996.2
+ '@aws-sdk/util-user-agent-browser': 3.972.5
+ '@aws-sdk/util-user-agent-node': 3.972.13
'@smithy/config-resolver': 4.4.9
'@smithy/core': 3.23.6
'@smithy/fetch-http-handler': 5.3.11
@@ -6407,69 +5958,10 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/client-sso@3.993.0':
+ '@aws-sdk/core@3.973.14':
dependencies:
- '@aws-crypto/sha256-browser': 5.2.0
- '@aws-crypto/sha256-js': 5.2.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/middleware-host-header': 3.972.3
- '@aws-sdk/middleware-logger': 3.972.3
- '@aws-sdk/middleware-recursion-detection': 3.972.3
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/region-config-resolver': 3.972.3
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.993.0
- '@aws-sdk/util-user-agent-browser': 3.972.3
- '@aws-sdk/util-user-agent-node': 3.972.10
- '@smithy/config-resolver': 4.4.6
- '@smithy/core': 3.23.2
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/hash-node': 4.2.8
- '@smithy/invalid-dependency': 4.2.8
- '@smithy/middleware-content-length': 4.2.8
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-retry': 4.4.33
- '@smithy/middleware-serde': 4.2.9
- '@smithy/middleware-stack': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/node-http-handler': 4.4.10
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-body-length-node': 4.2.1
- '@smithy/util-defaults-mode-browser': 4.3.32
- '@smithy/util-defaults-mode-node': 4.2.35
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - aws-crt
-
- '@aws-sdk/core@3.973.11':
- dependencies:
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/xml-builder': 3.972.5
- '@smithy/core': 3.23.2
- '@smithy/node-config-provider': 4.3.8
- '@smithy/property-provider': 4.2.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/signature-v4': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/util-base64': 4.3.0
- '@smithy/util-middleware': 4.2.8
- '@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
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/xml-builder': 3.972.7
'@smithy/core': 3.23.6
'@smithy/node-config-provider': 4.3.10
'@smithy/property-provider': 4.2.10
@@ -6482,39 +5974,18 @@ snapshots:
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-env@3.972.11':
+ '@aws-sdk/credential-provider-env@3.972.12':
dependencies:
- '@aws-sdk/core': 3.973.13
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/types': 3.973.3
'@smithy/property-provider': 4.2.10
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-env@3.972.9':
+ '@aws-sdk/credential-provider-http@3.972.14':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/credential-provider-http@3.972.11':
- dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/types': 3.973.1
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/node-http-handler': 4.4.10
- '@smithy/property-provider': 4.2.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/types': 3.973.3
'@smithy/fetch-http-handler': 5.3.11
'@smithy/node-http-handler': 4.4.12
'@smithy/property-provider': 4.2.10
@@ -6524,17 +5995,17 @@ snapshots:
'@smithy/util-stream': 4.5.15
tslib: 2.8.1
- '@aws-sdk/credential-provider-ini@3.972.11':
+ '@aws-sdk/credential-provider-ini@3.972.12':
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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/credential-provider-env': 3.972.12
+ '@aws-sdk/credential-provider-http': 3.972.14
+ '@aws-sdk/credential-provider-login': 3.972.12
+ '@aws-sdk/credential-provider-process': 3.972.12
+ '@aws-sdk/credential-provider-sso': 3.972.12
+ '@aws-sdk/credential-provider-web-identity': 3.972.12
+ '@aws-sdk/nested-clients': 3.996.2
+ '@aws-sdk/types': 3.973.3
'@smithy/credential-provider-imds': 4.2.10
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
@@ -6543,30 +6014,11 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/credential-provider-ini@3.972.9':
+ '@aws-sdk/credential-provider-login@3.972.12':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/credential-provider-env': 3.972.9
- '@aws-sdk/credential-provider-http': 3.972.11
- '@aws-sdk/credential-provider-login': 3.972.9
- '@aws-sdk/credential-provider-process': 3.972.9
- '@aws-sdk/credential-provider-sso': 3.972.9
- '@aws-sdk/credential-provider-web-identity': 3.972.9
- '@aws-sdk/nested-clients': 3.993.0
- '@aws-sdk/types': 3.973.1
- '@smithy/credential-provider-imds': 4.2.8
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/nested-clients': 3.996.2
+ '@aws-sdk/types': 3.973.3
'@smithy/property-provider': 4.2.10
'@smithy/protocol-http': 5.3.10
'@smithy/shared-ini-file-loader': 4.4.5
@@ -6575,45 +6027,15 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/credential-provider-login@3.972.9':
+ '@aws-sdk/credential-provider-node@3.972.13':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/nested-clients': 3.993.0
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - aws-crt
-
- '@aws-sdk/credential-provider-node@3.972.10':
- dependencies:
- '@aws-sdk/credential-provider-env': 3.972.9
- '@aws-sdk/credential-provider-http': 3.972.11
- '@aws-sdk/credential-provider-ini': 3.972.9
- '@aws-sdk/credential-provider-process': 3.972.9
- '@aws-sdk/credential-provider-sso': 3.972.9
- '@aws-sdk/credential-provider-web-identity': 3.972.9
- '@aws-sdk/types': 3.973.1
- '@smithy/credential-provider-imds': 4.2.8
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/credential-provider-env': 3.972.12
+ '@aws-sdk/credential-provider-http': 3.972.14
+ '@aws-sdk/credential-provider-ini': 3.972.12
+ '@aws-sdk/credential-provider-process': 3.972.12
+ '@aws-sdk/credential-provider-sso': 3.972.12
+ '@aws-sdk/credential-provider-web-identity': 3.972.12
+ '@aws-sdk/types': 3.973.3
'@smithy/credential-provider-imds': 4.2.10
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
@@ -6622,30 +6044,21 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/credential-provider-process@3.972.11':
+ '@aws-sdk/credential-provider-process@3.972.12':
dependencies:
- '@aws-sdk/core': 3.973.13
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/types': 3.973.3
'@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':
+ '@aws-sdk/credential-provider-sso@3.972.12':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/credential-provider-sso@3.972.11':
- dependencies:
- '@aws-sdk/core': 3.973.13
- '@aws-sdk/nested-clients': 3.996.1
- '@aws-sdk/token-providers': 3.997.0
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/nested-clients': 3.996.2
+ '@aws-sdk/token-providers': 3.998.0
+ '@aws-sdk/types': 3.973.3
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
'@smithy/types': 4.13.0
@@ -6653,24 +6066,11 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/credential-provider-sso@3.972.9':
+ '@aws-sdk/credential-provider-web-identity@3.972.12':
dependencies:
- '@aws-sdk/client-sso': 3.993.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/token-providers': 3.993.0
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/nested-clients': 3.996.2
+ '@aws-sdk/types': 3.973.3
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
'@smithy/types': 4.13.0
@@ -6678,127 +6078,55 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/credential-provider-web-identity@3.972.9':
+ '@aws-sdk/eventstream-handler-node@3.972.8':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/nested-clients': 3.993.0
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - aws-crt
-
- '@aws-sdk/eventstream-handler-node@3.972.5':
- dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/eventstream-codec': 4.2.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/eventstream-handler-node@3.972.7':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/eventstream-codec': 4.2.10
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/middleware-eventstream@3.972.3':
+ '@aws-sdk/middleware-eventstream@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/middleware-eventstream@3.972.4':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/protocol-http': 5.3.10
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/middleware-host-header@3.972.3':
+ '@aws-sdk/middleware-host-header@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/middleware-host-header@3.972.4':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/protocol-http': 5.3.10
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/middleware-logger@3.972.3':
+ '@aws-sdk/middleware-logger@3.972.5':
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
+ '@aws-sdk/types': 3.973.3
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/middleware-recursion-detection@3.972.3':
+ '@aws-sdk/middleware-recursion-detection@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@aws/lambda-invoke-store': 0.2.3
- '@smithy/protocol-http': 5.3.8
- '@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-sdk/types': 3.973.3
'@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':
+ '@aws-sdk/middleware-user-agent@3.972.14':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.993.0
- '@smithy/core': 3.23.2
- '@smithy/protocol-http': 5.3.8
- '@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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/util-endpoints': 3.996.2
'@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':
+ '@aws-sdk/middleware-websocket@3.972.9':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-format-url': 3.972.3
- '@smithy/eventstream-codec': 4.2.8
- '@smithy/eventstream-serde-browser': 4.2.8
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/protocol-http': 5.3.8
- '@smithy/signature-v4': 5.3.8
- '@smithy/types': 4.12.0
- '@smithy/util-base64': 4.3.0
- '@smithy/util-hex-encoding': 4.2.0
- '@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
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/util-format-url': 3.972.5
'@smithy/eventstream-codec': 4.2.10
'@smithy/eventstream-serde-browser': 4.2.10
'@smithy/fetch-http-handler': 5.3.11
@@ -6810,106 +6138,20 @@ snapshots:
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
- '@aws-sdk/nested-clients@3.993.0':
+ '@aws-sdk/nested-clients@3.996.2':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/middleware-host-header': 3.972.3
- '@aws-sdk/middleware-logger': 3.972.3
- '@aws-sdk/middleware-recursion-detection': 3.972.3
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/region-config-resolver': 3.972.3
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.993.0
- '@aws-sdk/util-user-agent-browser': 3.972.3
- '@aws-sdk/util-user-agent-node': 3.972.10
- '@smithy/config-resolver': 4.4.6
- '@smithy/core': 3.23.2
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/hash-node': 4.2.8
- '@smithy/invalid-dependency': 4.2.8
- '@smithy/middleware-content-length': 4.2.8
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-retry': 4.4.33
- '@smithy/middleware-serde': 4.2.9
- '@smithy/middleware-stack': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/node-http-handler': 4.4.10
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-body-length-node': 4.2.1
- '@smithy/util-defaults-mode-browser': 4.3.32
- '@smithy/util-defaults-mode-node': 4.2.35
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - aws-crt
-
- '@aws-sdk/nested-clients@3.995.0':
- dependencies:
- '@aws-crypto/sha256-browser': 5.2.0
- '@aws-crypto/sha256-js': 5.2.0
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/middleware-host-header': 3.972.3
- '@aws-sdk/middleware-logger': 3.972.3
- '@aws-sdk/middleware-recursion-detection': 3.972.3
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/region-config-resolver': 3.972.3
- '@aws-sdk/types': 3.973.1
- '@aws-sdk/util-endpoints': 3.995.0
- '@aws-sdk/util-user-agent-browser': 3.972.3
- '@aws-sdk/util-user-agent-node': 3.972.10
- '@smithy/config-resolver': 4.4.6
- '@smithy/core': 3.23.2
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/hash-node': 4.2.8
- '@smithy/invalid-dependency': 4.2.8
- '@smithy/middleware-content-length': 4.2.8
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-retry': 4.4.33
- '@smithy/middleware-serde': 4.2.9
- '@smithy/middleware-stack': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/node-http-handler': 4.4.10
- '@smithy/protocol-http': 5.3.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-body-length-node': 4.2.1
- '@smithy/util-defaults-mode-browser': 4.3.32
- '@smithy/util-defaults-mode-node': 4.2.35
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/middleware-host-header': 3.972.5
+ '@aws-sdk/middleware-logger': 3.972.5
+ '@aws-sdk/middleware-recursion-detection': 3.972.5
+ '@aws-sdk/middleware-user-agent': 3.972.14
+ '@aws-sdk/region-config-resolver': 3.972.5
+ '@aws-sdk/types': 3.973.3
+ '@aws-sdk/util-endpoints': 3.996.2
+ '@aws-sdk/util-user-agent-browser': 3.972.5
+ '@aws-sdk/util-user-agent-node': 3.972.13
'@smithy/config-resolver': 4.4.9
'@smithy/core': 3.23.6
'@smithy/fetch-http-handler': 5.3.11
@@ -6939,51 +6181,19 @@ snapshots:
transitivePeerDependencies:
- aws-crt
- '@aws-sdk/region-config-resolver@3.972.3':
+ '@aws-sdk/region-config-resolver@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/config-resolver': 4.4.6
- '@smithy/node-config-provider': 4.3.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/region-config-resolver@3.972.4':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@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':
+ '@aws-sdk/token-providers@3.998.0':
dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/nested-clients': 3.993.0
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - aws-crt
-
- '@aws-sdk/token-providers@3.995.0':
- dependencies:
- '@aws-sdk/core': 3.973.11
- '@aws-sdk/nested-clients': 3.995.0
- '@aws-sdk/types': 3.973.1
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
- 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
+ '@aws-sdk/core': 3.973.14
+ '@aws-sdk/nested-clients': 3.996.2
+ '@aws-sdk/types': 3.973.3
'@smithy/property-provider': 4.2.10
'@smithy/shared-ini-file-loader': 4.4.5
'@smithy/types': 4.13.0
@@ -6991,50 +6201,22 @@ snapshots:
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':
+ '@aws-sdk/types@3.973.3':
dependencies:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@aws-sdk/util-endpoints@3.993.0':
+ '@aws-sdk/util-endpoints@3.996.2':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-endpoints': 3.2.8
- tslib: 2.8.1
-
- '@aws-sdk/util-endpoints@3.995.0':
- dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-endpoints': 3.2.8
- tslib: 2.8.1
-
- '@aws-sdk/util-endpoints@3.996.1':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@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':
+ '@aws-sdk/util-format-url@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/querystring-builder': 4.2.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@aws-sdk/util-format-url@3.972.4':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/querystring-builder': 4.2.10
'@smithy/types': 4.13.0
tslib: 2.8.1
@@ -7043,43 +6225,22 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@aws-sdk/util-user-agent-browser@3.972.3':
+ '@aws-sdk/util-user-agent-browser@3.972.5':
dependencies:
- '@aws-sdk/types': 3.973.1
- '@smithy/types': 4.12.0
- bowser: 2.14.1
- tslib: 2.8.1
-
- '@aws-sdk/util-user-agent-browser@3.972.4':
- dependencies:
- '@aws-sdk/types': 3.973.2
+ '@aws-sdk/types': 3.973.3
'@smithy/types': 4.13.0
bowser: 2.14.1
tslib: 2.8.1
- '@aws-sdk/util-user-agent-node@3.972.10':
+ '@aws-sdk/util-user-agent-node@3.972.13':
dependencies:
- '@aws-sdk/middleware-user-agent': 3.972.11
- '@aws-sdk/types': 3.973.1
- '@smithy/node-config-provider': 4.3.8
- '@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
+ '@aws-sdk/middleware-user-agent': 3.972.14
+ '@aws-sdk/types': 3.973.3
'@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':
+ '@aws-sdk/xml-builder@3.972.7':
dependencies:
'@smithy/types': 4.13.0
fast-xml-parser: 5.3.6
@@ -7107,11 +6268,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@azure/msal-common@16.0.4': {}
+ '@azure/msal-common@16.1.0': {}
- '@azure/msal-node@5.0.4':
+ '@azure/msal-node@5.0.5':
dependencies:
- '@azure/msal-common': 16.0.4
+ '@azure/msal-common': 16.1.0
jsonwebtoken: 9.0.3
uuid: 8.3.2
@@ -7156,26 +6317,6 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
- '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8)':
- 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.0.8)
- '@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
-
'@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
@@ -7332,21 +6473,6 @@ snapshots:
- supports-color
optional: true
- '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)':
- dependencies:
- '@types/ws': 8.18.1
- discord-api-types: 0.38.40
- prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8)
- tslib: 2.8.1
- ws: 8.19.0
- transitivePeerDependencies:
- - '@discordjs/opus'
- - bufferutil
- - ffmpeg-static
- - node-opus
- - opusscript
- - utf-8-validate
-
'@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)':
dependencies:
'@types/ws': 8.18.1
@@ -7804,18 +6930,6 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
- '@mariozechner/pi-agent-core@0.54.1(ws@8.19.0)(zod@4.3.6)':
- dependencies:
- '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6)
- transitivePeerDependencies:
- - '@modelcontextprotocol/sdk'
- - aws-crt
- - bufferutil
- - supports-color
- - utf-8-validate
- - 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)
@@ -7828,21 +6942,9 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-ai@0.54.1(ws@8.19.0)(zod@4.3.6)':
+ '@mariozechner/pi-agent-core@0.55.1(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.995.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)
+ '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@@ -7855,7 +6957,7 @@ snapshots:
'@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
+ '@aws-sdk/client-bedrock-runtime': 3.998.0
'@google/genai': 1.42.0
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
@@ -7876,26 +6978,21 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-coding-agent@0.54.1(ws@8.19.0)(zod@4.3.6)':
+ '@mariozechner/pi-ai@0.55.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
- '@mariozechner/jiti': 2.6.5
- '@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-tui': 0.54.1
- '@silvia-odwyer/photon-node': 0.3.4
+ '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
+ '@aws-sdk/client-bedrock-runtime': 3.998.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
- 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
+ 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
@@ -7934,7 +7031,37 @@ snapshots:
- ws
- zod
- '@mariozechner/pi-tui@0.54.1':
+ '@mariozechner/pi-coding-agent@0.55.1(ws@8.19.0)(zod@4.3.6)':
+ dependencies:
+ '@mariozechner/jiti': 2.6.5
+ '@mariozechner/pi-agent-core': 0.55.1(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-tui': 0.55.1
+ '@silvia-odwyer/photon-node': 0.3.4
+ chalk: 5.6.2
+ cli-highlight: 2.1.11
+ diff: 8.0.3
+ extract-zip: 2.0.1
+ 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.55.0':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -7943,7 +7070,7 @@ snapshots:
marked: 15.0.12
mime-types: 3.0.2
- '@mariozechner/pi-tui@0.55.0':
+ '@mariozechner/pi-tui@0.55.1':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -7972,7 +7099,7 @@ snapshots:
'@microsoft/agents-hosting@1.3.1':
dependencies:
'@azure/core-auth': 1.10.1
- '@azure/msal-node': 5.0.4
+ '@azure/msal-node': 5.0.5
'@microsoft/agents-activity': 1.3.1
axios: 1.13.5(debug@4.4.3)
jsonwebtoken: 9.0.3
@@ -7990,99 +7117,52 @@ snapshots:
'@mozilla/readability@0.6.0': {}
- '@napi-rs/canvas-android-arm64@0.1.92':
+ '@napi-rs/canvas-android-arm64@0.1.95':
optional: true
- '@napi-rs/canvas-android-arm64@0.1.94':
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
optional: true
- '@napi-rs/canvas-darwin-arm64@0.1.92':
+ '@napi-rs/canvas-darwin-x64@0.1.95':
optional: true
- '@napi-rs/canvas-darwin-arm64@0.1.94':
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
optional: true
- '@napi-rs/canvas-darwin-x64@0.1.92':
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
optional: true
- '@napi-rs/canvas-darwin-x64@0.1.94':
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92':
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94':
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm64-gnu@0.1.92':
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm64-gnu@0.1.94':
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm64-musl@0.1.92':
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
optional: true
- '@napi-rs/canvas-linux-arm64-musl@0.1.94':
- optional: true
-
- '@napi-rs/canvas-linux-riscv64-gnu@0.1.92':
- optional: true
-
- '@napi-rs/canvas-linux-riscv64-gnu@0.1.94':
- optional: true
-
- '@napi-rs/canvas-linux-x64-gnu@0.1.92':
- optional: true
-
- '@napi-rs/canvas-linux-x64-gnu@0.1.94':
- optional: true
-
- '@napi-rs/canvas-linux-x64-musl@0.1.92':
- optional: true
-
- '@napi-rs/canvas-linux-x64-musl@0.1.94':
- optional: true
-
- '@napi-rs/canvas-win32-arm64-msvc@0.1.92':
- optional: true
-
- '@napi-rs/canvas-win32-arm64-msvc@0.1.94':
- optional: true
-
- '@napi-rs/canvas-win32-x64-msvc@0.1.92':
- optional: true
-
- '@napi-rs/canvas-win32-x64-msvc@0.1.94':
- optional: true
-
- '@napi-rs/canvas@0.1.92':
+ '@napi-rs/canvas@0.1.95':
optionalDependencies:
- '@napi-rs/canvas-android-arm64': 0.1.92
- '@napi-rs/canvas-darwin-arm64': 0.1.92
- '@napi-rs/canvas-darwin-x64': 0.1.92
- '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92
- '@napi-rs/canvas-linux-arm64-gnu': 0.1.92
- '@napi-rs/canvas-linux-arm64-musl': 0.1.92
- '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92
- '@napi-rs/canvas-linux-x64-gnu': 0.1.92
- '@napi-rs/canvas-linux-x64-musl': 0.1.92
- '@napi-rs/canvas-win32-arm64-msvc': 0.1.92
- '@napi-rs/canvas-win32-x64-msvc': 0.1.92
-
- '@napi-rs/canvas@0.1.94':
- optionalDependencies:
- '@napi-rs/canvas-android-arm64': 0.1.94
- '@napi-rs/canvas-darwin-arm64': 0.1.94
- '@napi-rs/canvas-darwin-x64': 0.1.94
- '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.94
- '@napi-rs/canvas-linux-arm64-gnu': 0.1.94
- '@napi-rs/canvas-linux-arm64-musl': 0.1.94
- '@napi-rs/canvas-linux-riscv64-gnu': 0.1.94
- '@napi-rs/canvas-linux-x64-gnu': 0.1.94
- '@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
+ '@napi-rs/canvas-android-arm64': 0.1.95
+ '@napi-rs/canvas-darwin-arm64': 0.1.95
+ '@napi-rs/canvas-darwin-x64': 0.1.95
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.95
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.95
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.95
+ '@napi-rs/canvas-linux-x64-musl': 0.1.95
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.95
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.95
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
@@ -8154,7 +7234,7 @@ snapshots:
dependencies:
'@octokit/auth-oauth-app': 9.0.3
'@octokit/auth-oauth-user': 6.0.2
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
toad-cache: 3.7.0
@@ -8165,14 +7245,14 @@ snapshots:
dependencies:
'@octokit/auth-oauth-device': 8.0.3
'@octokit/auth-oauth-user': 6.0.2
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
'@octokit/auth-oauth-device@8.0.3':
dependencies:
'@octokit/oauth-methods': 6.0.2
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
@@ -8180,7 +7260,7 @@ snapshots:
dependencies:
'@octokit/auth-oauth-device': 8.0.3
'@octokit/oauth-methods': 6.0.2
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
@@ -8195,20 +7275,20 @@ snapshots:
dependencies:
'@octokit/auth-token': 6.0.0
'@octokit/graphql': 9.0.3
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.3
- '@octokit/endpoint@11.0.2':
+ '@octokit/endpoint@11.0.3':
dependencies:
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
'@octokit/graphql@9.0.3':
dependencies:
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
@@ -8228,7 +7308,7 @@ snapshots:
'@octokit/oauth-methods@6.0.2':
dependencies:
'@octokit/oauth-authorization-url': 8.0.0
- '@octokit/request': 10.0.7
+ '@octokit/request': 10.0.8
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
@@ -8250,7 +7330,7 @@ snapshots:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
- '@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)':
+ '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/request-error': 7.1.0
@@ -8267,12 +7347,13 @@ snapshots:
dependencies:
'@octokit/types': 16.0.0
- '@octokit/request@10.0.7':
+ '@octokit/request@10.0.8':
dependencies:
- '@octokit/endpoint': 11.0.2
+ '@octokit/endpoint': 11.0.3
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
fast-content-type-parse: 3.0.0
+ json-with-bigint: 3.5.3
universal-user-agent: 7.0.3
'@octokit/types@16.0.0':
@@ -8937,20 +8018,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@smithy/abort-controller@4.2.8':
- dependencies:
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@smithy/config-resolver@4.4.6':
- dependencies:
- '@smithy/node-config-provider': 4.3.8
- '@smithy/types': 4.12.0
- '@smithy/util-config-provider': 4.2.0
- '@smithy/util-endpoints': 3.2.8
- '@smithy/util-middleware': 4.2.8
- tslib: 2.8.1
-
'@smithy/config-resolver@4.4.9':
dependencies:
'@smithy/node-config-provider': 4.3.10
@@ -8960,19 +8027,6 @@ snapshots:
'@smithy/util-middleware': 4.2.10
tslib: 2.8.1
- '@smithy/core@3.23.2':
- dependencies:
- '@smithy/middleware-serde': 4.2.9
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- '@smithy/util-base64': 4.3.0
- '@smithy/util-body-length-browser': 4.2.0
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-stream': 4.5.12
- '@smithy/util-utf8': 4.2.0
- '@smithy/uuid': 1.1.0
- tslib: 2.8.1
-
'@smithy/core@3.23.6':
dependencies:
'@smithy/middleware-serde': 4.2.11
@@ -8994,14 +8048,6 @@ snapshots:
'@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
- '@smithy/property-provider': 4.2.8
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- tslib: 2.8.1
-
'@smithy/eventstream-codec@4.2.10':
dependencies:
'@aws-crypto/crc32': 5.2.0
@@ -9009,59 +8055,29 @@ snapshots:
'@smithy/util-hex-encoding': 4.2.1
tslib: 2.8.1
- '@smithy/eventstream-codec@4.2.8':
- dependencies:
- '@aws-crypto/crc32': 5.2.0
- '@smithy/types': 4.12.0
- '@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
@@ -9070,14 +8086,6 @@ snapshots:
'@smithy/util-base64': 4.3.1
tslib: 2.8.1
- '@smithy/fetch-http-handler@5.3.9':
- dependencies:
- '@smithy/protocol-http': 5.3.8
- '@smithy/querystring-builder': 4.2.8
- '@smithy/types': 4.12.0
- '@smithy/util-base64': 4.3.0
- tslib: 2.8.1
-
'@smithy/hash-node@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -9085,31 +8093,15 @@ snapshots:
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
- '@smithy/hash-node@4.2.8':
- dependencies:
- '@smithy/types': 4.12.0
- '@smithy/util-buffer-from': 4.2.0
- '@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
- tslib: 2.8.1
-
'@smithy/is-array-buffer@2.2.0':
dependencies:
tslib: 2.8.1
- '@smithy/is-array-buffer@4.2.0':
- dependencies:
- tslib: 2.8.1
-
'@smithy/is-array-buffer@4.2.1':
dependencies:
tslib: 2.8.1
@@ -9120,23 +8112,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@smithy/middleware-content-length@4.2.8':
- dependencies:
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@smithy/middleware-endpoint@4.4.16':
- dependencies:
- '@smithy/core': 3.23.2
- '@smithy/middleware-serde': 4.2.9
- '@smithy/node-config-provider': 4.3.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- '@smithy/url-parser': 4.2.8
- '@smithy/util-middleware': 4.2.8
- tslib: 2.8.1
-
'@smithy/middleware-endpoint@4.4.20':
dependencies:
'@smithy/core': 3.23.6
@@ -9148,18 +8123,6 @@ snapshots:
'@smithy/util-middleware': 4.2.10
tslib: 2.8.1
- '@smithy/middleware-retry@4.4.33':
- dependencies:
- '@smithy/node-config-provider': 4.3.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/service-error-classification': 4.2.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-retry': 4.2.8
- '@smithy/uuid': 1.1.0
- tslib: 2.8.1
-
'@smithy/middleware-retry@4.4.37':
dependencies:
'@smithy/node-config-provider': 4.3.10
@@ -9178,22 +8141,11 @@ snapshots:
'@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
@@ -9201,21 +8153,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@smithy/node-config-provider@4.3.8':
- dependencies:
- '@smithy/property-provider': 4.2.8
- '@smithy/shared-ini-file-loader': 4.4.3
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@smithy/node-http-handler@4.4.10':
- dependencies:
- '@smithy/abort-controller': 4.2.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/querystring-builder': 4.2.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
'@smithy/node-http-handler@4.4.12':
dependencies:
'@smithy/abort-controller': 4.2.10
@@ -9229,56 +8166,26 @@ snapshots:
'@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
-
- '@smithy/shared-ini-file-loader@4.4.3':
- dependencies:
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
'@smithy/shared-ini-file-loader@4.4.5':
dependencies:
'@smithy/types': 4.13.0
@@ -9295,27 +8202,6 @@ snapshots:
'@smithy/util-utf8': 4.2.1
tslib: 2.8.1
- '@smithy/signature-v4@5.3.8':
- dependencies:
- '@smithy/is-array-buffer': 4.2.0
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- '@smithy/util-hex-encoding': 4.2.0
- '@smithy/util-middleware': 4.2.8
- '@smithy/util-uri-escape': 4.2.0
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
-
- '@smithy/smithy-client@4.11.5':
- dependencies:
- '@smithy/core': 3.23.2
- '@smithy/middleware-endpoint': 4.4.16
- '@smithy/middleware-stack': 4.2.8
- '@smithy/protocol-http': 5.3.8
- '@smithy/types': 4.12.0
- '@smithy/util-stream': 4.5.12
- tslib: 2.8.1
-
'@smithy/smithy-client@4.12.0':
dependencies:
'@smithy/core': 3.23.6
@@ -9326,10 +8212,6 @@ snapshots:
'@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
@@ -9340,36 +8222,16 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@smithy/url-parser@4.2.8':
- dependencies:
- '@smithy/querystring-parser': 4.2.8
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@smithy/util-base64@4.3.0':
- dependencies:
- '@smithy/util-buffer-from': 4.2.0
- '@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
@@ -9379,31 +8241,15 @@ snapshots:
'@smithy/is-array-buffer': 2.2.0
tslib: 2.8.1
- '@smithy/util-buffer-from@4.2.0':
- dependencies:
- '@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
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
'@smithy/util-defaults-mode-browser@4.3.36':
dependencies:
'@smithy/property-provider': 4.2.10
@@ -9411,16 +8257,6 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
- '@smithy/util-defaults-mode-node@4.2.35':
- dependencies:
- '@smithy/config-resolver': 4.4.6
- '@smithy/credential-provider-imds': 4.2.8
- '@smithy/node-config-provider': 4.3.8
- '@smithy/property-provider': 4.2.8
- '@smithy/smithy-client': 4.11.5
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
'@smithy/util-defaults-mode-node@4.2.39':
dependencies:
'@smithy/config-resolver': 4.4.9
@@ -9431,22 +8267,12 @@ snapshots:
'@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
@@ -9456,34 +8282,12 @@ snapshots:
'@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
- '@smithy/types': 4.12.0
- tslib: 2.8.1
-
- '@smithy/util-stream@4.5.12':
- dependencies:
- '@smithy/fetch-http-handler': 5.3.9
- '@smithy/node-http-handler': 4.4.10
- '@smithy/types': 4.12.0
- '@smithy/util-base64': 4.3.0
- '@smithy/util-buffer-from': 4.2.0
- '@smithy/util-hex-encoding': 4.2.0
- '@smithy/util-utf8': 4.2.0
- tslib: 2.8.1
-
'@smithy/util-stream@4.5.15':
dependencies:
'@smithy/fetch-http-handler': 5.3.11
@@ -9495,10 +8299,6 @@ snapshots:
'@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
@@ -9508,20 +8308,11 @@ snapshots:
'@smithy/util-buffer-from': 2.2.0
tslib: 2.8.1
- '@smithy/util-utf8@4.2.0':
- dependencies:
- '@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
@@ -9813,36 +8604,41 @@ snapshots:
dependencies:
'@types/node': 25.3.0
- '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1':
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 25.3.0
optional: true
- '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1':
optional: true
- '@typescript/native-preview@7.0.0-dev.20260224.1':
+ '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1':
+ optional: true
+
+ '@typescript/native-preview@7.0.0-dev.20260225.1':
optionalDependencies:
- '@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
+ '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-linux-arm': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-linux-x64': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260225.1
+ '@typescript/native-preview-win32-x64': 7.0.0-dev.20260225.1
'@typespec/ts-http-runtime@0.3.3':
dependencies:
@@ -10243,6 +9039,8 @@ snapshots:
dependencies:
base-x: 5.0.1
+ buffer-crc32@0.2.13: {}
+
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@@ -10519,6 +9317,10 @@ snapshots:
encodeurl@2.0.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
entities@4.5.0: {}
entities@7.0.1: {}
@@ -10678,6 +9480,16 @@ snapshots:
extend@3.0.2: {}
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
extsprintf@1.3.0: {}
fake-indexeddb@6.2.5: {}
@@ -10692,6 +9504,10 @@ snapshots:
dependencies:
strnum: 2.1.2
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -10835,8 +9651,6 @@ snapshots:
get-caller-file@2.0.5: {}
- get-east-asian-width@1.4.0: {}
-
get-east-asian-width@1.5.0: {}
get-intrinsic@1.3.0:
@@ -10857,6 +9671,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.3
+
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -11068,7 +9886,7 @@ snapshots:
ipaddr.js@2.3.0: {}
- ipull@3.9.3:
+ ipull@3.9.5:
dependencies:
'@tinyhttp/content-disposition': 2.2.4
async-retry: 1.3.3
@@ -11111,7 +9929,7 @@ snapshots:
is-fullwidth-code-point@5.1.0:
dependencies:
- get-east-asian-width: 1.4.0
+ get-east-asian-width: 1.5.0
is-interactive@2.0.0: {}
@@ -11185,6 +10003,8 @@ snapshots:
json-stringify-safe@5.0.1: {}
+ json-with-bigint@3.5.3: {}
+
json5@2.2.3: {}
jsonfile@6.2.0:
@@ -11259,7 +10079,7 @@ snapshots:
lifecycle-utils@2.1.0: {}
- lifecycle-utils@3.1.0: {}
+ lifecycle-utils@3.1.1: {}
lightningcss-android-arm64@1.30.2:
optional: true
@@ -11622,9 +10442,9 @@ snapshots:
filenamify: 6.0.0
fs-extra: 11.3.3
ignore: 7.0.5
- ipull: 3.9.3
+ ipull: 3.9.5
is-unicode-supported: 2.1.0
- lifecycle-utils: 3.1.0
+ lifecycle-utils: 3.1.1
log-symbols: 7.0.1
nanoid: 5.1.6
node-addon-api: 8.5.0
@@ -11633,7 +10453,7 @@ snapshots:
pretty-ms: 9.3.0
proper-lockfile: 4.1.2
semver: 7.7.4
- simple-git: 3.31.1
+ simple-git: 3.32.2
slice-ansi: 7.1.2
stdout-update: 4.0.1
strip-ansi: 7.1.2
@@ -11718,7 +10538,7 @@ snapshots:
'@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.6)
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
'@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6)
- '@octokit/plugin-retry': 8.0.3(@octokit/core@7.0.6)
+ '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6)
'@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6)
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
@@ -11766,28 +10586,29 @@ snapshots:
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)):
+ openclaw@2026.2.24(@napi-rs/canvas@0.1.95)(@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)
+ '@aws-sdk/client-bedrock': 3.998.0
+ '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)
'@clack/prompts': 1.0.1
- '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)
+ '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@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
+ '@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-coding-agent': 0.55.0(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-tui': 0.55.0
'@mozilla/readability': 0.6.0
- '@napi-rs/canvas': 0.1.94
+ '@napi-rs/canvas': 0.1.95
'@sinclair/typebox': 0.34.48
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.14.1
+ '@snazzah/davey': 0.1.9
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.18.0
chalk: 5.6.2
@@ -11810,7 +10631,7 @@ snapshots:
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
+ opusscript: 0.1.1
osc-progress: 0.3.0
pdfjs-dist: 5.4.624
playwright-core: 1.58.2
@@ -11847,8 +10668,6 @@ snapshots:
'@wasm-audio-decoders/common': 9.0.7
optional: true
- opusscript@0.0.8: {}
-
opusscript@0.1.1: {}
ora@8.2.0:
@@ -12016,11 +10835,13 @@ snapshots:
pdfjs-dist@5.4.624:
optionalDependencies:
- '@napi-rs/canvas': 0.1.94
+ '@napi-rs/canvas': 0.1.95
node-readable-to-web-readable-stream: 0.4.2
peberminta@0.9.0: {}
+ pend@1.2.0: {}
+
performance-now@2.1.0: {}
picocolors@1.1.1: {}
@@ -12081,11 +10902,6 @@ snapshots:
dependencies:
parse-ms: 4.0.0
- prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8):
- optionalDependencies:
- '@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
@@ -12171,6 +10987,11 @@ snapshots:
dependencies:
punycode: 2.3.1
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode.js@2.3.1: {}
punycode@2.3.1: {}
@@ -12279,7 +11100,7 @@ snapshots:
dependencies:
glob: 10.5.0
- 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):
+ rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260225.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
@@ -12292,7 +11113,7 @@ snapshots:
obug: 2.1.1
rolldown: 1.0.0-rc.3
optionalDependencies:
- '@typescript/native-preview': 7.0.0-dev.20260224.1
+ '@typescript/native-preview': 7.0.0-dev.20260225.1
typescript: 5.9.3
transitivePeerDependencies:
- oxc-resolver
@@ -12520,7 +11341,7 @@ snapshots:
dependencies:
signal-polyfill: 0.2.2
- simple-git@3.31.1:
+ simple-git@3.32.2:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
@@ -12649,7 +11470,7 @@ snapshots:
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
- get-east-asian-width: 1.4.0
+ get-east-asian-width: 1.5.0
strip-ansi: 7.1.2
string_decoder@1.1.1:
@@ -12743,7 +11564,7 @@ snapshots:
ts-algebra@2.0.0: {}
- tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3):
+ tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3):
dependencies:
ansis: 4.2.0
cac: 6.7.14
@@ -12754,7 +11575,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.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)
+ rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)
semver: 7.7.4
tinyexec: 1.0.2
tinyglobby: 0.2.15
@@ -13001,6 +11822,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
yoctocolors@2.1.2: {}
zod-to-json-schema@3.25.1(zod@3.25.76):
diff --git a/scripts/pr b/scripts/pr
index 90cfe029db0..36ab74972c4 100755
--- a/scripts/pr
+++ b/scripts/pr
@@ -664,6 +664,61 @@ validate_changelog_entry_for_pr() {
echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)"
}
+changed_changelog_fragment_files() {
+ git diff --name-only origin/main...HEAD -- changelog/fragments | rg '^changelog/fragments/.*\.md$' || true
+}
+
+validate_changelog_fragments_for_pr() {
+ local pr="$1"
+ local contrib="$2"
+ shift 2
+
+ if [ "$#" -lt 1 ]; then
+ echo "No changelog fragments provided for validation."
+ exit 1
+ fi
+
+ local pr_pattern
+ pr_pattern="(#$pr|openclaw#$pr)"
+
+ local added_lines
+ local file
+ local all_added_lines=""
+ for file in "$@"; do
+ added_lines=$(git diff --unified=0 origin/main...HEAD -- "$file" | awk '
+ /^\+\+\+/ { next }
+ /^\+/ { print substr($0, 2) }
+ ')
+
+ if [ -z "$added_lines" ]; then
+ echo "$file is in diff but no added lines were detected."
+ exit 1
+ fi
+
+ all_added_lines=$(printf '%s\n%s\n' "$all_added_lines" "$added_lines")
+ done
+
+ local with_pr
+ with_pr=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" || true)
+ if [ -z "$with_pr" ]; then
+ echo "Changelog fragment update must reference PR #$pr (for example, (#$pr))."
+ exit 1
+ fi
+
+ if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
+ local with_pr_and_thanks
+ with_pr_and_thanks=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true)
+ if [ -z "$with_pr_and_thanks" ]; then
+ echo "Changelog fragment update must include both PR #$pr and thanks @$contrib on the entry line."
+ exit 1
+ fi
+ echo "changelog fragments validated: found PR #$pr + thanks @$contrib"
+ return 0
+ fi
+
+ echo "changelog fragments validated: found PR #$pr (contributor handle unavailable, skipping thanks check)"
+}
+
prepare_gates() {
local pr="$1"
enter_worktree "$pr" false
@@ -684,13 +739,30 @@ prepare_gates() {
docs_only=true
fi
- # Enforce workflow policy: every prepared PR must include a changelog update.
- if ! printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then
- echo "Missing CHANGELOG.md update in PR diff. This workflow requires a changelog entry."
+ local has_changelog_update=false
+ if printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then
+ has_changelog_update=true
+ fi
+ local fragment_files
+ fragment_files=$(changed_changelog_fragment_files)
+ local has_fragment_update=false
+ if [ -n "$fragment_files" ]; then
+ has_fragment_update=true
+ fi
+ # Enforce workflow policy: every prepared PR must include either CHANGELOG.md
+ # or one or more changelog fragments.
+ if [ "$has_changelog_update" = "false" ] && [ "$has_fragment_update" = "false" ]; then
+ echo "Missing changelog update. Add CHANGELOG.md changes or changelog/fragments/*.md entry."
exit 1
fi
local contrib="${PR_AUTHOR:-}"
- validate_changelog_entry_for_pr "$pr" "$contrib"
+ if [ "$has_changelog_update" = "true" ]; then
+ validate_changelog_entry_for_pr "$pr" "$contrib"
+ fi
+ if [ "$has_fragment_update" = "true" ]; then
+ mapfile -t fragment_file_list <<<"$fragment_files"
+ validate_changelog_fragments_for_pr "$pr" "$contrib" "${fragment_file_list[@]}"
+ fi
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts
index 5a2dae87e75..79d0aa0c07b 100644
--- a/src/agents/apply-patch.test.ts
+++ b/src/agents/apply-patch.test.ts
@@ -159,6 +159,42 @@ describe("applyPatch", () => {
});
});
+ it("rejects hardlink alias escapes by default", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ await withTempDir(async (dir) => {
+ const outside = path.join(
+ path.dirname(dir),
+ `outside-hardlink-${process.pid}-${Date.now()}.txt`,
+ );
+ const linkPath = path.join(dir, "hardlink.txt");
+ await fs.writeFile(outside, "initial\n", "utf8");
+ try {
+ try {
+ await fs.link(outside, linkPath);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
+ return;
+ }
+ throw err;
+ }
+ const patch = `*** Begin Patch
+*** Update File: hardlink.txt
+@@
+-initial
++pwned
+*** End Patch`;
+ await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/hardlink|sandbox/i);
+ const outsideContents = await fs.readFile(outside, "utf8");
+ expect(outsideContents).toBe("initial\n");
+ } finally {
+ await fs.rm(linkPath, { force: true });
+ await fs.rm(outside, { force: true });
+ }
+ });
+ });
+
it("allows symlinks that resolve within cwd by default", async () => {
await withTempDir(async (dir) => {
const target = path.join(dir, "target.txt");
diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts
index fecf4cf03bc..4f1487d34ea 100644
--- a/src/agents/apply-patch.ts
+++ b/src/agents/apply-patch.ts
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
+import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import { applyUpdateHunk } from "./apply-patch-update.js";
import { assertSandboxPath, resolveSandboxInputPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
@@ -154,7 +155,7 @@ export async function applyPatch(
}
if (hunk.kind === "delete") {
- const target = await resolvePatchPath(hunk.path, options, "unlink");
+ const target = await resolvePatchPath(hunk.path, options, PATH_ALIAS_POLICIES.unlinkTarget);
await fileOps.remove(target.resolved);
recordSummary(summary, seen, "deleted", target.display);
continue;
@@ -253,7 +254,7 @@ async function ensureDir(filePath: string, ops: PatchFileOps) {
async function resolvePatchPath(
filePath: string,
options: ApplyPatchOptions,
- purpose: "readWrite" | "unlink" = "readWrite",
+ aliasPolicy: PathAliasPolicy = PATH_ALIAS_POLICIES.strict,
): Promise<{ resolved: string; display: string }> {
if (options.sandbox) {
const resolved = options.sandbox.bridge.resolvePath({
@@ -265,7 +266,8 @@ async function resolvePatchPath(
filePath: resolved.hostPath,
cwd: options.cwd,
root: options.cwd,
- allowFinalSymlink: purpose === "unlink",
+ allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
+ allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
});
}
return {
@@ -281,7 +283,8 @@ async function resolvePatchPath(
filePath,
cwd: options.cwd,
root: options.cwd,
- allowFinalSymlink: purpose === "unlink",
+ allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink,
+ allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
})
).resolved
: resolvePathFromCwd(filePath, options.cwd);
diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts
index 1a30d8a9119..865fbf87816 100644
--- a/src/agents/auth-profiles.markauthprofilefailure.test.ts
+++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts
@@ -114,6 +114,22 @@ describe("markAuthProfileFailure", () => {
expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil);
});
});
+ it("disables auth_permanent failures via disabledUntil (like billing)", async () => {
+ await withAuthProfileStore(async ({ agentDir, store }) => {
+ await markAuthProfileFailure({
+ store,
+ profileId: "anthropic:default",
+ reason: "auth_permanent",
+ agentDir,
+ });
+
+ const stats = store.usageStats?.["anthropic:default"];
+ expect(typeof stats?.disabledUntil).toBe("number");
+ expect(stats?.disabledReason).toBe("auth_permanent");
+ // Should NOT set cooldownUntil (that's for transient errors)
+ expect(stats?.cooldownUntil).toBeUndefined();
+ });
+ });
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts
index 7332d304812..c23e6aa404d 100644
--- a/src/agents/auth-profiles/types.ts
+++ b/src/agents/auth-profiles/types.ts
@@ -34,6 +34,7 @@ export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCr
export type AuthProfileFailureReason =
| "auth"
+ | "auth_permanent"
| "format"
| "rate_limit"
| "billing"
diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts
index 0025007f729..8c499654b49 100644
--- a/src/agents/auth-profiles/usage.test.ts
+++ b/src/agents/auth-profiles/usage.test.ts
@@ -141,6 +141,24 @@ describe("resolveProfilesUnavailableReason", () => {
).toBe("billing");
});
+ it("returns auth_permanent for active permanent auth disables", () => {
+ const now = Date.now();
+ const store = makeStore({
+ "anthropic:default": {
+ disabledUntil: now + 60_000,
+ disabledReason: "auth_permanent",
+ },
+ });
+
+ expect(
+ resolveProfilesUnavailableReason({
+ store,
+ profileIds: ["anthropic:default"],
+ now,
+ }),
+ ).toBe("auth_permanent");
+ });
+
it("uses recorded non-rate-limit failure counts for active cooldown windows", () => {
const now = Date.now();
const store = makeStore({
@@ -490,7 +508,7 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
async function markFailureAt(params: {
store: ReturnType;
now: number;
- reason: "rate_limit" | "billing";
+ reason: "rate_limit" | "billing" | "auth_permanent";
}): Promise {
vi.useFakeTimers();
vi.setSystemTime(params.now);
@@ -528,6 +546,18 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
}),
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
+ {
+ label: "disabledUntil(auth_permanent)",
+ reason: "auth_permanent" as const,
+ buildUsageStats: (now: number): WindowStats => ({
+ disabledUntil: now + 20 * 60 * 60 * 1000,
+ disabledReason: "auth_permanent",
+ errorCount: 5,
+ failureCounts: { auth_permanent: 5 },
+ lastFailureAt: now - 60_000,
+ }),
+ readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
+ },
];
for (const testCase of activeWindowCases) {
@@ -573,6 +603,19 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
+ {
+ label: "disabledUntil(auth_permanent)",
+ reason: "auth_permanent" as const,
+ buildUsageStats: (now: number): WindowStats => ({
+ disabledUntil: now - 60_000,
+ disabledReason: "auth_permanent",
+ errorCount: 5,
+ failureCounts: { auth_permanent: 2 },
+ lastFailureAt: now - 60_000,
+ }),
+ expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
+ readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
+ },
];
for (const testCase of expiredWindowCases) {
diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts
index 958e3ae127e..60c43c9c3c8 100644
--- a/src/agents/auth-profiles/usage.ts
+++ b/src/agents/auth-profiles/usage.ts
@@ -4,6 +4,7 @@ import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [
+ "auth_permanent",
"auth",
"billing",
"format",
@@ -394,8 +395,8 @@ function computeNextProfileUsageStats(params: {
lastFailureAt: params.now,
};
- if (params.reason === "billing") {
- const billingCount = failureCounts.billing ?? 1;
+ if (params.reason === "billing" || params.reason === "auth_permanent") {
+ const billingCount = failureCounts[params.reason] ?? 1;
const backoffMs = calculateAuthProfileBillingDisableMsWithConfig({
errorCount: billingCount,
baseMs: params.cfgResolved.billingBackoffMs,
@@ -408,7 +409,7 @@ function computeNextProfileUsageStats(params: {
now: params.now,
recomputedUntil: params.now + backoffMs,
});
- updatedStats.disabledReason = "billing";
+ updatedStats.disabledReason = params.reason;
} else {
const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount);
// Keep active cooldown windows immutable so retries within the window
@@ -424,8 +425,9 @@ function computeNextProfileUsageStats(params: {
}
/**
- * Mark a profile as failed for a specific reason. Billing failures are treated
- * as "disabled" (longer backoff) vs the regular cooldown window.
+ * Mark a profile as failed for a specific reason. Billing and permanent-auth
+ * failures are treated as "disabled" (longer backoff) vs the regular cooldown
+ * window.
*/
export async function markAuthProfileFailure(params: {
store: AuthProfileStore;
diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts
index 83323845c0c..cda30757e26 100644
--- a/src/agents/bash-tools.exec-approval-request.ts
+++ b/src/agents/bash-tools.exec-approval-request.ts
@@ -8,6 +8,7 @@ import { callGatewayTool } from "./tools/gateway.js";
export type RequestExecApprovalDecisionParams = {
id: string;
command: string;
+ commandArgv?: string[];
cwd: string;
nodeId?: string;
host: "gateway" | "node";
@@ -62,6 +63,7 @@ export async function registerExecApprovalRequest(
{
id: params.id,
command: params.command,
+ commandArgv: params.commandArgv,
cwd: params.cwd,
nodeId: params.nodeId,
host: params.host,
@@ -116,6 +118,7 @@ export async function requestExecApprovalDecision(
export async function requestExecApprovalDecisionForHost(params: {
approvalId: string;
command: string;
+ commandArgv?: string[];
workdir: string;
host: "gateway" | "node";
nodeId?: string;
@@ -128,6 +131,7 @@ export async function requestExecApprovalDecisionForHost(params: {
return await requestExecApprovalDecision({
id: params.approvalId,
command: params.command,
+ commandArgv: params.commandArgv,
cwd: params.workdir,
nodeId: params.nodeId,
host: params.host,
@@ -142,6 +146,7 @@ export async function requestExecApprovalDecisionForHost(params: {
export async function registerExecApprovalRequestForHost(params: {
approvalId: string;
command: string;
+ commandArgv?: string[];
workdir: string;
host: "gateway" | "node";
nodeId?: string;
@@ -154,6 +159,7 @@ export async function registerExecApprovalRequestForHost(params: {
return await registerExecApprovalRequest({
id: params.approvalId,
command: params.command,
+ commandArgv: params.commandArgv,
cwd: params.workdir,
nodeId: params.nodeId,
host: params.host,
diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts
index 5a45c869292..47f2931b980 100644
--- a/src/agents/bash-tools.exec-host-node.ts
+++ b/src/agents/bash-tools.exec-host-node.ts
@@ -194,6 +194,7 @@ export async function executeNodeHostCommand(
const registration = await registerExecApprovalRequestForHost({
approvalId,
command: params.command,
+ commandArgv: argv,
workdir: params.workdir,
host: "node",
nodeId,
diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts
index d7c1edccbe1..8b2cb846298 100644
--- a/src/agents/failover-error.test.ts
+++ b/src/agents/failover-error.test.ts
@@ -4,6 +4,7 @@ import {
describeFailoverError,
isTimeoutError,
resolveFailoverReasonFromError,
+ resolveFailoverStatus,
} from "./failover-error.js";
describe("failover-error", () => {
@@ -69,6 +70,36 @@ describe("failover-error", () => {
expect(err?.status).toBe(400);
});
+ it("401/403 with generic message still returns auth (backward compat)", () => {
+ expect(resolveFailoverReasonFromError({ status: 401, message: "Unauthorized" })).toBe("auth");
+ expect(resolveFailoverReasonFromError({ status: 403, message: "Forbidden" })).toBe("auth");
+ });
+
+ it("401 with permanent auth message returns auth_permanent", () => {
+ expect(resolveFailoverReasonFromError({ status: 401, message: "invalid_api_key" })).toBe(
+ "auth_permanent",
+ );
+ });
+
+ it("403 with revoked key message returns auth_permanent", () => {
+ expect(resolveFailoverReasonFromError({ status: 403, message: "api key revoked" })).toBe(
+ "auth_permanent",
+ );
+ });
+
+ it("resolveFailoverStatus maps auth_permanent to 403", () => {
+ expect(resolveFailoverStatus("auth_permanent")).toBe(403);
+ });
+
+ it("coerces permanent auth error with correct reason", () => {
+ const err = coerceToFailoverError(
+ { status: 401, message: "invalid_api_key" },
+ { provider: "anthropic", model: "claude-opus-4-6" },
+ );
+ expect(err?.reason).toBe("auth_permanent");
+ expect(err?.provider).toBe("anthropic");
+ });
+
it("describes non-Error values consistently", () => {
const described = describeFailoverError(123);
expect(described.message).toBe("123");
diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts
index 4de2babde4d..708af55e322 100644
--- a/src/agents/failover-error.ts
+++ b/src/agents/failover-error.ts
@@ -1,4 +1,8 @@
-import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
+import {
+ classifyFailoverReason,
+ isAuthPermanentErrorMessage,
+ type FailoverReason,
+} from "./pi-embedded-helpers.js";
const TIMEOUT_HINT_RE =
/timeout|timed out|deadline exceeded|context deadline exceeded|stop reason:\s*abort|reason:\s*abort|unhandled stop reason:\s*abort/i;
@@ -47,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine
return 429;
case "auth":
return 401;
+ case "auth_permanent":
+ return 403;
case "timeout":
return 408;
case "format":
@@ -158,6 +164,10 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n
return "rate_limit";
}
if (status === 401 || status === 403) {
+ const msg = getErrorMessage(err);
+ if (msg && isAuthPermanentErrorMessage(msg)) {
+ return "auth_permanent";
+ }
return "auth";
}
if (status === 408) {
diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts
index 0c222ec2115..3e36366c4ad 100644
--- a/src/agents/model-fallback.probe.test.ts
+++ b/src/agents/model-fallback.probe.test.ts
@@ -163,7 +163,7 @@ describe("runWithModelFallback – probe logic", () => {
expectPrimaryProbeSuccess(result, run, "recovered");
});
- it("does NOT probe non-primary candidates during cooldown", async () => {
+ it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => {
const cfg = makeCfg({
agents: {
defaults: {
@@ -182,25 +182,23 @@ describe("runWithModelFallback – probe logic", () => {
const almostExpired = NOW + 30 * 1000; // 30s remaining
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
- // Primary probe fails with 429
+ // Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns.
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
- .mockResolvedValue("should-not-reach");
+ .mockResolvedValue("fallback-ok");
- try {
- await runWithModelFallback({
- cfg,
- provider: "openai",
- model: "gpt-4.1-mini",
- run,
- });
- expect.unreachable("should have thrown since all candidates exhausted");
- } catch {
- // Primary was probed (i === 0 + within margin), non-primary were skipped
- expect(run).toHaveBeenCalledTimes(1); // only primary was actually called
- expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini");
- }
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "openai",
+ model: "gpt-4.1-mini",
+ run,
+ });
+
+ expect(result.result).toBe("fallback-ok");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini");
+ expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5");
});
it("throttles probe when called within 30s interval", async () => {
diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts
index 16592cdb456..cd0217faafc 100644
--- a/src/agents/model-fallback.test.ts
+++ b/src/agents/model-fallback.test.ts
@@ -143,10 +143,22 @@ async function expectSkippedUnavailableProvider(params: {
}) {
const provider = `${params.providerPrefix}-${crypto.randomUUID()}`;
const cfg = makeProviderFallbackCfg(provider);
- const store = makeSingleProviderStore({
+ const primaryStore = makeSingleProviderStore({
provider,
usageStat: params.usageStat,
});
+ // Include fallback provider profile so the fallback is attempted (not skipped as no-profile).
+ const store: AuthProfileStore = {
+ ...primaryStore,
+ profiles: {
+ ...primaryStore.profiles,
+ "fallback:default": {
+ type: "api_key",
+ provider: "fallback",
+ key: "test-key",
+ },
+ },
+ };
const run = createFallbackOnlyRun();
const result = await runWithStoredAuth({
@@ -436,11 +448,11 @@ describe("runWithModelFallback", () => {
run,
});
- // Override model failed with model_not_found → falls back to configured primary.
+ // Override model failed with model_not_found → tries fallbacks first (same provider).
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
- expect(run.mock.calls[1]?.[0]).toBe("openai");
- expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini");
+ expect(run.mock.calls[1]?.[0]).toBe("anthropic");
+ expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("skips providers when all profiles are in cooldown", async () => {
@@ -794,6 +806,296 @@ describe("runWithModelFallback", () => {
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1-mini");
});
+
+ // Tests for Bug A fix: Model fallback with session overrides
+ describe("fallback behavior with session model overrides", () => {
+ it("allows fallbacks when session model differs from config within same provider", async () => {
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["anthropic/claude-sonnet-4-5", "google/gemini-2.5-flash"],
+ },
+ },
+ },
+ });
+
+ const run = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Rate limit exceeded")) // Session model fails
+ .mockResolvedValueOnce("fallback success"); // First fallback succeeds
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-sonnet-4-20250514", // Different from config primary
+ run,
+ });
+
+ expect(result.result).toBe("fallback success");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-20250514");
+ expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-sonnet-4-5"); // Fallback tried
+ });
+
+ it("allows fallbacks with model version differences within same provider", async () => {
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Weekly quota exceeded"))
+ .mockResolvedValueOnce("groq success");
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-5", // Version difference from config
+ run,
+ });
+
+ expect(result.result).toBe("groq success");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile");
+ });
+
+ it("still skips fallbacks when using different provider than config", async () => {
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: [], // Empty fallbacks to match working pattern
+ },
+ },
+ },
+ });
+
+ const run = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('No credentials found for profile "openai:default".'))
+ .mockResolvedValueOnce("config primary worked");
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "openai", // Different provider
+ model: "gpt-4.1-mini",
+ run,
+ });
+
+ // Cross-provider requests should skip configured fallbacks but still try configured primary
+ expect(result.result).toBe("config primary worked");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini"); // Original request
+ expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-opus-4-6"); // Config primary as final fallback
+ });
+
+ it("uses fallbacks when session model exactly matches config primary", async () => {
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Quota exceeded"))
+ .mockResolvedValueOnce("fallback worked");
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6", // Exact match
+ run,
+ });
+
+ expect(result.result).toBe("fallback worked");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile");
+ });
+ });
+
+ // Tests for Bug B fix: Rate limit vs auth/billing cooldown distinction
+ describe("fallback behavior with provider cooldowns", () => {
+ async function makeAuthStoreWithCooldown(
+ provider: string,
+ reason: "rate_limit" | "auth" | "billing",
+ ): Promise<{ store: AuthProfileStore; dir: string }> {
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
+ const now = Date.now();
+ const store: AuthProfileStore = {
+ version: AUTH_STORE_VERSION,
+ profiles: {
+ [`${provider}:default`]: { type: "api_key", provider, key: "test-key" },
+ },
+ usageStats: {
+ [`${provider}:default`]:
+ reason === "rate_limit"
+ ? {
+ // Real rate-limit cooldowns are tracked through cooldownUntil
+ // and failureCounts, not disabledReason.
+ cooldownUntil: now + 300000,
+ failureCounts: { rate_limit: 1 },
+ }
+ : {
+ // Auth/billing issues use disabledUntil
+ disabledUntil: now + 300000,
+ disabledReason: reason,
+ },
+ },
+ };
+ saveAuthProfileStore(store, tmpDir);
+ return { store, dir: tmpDir };
+ }
+
+ it("attempts same-provider fallbacks during rate limit cooldown", async () => {
+ const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit");
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi.fn().mockResolvedValueOnce("sonnet success"); // Fallback succeeds
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ run,
+ agentDir: dir,
+ });
+
+ expect(result.result).toBe("sonnet success");
+ expect(run).toHaveBeenCalledTimes(1); // Primary skipped, fallback attempted
+ expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5");
+ });
+
+ it("skips same-provider models on auth cooldown but still tries no-profile fallback providers", async () => {
+ const { dir } = await makeAuthStoreWithCooldown("anthropic", "auth");
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi.fn().mockResolvedValueOnce("groq success");
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ run,
+ agentDir: dir,
+ });
+
+ expect(result.result).toBe("groq success");
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile");
+ });
+
+ it("skips same-provider models on billing cooldown but still tries no-profile fallback providers", async () => {
+ const { dir } = await makeAuthStoreWithCooldown("anthropic", "billing");
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi.fn().mockResolvedValueOnce("groq success");
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ run,
+ agentDir: dir,
+ });
+
+ expect(result.result).toBe("groq success");
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile");
+ });
+
+ it("tries cross-provider fallbacks when same provider has rate limit", async () => {
+ // Anthropic in rate limit cooldown, Groq available
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
+ const store: AuthProfileStore = {
+ version: AUTH_STORE_VERSION,
+ profiles: {
+ "anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" },
+ "groq:default": { type: "api_key", provider: "groq", key: "test-key" },
+ },
+ usageStats: {
+ "anthropic:default": {
+ // Rate-limit reason is inferred from failureCounts for cooldown windows.
+ cooldownUntil: Date.now() + 300000,
+ failureCounts: { rate_limit: 2 },
+ },
+ // Groq not in cooldown
+ },
+ };
+ saveAuthProfileStore(store, tmpDir);
+
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"],
+ },
+ },
+ },
+ });
+
+ const run = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Still rate limited")) // Sonnet still fails
+ .mockResolvedValueOnce("groq success"); // Groq works
+
+ const result = await runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ run,
+ agentDir: tmpDir,
+ });
+
+ expect(result.result).toBe("groq success");
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5"); // Rate limit allows attempt
+ expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); // Cross-provider works
+ });
+ });
});
describe("runWithImageModelFallback", () => {
diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts
index e59d9e9357c..da03d88d847 100644
--- a/src/agents/model-fallback.ts
+++ b/src/agents/model-fallback.ts
@@ -224,21 +224,21 @@ function resolveFallbackCandidates(params: {
const configuredFallbacks = resolveAgentModelFallbackValues(
params.cfg?.agents?.defaults?.model,
);
- if (sameModelCandidate(normalizedPrimary, configuredPrimary)) {
- return configuredFallbacks;
- }
- // Preserve resilience after failover: when current model is one of the
- // configured fallback refs, keep traversing the configured fallback chain.
- const isConfiguredFallback = configuredFallbacks.some((raw) => {
- const resolved = resolveModelRefFromString({
- raw: String(raw ?? ""),
- defaultProvider,
- aliasIndex,
+ // When user runs a different provider than config, only use configured fallbacks
+ // if the current model is already in that chain (e.g. session on first fallback).
+ if (normalizedPrimary.provider !== configuredPrimary.provider) {
+ const isConfiguredFallback = configuredFallbacks.some((raw) => {
+ const resolved = resolveModelRefFromString({
+ raw: String(raw ?? ""),
+ defaultProvider,
+ aliasIndex,
+ });
+ return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false;
});
- return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false;
- });
- // Keep legacy override behavior for ad-hoc models outside configured chain.
- return isConfiguredFallback ? configuredFallbacks : [];
+ return isConfiguredFallback ? configuredFallbacks : [];
+ }
+ // Same provider: always use full fallback chain (model version differences within provider).
+ return configuredFallbacks;
})();
for (const raw of modelFallbacks) {
@@ -306,6 +306,76 @@ export const _probeThrottleInternals = {
resolveProbeThrottleKey,
} as const;
+type CooldownDecision =
+ | {
+ type: "skip";
+ reason: FailoverReason;
+ error: string;
+ }
+ | {
+ type: "attempt";
+ reason: FailoverReason;
+ markProbe: boolean;
+ };
+
+function resolveCooldownDecision(params: {
+ candidate: ModelCandidate;
+ isPrimary: boolean;
+ requestedModel: boolean;
+ hasFallbackCandidates: boolean;
+ now: number;
+ probeThrottleKey: string;
+ authStore: ReturnType;
+ profileIds: string[];
+}): CooldownDecision {
+ const shouldProbe = shouldProbePrimaryDuringCooldown({
+ isPrimary: params.isPrimary,
+ hasFallbackCandidates: params.hasFallbackCandidates,
+ now: params.now,
+ throttleKey: params.probeThrottleKey,
+ authStore: params.authStore,
+ profileIds: params.profileIds,
+ });
+
+ const inferredReason =
+ resolveProfilesUnavailableReason({
+ store: params.authStore,
+ profileIds: params.profileIds,
+ now: params.now,
+ }) ?? "rate_limit";
+ const isPersistentIssue =
+ inferredReason === "auth" ||
+ inferredReason === "auth_permanent" ||
+ inferredReason === "billing";
+ if (isPersistentIssue) {
+ return {
+ type: "skip",
+ reason: inferredReason,
+ error: `Provider ${params.candidate.provider} has ${inferredReason} issue (skipping all models)`,
+ };
+ }
+
+ // For primary: try when requested model or when probe allows.
+ // For same-provider fallbacks: only relax cooldown on rate_limit, which
+ // is commonly model-scoped and can recover on a sibling model.
+ const shouldAttemptDespiteCooldown =
+ (params.isPrimary && (!params.requestedModel || shouldProbe)) ||
+ (!params.isPrimary && inferredReason === "rate_limit");
+ if (!shouldAttemptDespiteCooldown) {
+ return {
+ type: "skip",
+ reason: inferredReason,
+ error: `Provider ${params.candidate.provider} is in cooldown (all profiles unavailable)`,
+ };
+ }
+
+ return {
+ type: "attempt",
+ reason: inferredReason,
+ markProbe: params.isPrimary && shouldProbe,
+ };
+}
+
export async function runWithModelFallback(params: {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -342,41 +412,38 @@ export async function runWithModelFallback(params: {
if (profileIds.length > 0 && !isAnyProfileAvailable) {
// All profiles for this provider are in cooldown.
- // For the primary model (i === 0), probe it if the soonest cooldown
- // expiry is close or already past. This avoids staying on a fallback
- // model long after the real rate-limit window clears.
+ const isPrimary = i === 0;
+ const requestedModel =
+ params.provider === candidate.provider && params.model === candidate.model;
const now = Date.now();
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
- const shouldProbe = shouldProbePrimaryDuringCooldown({
- isPrimary: i === 0,
+ const decision = resolveCooldownDecision({
+ candidate,
+ isPrimary,
+ requestedModel,
hasFallbackCandidates,
now,
- throttleKey: probeThrottleKey,
+ probeThrottleKey,
authStore,
profileIds,
});
- if (!shouldProbe) {
- const inferredReason =
- resolveProfilesUnavailableReason({
- store: authStore,
- profileIds,
- now,
- }) ?? "rate_limit";
- // Skip without attempting
+
+ if (decision.type === "skip") {
attempts.push({
provider: candidate.provider,
model: candidate.model,
- error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
- reason: inferredReason,
+ error: decision.error,
+ reason: decision.reason,
});
continue;
}
- // Primary model probe: attempt it despite cooldown to detect recovery.
- // If it fails, the error is caught below and we fall through to the
- // next candidate as usual.
- lastProbeAttempt.set(probeThrottleKey, now);
+
+ if (decision.markProbe) {
+ lastProbeAttempt.set(probeThrottleKey, now);
+ }
}
}
+
try {
const result = await params.run(candidate.provider, candidate.model);
return {
diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
index 638b6c24bb8..a109af6d89f 100644
--- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
+++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
classifyFailoverReason,
isAuthErrorMessage,
+ isAuthPermanentErrorMessage,
isBillingErrorMessage,
isCloudCodeAssistFormatError,
isCloudflareOrHtmlErrorPage,
@@ -16,6 +17,39 @@ import {
parseImageSizeError,
} from "./pi-embedded-helpers.js";
+describe("isAuthPermanentErrorMessage", () => {
+ it("matches permanent auth failure patterns", () => {
+ const samples = [
+ "invalid_api_key",
+ "api key revoked",
+ "api key deactivated",
+ "key has been disabled",
+ "key has been revoked",
+ "account has been deactivated",
+ "could not authenticate api key",
+ "could not validate credentials",
+ "API_KEY_REVOKED",
+ "api_key_deleted",
+ ];
+ for (const sample of samples) {
+ expect(isAuthPermanentErrorMessage(sample)).toBe(true);
+ }
+ });
+ it("does not match transient auth errors", () => {
+ const samples = [
+ "unauthorized",
+ "invalid token",
+ "authentication failed",
+ "forbidden",
+ "access denied",
+ "token has expired",
+ ];
+ for (const sample of samples) {
+ expect(isAuthPermanentErrorMessage(sample)).toBe(false);
+ }
+ });
+});
+
describe("isAuthErrorMessage", () => {
it("matches credential validation errors", () => {
const samples = [
@@ -480,6 +514,12 @@ describe("classifyFailoverReason", () => {
),
).toBe("rate_limit");
});
+ it("classifies permanent auth errors as auth_permanent", () => {
+ expect(classifyFailoverReason("invalid_api_key")).toBe("auth_permanent");
+ expect(classifyFailoverReason("Your api key has been revoked")).toBe("auth_permanent");
+ expect(classifyFailoverReason("key has been disabled")).toBe("auth_permanent");
+ expect(classifyFailoverReason("account has been deactivated")).toBe("auth_permanent");
+ });
it("classifies JSON api_error internal server failures as timeout", () => {
expect(
classifyFailoverReason(
diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts
index 06bf2b1938b..dd10fdca3d1 100644
--- a/src/agents/pi-embedded-helpers.ts
+++ b/src/agents/pi-embedded-helpers.ts
@@ -16,6 +16,7 @@ export {
getApiErrorPayloadFingerprint,
isAuthAssistantError,
isAuthErrorMessage,
+ isAuthPermanentErrorMessage,
isModelNotFoundErrorMessage,
isBillingAssistantError,
parseApiErrorInfo,
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index 6eea521ede1..246f6c0ad24 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -649,6 +649,14 @@ const ERROR_PATTERNS = {
"plans & billing",
"insufficient balance",
],
+ authPermanent: [
+ /api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
+ "invalid_api_key",
+ "key has been disabled",
+ "key has been revoked",
+ "account has been deactivated",
+ /could not (?:authenticate|validate).*(?:api[_ ]?key|credentials)/i,
+ ],
auth: [
/invalid[_ ]?api[_ ]?key/,
"incorrect api key",
@@ -755,6 +763,10 @@ export function isBillingAssistantError(msg: AssistantMessage | undefined): bool
return isBillingErrorMessage(msg.errorMessage ?? "");
}
+export function isAuthPermanentErrorMessage(raw: string): boolean {
+ return matchesErrorPatterns(raw, ERROR_PATTERNS.authPermanent);
+}
+
export function isAuthErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.auth);
}
@@ -899,6 +911,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isTimeoutErrorMessage(raw)) {
return "timeout";
}
+ if (isAuthPermanentErrorMessage(raw)) {
+ return "auth_permanent";
+ }
if (isAuthErrorMessage(raw)) {
return "auth";
}
diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/pi-embedded-helpers/types.ts
index 2753e979eb2..2440473d9f6 100644
--- a/src/agents/pi-embedded-helpers/types.ts
+++ b/src/agents/pi-embedded-helpers/types.ts
@@ -2,6 +2,7 @@ export type EmbeddedContextFile = { path: string; content: string };
export type FailoverReason =
| "auth"
+ | "auth_permanent"
| "format"
| "rate_limit"
| "billing"
diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts
index 1b11bbf49be..6def07167cb 100644
--- a/src/agents/pi-tool-definition-adapter.test.ts
+++ b/src/agents/pi-tool-definition-adapter.test.ts
@@ -25,6 +25,15 @@ async function executeThrowingTool(name: string, callId: string) {
return await def.execute(callId, {}, undefined, undefined, extensionContext);
}
+async function executeTool(tool: AgentTool, callId: string) {
+ const defs = toToolDefinitions([tool]);
+ const def = defs[0];
+ if (!def) {
+ throw new Error("missing tool definition");
+ }
+ return await def.execute(callId, {}, undefined, undefined, extensionContext);
+}
+
describe("pi tool definition adapter", () => {
it("wraps tool errors into a tool result", async () => {
const result = await executeThrowingTool("boom", "call1");
@@ -46,4 +55,46 @@ describe("pi tool definition adapter", () => {
error: "nope",
});
});
+
+ it("coerces details-only tool results to include content", async () => {
+ const tool = {
+ name: "memory_query",
+ label: "Memory Query",
+ description: "returns details only",
+ parameters: Type.Object({}),
+ execute: (async () => ({
+ details: {
+ hits: [{ id: "a1", score: 0.9 }],
+ },
+ })) as unknown as AgentTool["execute"],
+ } satisfies AgentTool;
+
+ const result = await executeTool(tool, "call3");
+ expect(result.details).toEqual({
+ hits: [{ id: "a1", score: 0.9 }],
+ });
+ expect(result.content[0]).toMatchObject({ type: "text" });
+ expect((result.content[0] as { text?: string }).text).toContain('"hits"');
+ });
+
+ it("coerces non-standard object results to include content", async () => {
+ const tool = {
+ name: "memory_query_raw",
+ label: "Memory Query Raw",
+ description: "returns plain object",
+ parameters: Type.Object({}),
+ execute: (async () => ({
+ count: 2,
+ ids: ["m1", "m2"],
+ })) as unknown as AgentTool["execute"],
+ } satisfies AgentTool;
+
+ const result = await executeTool(tool, "call4");
+ expect(result.details).toEqual({
+ count: 2,
+ ids: ["m1", "m2"],
+ });
+ expect(result.content[0]).toMatchObject({ type: "text" });
+ expect((result.content[0] as { text?: string }).text).toContain('"count"');
+ });
});
diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts
index f3963600c80..a6221586242 100644
--- a/src/agents/pi-tool-definition-adapter.ts
+++ b/src/agents/pi-tool-definition-adapter.ts
@@ -62,6 +62,56 @@ function describeToolExecutionError(err: unknown): {
return { message: String(err) };
}
+function stringifyToolPayload(payload: unknown): string {
+ if (typeof payload === "string") {
+ return payload;
+ }
+ try {
+ const encoded = JSON.stringify(payload, null, 2);
+ if (typeof encoded === "string") {
+ return encoded;
+ }
+ } catch {
+ // Fall through to String(payload) for non-serializable values.
+ }
+ return String(payload);
+}
+
+function normalizeToolExecutionResult(params: {
+ toolName: string;
+ result: unknown;
+}): AgentToolResult {
+ const { toolName, result } = params;
+ if (result && typeof result === "object") {
+ const record = result as Record;
+ if (Array.isArray(record.content)) {
+ return result as AgentToolResult;
+ }
+ logDebug(`tools: ${toolName} returned non-standard result (missing content[]); coercing`);
+ const details = "details" in record ? record.details : record;
+ const safeDetails = details ?? { status: "ok", tool: toolName };
+ return {
+ content: [
+ {
+ type: "text",
+ text: stringifyToolPayload(safeDetails),
+ },
+ ],
+ details: safeDetails,
+ };
+ }
+ const safeDetails = result ?? { status: "ok", tool: toolName };
+ return {
+ content: [
+ {
+ type: "text",
+ text: stringifyToolPayload(safeDetails),
+ },
+ ],
+ details: safeDetails,
+ };
+}
+
function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
toolCallId: string;
params: unknown;
@@ -111,7 +161,11 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
}
executeParams = hookOutcome.params;
}
- const result = await tool.execute(toolCallId, executeParams, signal, onUpdate);
+ const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate);
+ const result = normalizeToolExecutionResult({
+ toolName: normalizedName,
+ result: rawResult,
+ });
const afterParams = beforeHookWrapped
? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams)
: executeParams;
diff --git a/src/agents/pi-tools.message-provider-policy.test.ts b/src/agents/pi-tools.message-provider-policy.test.ts
new file mode 100644
index 00000000000..0bcdd5144f0
--- /dev/null
+++ b/src/agents/pi-tools.message-provider-policy.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { createOpenClawCodingTools } from "./pi-tools.js";
+
+describe("createOpenClawCodingTools message provider policy", () => {
+ it.each(["voice", "VOICE", " Voice "])(
+ "does not expose tts tool for normalized voice provider: %s",
+ (messageProvider) => {
+ const tools = createOpenClawCodingTools({ messageProvider });
+ const names = new Set(tools.map((tool) => tool.name));
+ expect(names.has("tts")).toBe(false);
+ },
+ );
+
+ it("keeps tts tool for non-voice providers", () => {
+ const tools = createOpenClawCodingTools({ messageProvider: "discord" });
+ const names = new Set(tools.map((tool) => tool.name));
+ expect(names.has("tts")).toBe(true);
+ });
+});
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index e2d29d375da..15be5766c89 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -67,6 +67,31 @@ function isOpenAIProvider(provider?: string) {
return normalized === "openai" || normalized === "openai-codex";
}
+const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> = {
+ voice: ["tts"],
+};
+
+function normalizeMessageProvider(messageProvider?: string): string | undefined {
+ const normalized = messageProvider?.trim().toLowerCase();
+ return normalized && normalized.length > 0 ? normalized : undefined;
+}
+
+function applyMessageProviderToolPolicy(
+ tools: AnyAgentTool[],
+ messageProvider?: string,
+): AnyAgentTool[] {
+ const normalizedProvider = normalizeMessageProvider(messageProvider);
+ if (!normalizedProvider) {
+ return tools;
+ }
+ const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider];
+ if (!deniedTools || deniedTools.length === 0) {
+ return tools;
+ }
+ const deniedSet = new Set(deniedTools);
+ return tools.filter((tool) => !deniedSet.has(tool.name));
+}
+
function isApplyPatchAllowedForModel(params: {
modelProvider?: string;
modelId?: string;
@@ -480,9 +505,10 @@ export function createOpenClawCodingTools(options?: {
senderIsOwner: options?.senderIsOwner,
}),
];
+ const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider);
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
- const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
+ const toolsByAuthorization = applyOwnerOnlyToolPolicy(toolsForMessageProvider, senderIsOwner);
const subagentFiltered = applyToolPolicyPipeline({
tools: toolsByAuthorization,
toolMeta: (tool) => getPluginToolMeta(tool),
diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts
index 6fe98ff03f8..4efa494555e 100644
--- a/src/agents/pi-tools.workspace-paths.test.ts
+++ b/src/agents/pi-tools.workspace-paths.test.ts
@@ -151,6 +151,46 @@ describe("workspace path resolution", () => {
).rejects.toThrow(/Path escapes sandbox root/i);
});
});
+
+ it("rejects hardlinked file aliases when workspaceOnly is enabled", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ await withTempDir("openclaw-ws-", async (workspaceDir) => {
+ const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
+ const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
+ const { readTool, writeTool } = expectReadWriteEditTools(tools);
+ const outsidePath = path.join(
+ path.dirname(workspaceDir),
+ `outside-hardlink-${process.pid}-${Date.now()}.txt`,
+ );
+ const hardlinkPath = path.join(workspaceDir, "linked.txt");
+ await fs.writeFile(outsidePath, "top-secret", "utf8");
+ try {
+ try {
+ await fs.link(outsidePath, hardlinkPath);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
+ return;
+ }
+ throw err;
+ }
+ await expect(readTool.execute("ws-read-hardlink", { path: "linked.txt" })).rejects.toThrow(
+ /hardlink|sandbox/i,
+ );
+ await expect(
+ writeTool.execute("ws-write-hardlink", {
+ path: "linked.txt",
+ content: "pwned",
+ }),
+ ).rejects.toThrow(/hardlink|sandbox/i);
+ expect(await fs.readFile(outsidePath, "utf8")).toBe("top-secret");
+ } finally {
+ await fs.rm(hardlinkPath, { force: true });
+ await fs.rm(outsidePath, { force: true });
+ }
+ });
+ });
});
describe("sandboxed workspace paths", () => {
diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts
index 761106e8574..7cb026c28a4 100644
--- a/src/agents/sandbox-paths.ts
+++ b/src/agents/sandbox-paths.ts
@@ -1,8 +1,8 @@
-import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, URL } from "node:url";
-import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
+import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js";
+import { isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -61,11 +61,19 @@ export async function assertSandboxPath(params: {
filePath: string;
cwd: string;
root: string;
- allowFinalSymlink?: boolean;
+ allowFinalSymlinkForUnlink?: boolean;
+ allowFinalHardlinkForUnlink?: boolean;
}) {
const resolved = resolveSandboxPath(params);
- await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), {
- allowFinalSymlink: params.allowFinalSymlink,
+ const policy: PathAliasPolicy = {
+ allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink,
+ allowFinalHardlinkForUnlink: params.allowFinalHardlinkForUnlink,
+ };
+ await assertNoPathAliasEscape({
+ absolutePath: resolved.resolved,
+ rootPath: path.resolve(params.root),
+ boundaryLabel: "sandbox root",
+ policy,
});
return resolved;
}
@@ -194,76 +202,11 @@ async function assertNoTmpAliasEscape(params: {
filePath: string;
tmpRoot: string;
}): Promise {
- await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot);
- await assertNoHardlinkedFinalPath(params.filePath, params.tmpRoot);
-}
-
-async function assertNoHardlinkedFinalPath(filePath: string, tmpRoot: string): Promise {
- let stat: Awaited>;
- try {
- stat = await fs.stat(filePath);
- } catch (err) {
- if (isNotFoundPathError(err)) {
- return;
- }
- throw err;
- }
- if (!stat.isFile()) {
- return;
- }
- if (stat.nlink > 1) {
- throw new Error(
- `Hardlinked tmp media path is not allowed under tmp root (${shortPath(tmpRoot)}): ${shortPath(filePath)}`,
- );
- }
-}
-
-async function assertNoSymlinkEscape(
- relative: string,
- root: string,
- options?: { allowFinalSymlink?: boolean },
-) {
- if (!relative) {
- return;
- }
- const rootReal = await tryRealpath(root);
- const parts = relative.split(path.sep).filter(Boolean);
- let current = root;
- for (let idx = 0; idx < parts.length; idx += 1) {
- const part = parts[idx];
- const isLast = idx === parts.length - 1;
- current = path.join(current, part);
- try {
- const stat = await fs.lstat(current);
- if (stat.isSymbolicLink()) {
- // Unlinking a symlink itself is safe even if it points outside the root. What we
- // must prevent is traversing through a symlink to reach targets outside root.
- if (options?.allowFinalSymlink && isLast) {
- return;
- }
- const target = await tryRealpath(current);
- if (!isPathInside(rootReal, target)) {
- throw new Error(
- `Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`,
- );
- }
- current = target;
- }
- } catch (err) {
- if (isNotFoundPathError(err)) {
- return;
- }
- throw err;
- }
- }
-}
-
-async function tryRealpath(value: string): Promise {
- try {
- return await fs.realpath(value);
- } catch {
- return path.resolve(value);
- }
+ await assertNoPathAliasEscape({
+ absolutePath: params.filePath,
+ rootPath: params.tmpRoot,
+ boundaryLabel: "tmp root",
+ });
}
function shortPath(value: string) {
diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts
index d3bcd735e9e..f5c9aaedd6d 100644
--- a/src/agents/sandbox/fs-bridge.test.ts
+++ b/src/agents/sandbox/fs-bridge.test.ts
@@ -195,6 +195,42 @@ describe("sandbox fs bridge shell compatibility", () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
+ it("rejects pre-existing host hardlink escapes before docker exec", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-hardlink-"));
+ const workspaceDir = path.join(stateDir, "workspace");
+ const outsideDir = path.join(stateDir, "outside");
+ const outsideFile = path.join(outsideDir, "secret.txt");
+ await fs.mkdir(workspaceDir, { recursive: true });
+ await fs.mkdir(outsideDir, { recursive: true });
+ await fs.writeFile(outsideFile, "classified");
+ const hardlinkPath = path.join(workspaceDir, "link.txt");
+ try {
+ try {
+ await fs.link(outsideFile, hardlinkPath);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
+ return;
+ }
+ throw err;
+ }
+
+ const bridge = createSandboxFsBridge({
+ sandbox: createSandbox({
+ workspaceDir,
+ agentWorkspaceDir: workspaceDir,
+ }),
+ });
+
+ await expect(bridge.readFile({ filePath: "link.txt" })).rejects.toThrow(/hardlink|sandbox/i);
+ expect(mockedExecDockerRaw).not.toHaveBeenCalled();
+ } finally {
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ });
+
it("rejects container-canonicalized paths outside allowed mounts", async () => {
mockedExecDockerRaw.mockImplementation(async (args) => {
const script = getDockerScript(args);
diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts
index 226fc39ca1d..23ebcce51b1 100644
--- a/src/agents/sandbox/fs-bridge.ts
+++ b/src/agents/sandbox/fs-bridge.ts
@@ -1,6 +1,8 @@
-import fs from "node:fs/promises";
-import path from "node:path";
-import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js";
+import {
+ assertNoPathAliasEscape,
+ PATH_ALIAS_POLICIES,
+ type PathAliasPolicy,
+} from "../../infra/path-alias-guards.js";
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
import {
buildSandboxFsMounts,
@@ -20,7 +22,7 @@ type RunCommandOptions = {
type PathSafetyOptions = {
action: string;
- allowFinalSymlink?: boolean;
+ aliasPolicy?: PathAliasPolicy;
requireWritable?: boolean;
};
@@ -150,7 +152,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
await this.assertPathSafety(target, {
action: "remove files",
requireWritable: true,
- allowFinalSymlink: true,
+ aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
});
const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter(
Boolean,
@@ -175,7 +177,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
await this.assertPathSafety(from, {
action: "rename files",
requireWritable: true,
- allowFinalSymlink: true,
+ aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
});
await this.assertPathSafety(to, {
action: "rename files",
@@ -252,15 +254,16 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
);
}
- await assertNoHostSymlinkEscape({
+ await assertNoPathAliasEscape({
absolutePath: target.hostPath,
rootPath: lexicalMount.hostRoot,
- allowFinalSymlink: options.allowFinalSymlink === true,
+ boundaryLabel: "sandbox mount root",
+ policy: options.aliasPolicy,
});
const canonicalContainerPath = await this.resolveCanonicalContainerPath({
containerPath: target.containerPath,
- allowFinalSymlink: options.allowFinalSymlink === true,
+ allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true,
});
const canonicalMount = this.resolveMountByContainerPath(canonicalContainerPath);
if (!canonicalMount) {
@@ -287,7 +290,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
private async resolveCanonicalContainerPath(params: {
containerPath: string;
- allowFinalSymlink: boolean;
+ allowFinalSymlinkForUnlink: boolean;
}): Promise {
const script = [
"set -eu",
@@ -308,7 +311,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
'printf "%s%s\\n" "$canonical" "$suffix"',
].join("\n");
const result = await this.runCommand(script, {
- args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"],
+ args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"],
});
const canonical = result.stdout.toString("utf8").trim();
if (!canonical.startsWith("/")) {
@@ -351,53 +354,3 @@ function coerceStatType(typeRaw?: string): "file" | "directory" | "other" {
}
return "other";
}
-
-async function assertNoHostSymlinkEscape(params: {
- absolutePath: string;
- rootPath: string;
- allowFinalSymlink: boolean;
-}): Promise {
- const root = path.resolve(params.rootPath);
- const target = path.resolve(params.absolutePath);
- if (!isPathInside(root, target)) {
- throw new Error(`Sandbox path escapes mount root (${root}): ${params.absolutePath}`);
- }
- const relative = path.relative(root, target);
- if (!relative) {
- return;
- }
- const rootReal = await tryRealpath(root);
- const parts = relative.split(path.sep).filter(Boolean);
- let current = root;
- for (let idx = 0; idx < parts.length; idx += 1) {
- current = path.join(current, parts[idx] ?? "");
- const isLast = idx === parts.length - 1;
- try {
- const stat = await fs.lstat(current);
- if (!stat.isSymbolicLink()) {
- continue;
- }
- if (params.allowFinalSymlink && isLast) {
- return;
- }
- const symlinkTarget = await tryRealpath(current);
- if (!isPathInside(rootReal, symlinkTarget)) {
- throw new Error(`Symlink escapes sandbox mount root (${rootReal}): ${current}`);
- }
- current = symlinkTarget;
- } catch (error) {
- if (isNotFoundPathError(error)) {
- return;
- }
- throw error;
- }
- }
-}
-
-async function tryRealpath(value: string): Promise {
- try {
- return await fs.realpath(value);
- } catch {
- return path.resolve(value);
- }
-}
diff --git a/src/agents/subagent-announce-dispatch.test.ts b/src/agents/subagent-announce-dispatch.test.ts
new file mode 100644
index 00000000000..fcc2f992e2b
--- /dev/null
+++ b/src/agents/subagent-announce-dispatch.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ mapQueueOutcomeToDeliveryResult,
+ runSubagentAnnounceDispatch,
+} from "./subagent-announce-dispatch.js";
+
+describe("mapQueueOutcomeToDeliveryResult", () => {
+ it("maps steered to delivered", () => {
+ expect(mapQueueOutcomeToDeliveryResult("steered")).toEqual({
+ delivered: true,
+ path: "steered",
+ });
+ });
+
+ it("maps queued to delivered", () => {
+ expect(mapQueueOutcomeToDeliveryResult("queued")).toEqual({
+ delivered: true,
+ path: "queued",
+ });
+ });
+
+ it("maps none to not-delivered", () => {
+ expect(mapQueueOutcomeToDeliveryResult("none")).toEqual({
+ delivered: false,
+ path: "none",
+ });
+ });
+});
+
+describe("runSubagentAnnounceDispatch", () => {
+ it("uses queue-first ordering for non-completion mode", async () => {
+ const queue = vi.fn(async () => "none" as const);
+ const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: false,
+ queue,
+ direct,
+ });
+
+ expect(queue).toHaveBeenCalledTimes(1);
+ expect(direct).toHaveBeenCalledTimes(1);
+ expect(result.delivered).toBe(true);
+ expect(result.path).toBe("direct");
+ expect(result.phases).toEqual([
+ { phase: "queue-primary", delivered: false, path: "none", error: undefined },
+ { phase: "direct-primary", delivered: true, path: "direct", error: undefined },
+ ]);
+ });
+
+ it("short-circuits direct send when non-completion queue delivers", async () => {
+ const queue = vi.fn(async () => "queued" as const);
+ const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: false,
+ queue,
+ direct,
+ });
+
+ expect(queue).toHaveBeenCalledTimes(1);
+ expect(direct).not.toHaveBeenCalled();
+ expect(result.path).toBe("queued");
+ expect(result.phases).toEqual([
+ { phase: "queue-primary", delivered: true, path: "queued", error: undefined },
+ ]);
+ });
+
+ it("uses direct-first ordering for completion mode", async () => {
+ const queue = vi.fn(async () => "queued" as const);
+ const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: true,
+ queue,
+ direct,
+ });
+
+ expect(direct).toHaveBeenCalledTimes(1);
+ expect(queue).not.toHaveBeenCalled();
+ expect(result.path).toBe("direct");
+ expect(result.phases).toEqual([
+ { phase: "direct-primary", delivered: true, path: "direct", error: undefined },
+ ]);
+ });
+
+ it("falls back to queue when completion direct send fails", async () => {
+ const queue = vi.fn(async () => "steered" as const);
+ const direct = vi.fn(async () => ({
+ delivered: false,
+ path: "direct" as const,
+ error: "network",
+ }));
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: true,
+ queue,
+ direct,
+ });
+
+ expect(direct).toHaveBeenCalledTimes(1);
+ expect(queue).toHaveBeenCalledTimes(1);
+ expect(result.path).toBe("steered");
+ expect(result.phases).toEqual([
+ { phase: "direct-primary", delivered: false, path: "direct", error: "network" },
+ { phase: "queue-fallback", delivered: true, path: "steered", error: undefined },
+ ]);
+ });
+
+ it("returns direct failure when completion fallback queue cannot deliver", async () => {
+ const queue = vi.fn(async () => "none" as const);
+ const direct = vi.fn(async () => ({
+ delivered: false,
+ path: "direct" as const,
+ error: "failed",
+ }));
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: true,
+ queue,
+ direct,
+ });
+
+ expect(result).toMatchObject({
+ delivered: false,
+ path: "direct",
+ error: "failed",
+ });
+ expect(result.phases).toEqual([
+ { phase: "direct-primary", delivered: false, path: "direct", error: "failed" },
+ { phase: "queue-fallback", delivered: false, path: "none", error: undefined },
+ ]);
+ });
+
+ it("returns none immediately when signal is already aborted", async () => {
+ const queue = vi.fn(async () => "none" as const);
+ const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
+ const controller = new AbortController();
+ controller.abort();
+
+ const result = await runSubagentAnnounceDispatch({
+ expectsCompletionMessage: true,
+ signal: controller.signal,
+ queue,
+ direct,
+ });
+
+ expect(queue).not.toHaveBeenCalled();
+ expect(direct).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ delivered: false,
+ path: "none",
+ phases: [],
+ });
+ });
+});
diff --git a/src/agents/subagent-announce-dispatch.ts b/src/agents/subagent-announce-dispatch.ts
new file mode 100644
index 00000000000..93aa0dd9092
--- /dev/null
+++ b/src/agents/subagent-announce-dispatch.ts
@@ -0,0 +1,104 @@
+export type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none";
+
+export type SubagentAnnounceQueueOutcome = "steered" | "queued" | "none";
+
+export type SubagentAnnounceDeliveryResult = {
+ delivered: boolean;
+ path: SubagentDeliveryPath;
+ error?: string;
+ phases?: SubagentAnnounceDispatchPhaseResult[];
+};
+
+export type SubagentAnnounceDispatchPhase = "queue-primary" | "direct-primary" | "queue-fallback";
+
+export type SubagentAnnounceDispatchPhaseResult = {
+ phase: SubagentAnnounceDispatchPhase;
+ delivered: boolean;
+ path: SubagentDeliveryPath;
+ error?: string;
+};
+
+export function mapQueueOutcomeToDeliveryResult(
+ outcome: SubagentAnnounceQueueOutcome,
+): SubagentAnnounceDeliveryResult {
+ if (outcome === "steered") {
+ return {
+ delivered: true,
+ path: "steered",
+ };
+ }
+ if (outcome === "queued") {
+ return {
+ delivered: true,
+ path: "queued",
+ };
+ }
+ return {
+ delivered: false,
+ path: "none",
+ };
+}
+
+export async function runSubagentAnnounceDispatch(params: {
+ expectsCompletionMessage: boolean;
+ signal?: AbortSignal;
+ queue: () => Promise;
+ direct: () => Promise;
+}): Promise {
+ const phases: SubagentAnnounceDispatchPhaseResult[] = [];
+ const appendPhase = (
+ phase: SubagentAnnounceDispatchPhase,
+ result: SubagentAnnounceDeliveryResult,
+ ) => {
+ phases.push({
+ phase,
+ delivered: result.delivered,
+ path: result.path,
+ error: result.error,
+ });
+ };
+ const withPhases = (result: SubagentAnnounceDeliveryResult): SubagentAnnounceDeliveryResult => ({
+ ...result,
+ phases,
+ });
+
+ if (params.signal?.aborted) {
+ return withPhases({
+ delivered: false,
+ path: "none",
+ });
+ }
+
+ if (!params.expectsCompletionMessage) {
+ const primaryQueue = mapQueueOutcomeToDeliveryResult(await params.queue());
+ appendPhase("queue-primary", primaryQueue);
+ if (primaryQueue.delivered) {
+ return withPhases(primaryQueue);
+ }
+
+ const primaryDirect = await params.direct();
+ appendPhase("direct-primary", primaryDirect);
+ return withPhases(primaryDirect);
+ }
+
+ const primaryDirect = await params.direct();
+ appendPhase("direct-primary", primaryDirect);
+ if (primaryDirect.delivered) {
+ return withPhases(primaryDirect);
+ }
+
+ if (params.signal?.aborted) {
+ return withPhases({
+ delivered: false,
+ path: "none",
+ });
+ }
+
+ const fallbackQueue = mapQueueOutcomeToDeliveryResult(await params.queue());
+ appendPhase("queue-fallback", fallbackQueue);
+ if (fallbackQueue.delivered) {
+ return withPhases(fallbackQueue);
+ }
+
+ return withPhases(primaryDirect);
+}
diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts
index 91f4b0d6752..8952e82cc68 100644
--- a/src/agents/subagent-announce.format.test.ts
+++ b/src/agents/subagent-announce.format.test.ts
@@ -825,6 +825,47 @@ describe("subagent announce formatting", () => {
}
});
+ it("routes manual completion direct-send for telegram forum topics", async () => {
+ sendSpy.mockClear();
+ agentSpy.mockClear();
+ sessionStore = {
+ "agent:main:subagent:test": {
+ sessionId: "child-session-telegram-topic",
+ },
+ "agent:main:main": {
+ sessionId: "requester-session-telegram-topic",
+ lastChannel: "telegram",
+ lastTo: "123:topic:999",
+ lastThreadId: 999,
+ },
+ };
+ chatHistoryMock.mockResolvedValueOnce({
+ messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }],
+ });
+
+ const didAnnounce = await runSubagentAnnounceFlow({
+ childSessionKey: "agent:main:subagent:test",
+ childRunId: "run-direct-telegram-topic",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ requesterOrigin: {
+ channel: "telegram",
+ to: "123",
+ threadId: 42,
+ },
+ ...defaultOutcomeAnnounce,
+ expectsCompletionMessage: true,
+ });
+
+ expect(didAnnounce).toBe(true);
+ expect(sendSpy).toHaveBeenCalledTimes(1);
+ expect(agentSpy).not.toHaveBeenCalled();
+ const call = sendSpy.mock.calls[0]?.[0] as { params?: Record };
+ expect(call?.params?.channel).toBe("telegram");
+ expect(call?.params?.to).toBe("123");
+ expect(call?.params?.threadId).toBe("42");
+ });
+
it("uses hook-provided thread target across requester thread variants", async () => {
const cases = [
{
diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts
index 7d7fd7ceb48..c99a6cb6593 100644
--- a/src/agents/subagent-announce.ts
+++ b/src/agents/subagent-announce.ts
@@ -32,6 +32,10 @@ import {
queueEmbeddedPiMessage,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded.js";
+import {
+ runSubagentAnnounceDispatch,
+ type SubagentAnnounceDeliveryResult,
+} from "./subagent-announce-dispatch.js";
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import type { SpawnSubagentMode } from "./subagent-spawn.js";
@@ -53,14 +57,6 @@ type ToolResultMessage = {
content?: unknown;
};
-type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none";
-
-type SubagentAnnounceDeliveryResult = {
- delivered: boolean;
- path: SubagentDeliveryPath;
- error?: string;
-};
-
function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): number {
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
@@ -705,27 +701,6 @@ async function maybeQueueSubagentAnnounce(params: {
return "none";
}
-function queueOutcomeToDeliveryResult(
- outcome: "steered" | "queued" | "none",
-): SubagentAnnounceDeliveryResult {
- if (outcome === "steered") {
- return {
- delivered: true,
- path: "steered",
- };
- }
- if (outcome === "queued") {
- return {
- delivered: true,
- path: "queued",
- };
- }
- return {
- delivered: false,
- path: "none",
- };
-}
-
async function sendSubagentAnnounceDirectly(params: {
targetRequesterSessionKey: string;
triggerMessage: string;
@@ -905,64 +880,34 @@ async function deliverSubagentAnnouncement(params: {
directIdempotencyKey: string;
signal?: AbortSignal;
}): Promise {
- if (params.signal?.aborted) {
- return {
- delivered: false,
- path: "none",
- };
- }
- // Non-completion mode mirrors historical behavior: try queued/steered delivery first,
- // then (only if not queued) attempt direct delivery.
- if (!params.expectsCompletionMessage) {
- const queueOutcome = await maybeQueueSubagentAnnounce({
- requesterSessionKey: params.requesterSessionKey,
- announceId: params.announceId,
- triggerMessage: params.triggerMessage,
- summaryLine: params.summaryLine,
- requesterOrigin: params.requesterOrigin,
- signal: params.signal,
- });
- const queued = queueOutcomeToDeliveryResult(queueOutcome);
- if (queued.delivered) {
- return queued;
- }
- }
-
- // Completion-mode uses direct send first so manual spawns can return immediately
- // in the common ready-to-deliver case.
- const direct = await sendSubagentAnnounceDirectly({
- targetRequesterSessionKey: params.targetRequesterSessionKey,
- triggerMessage: params.triggerMessage,
- completionMessage: params.completionMessage,
- directIdempotencyKey: params.directIdempotencyKey,
- completionDirectOrigin: params.completionDirectOrigin,
- completionRouteMode: params.completionRouteMode,
- spawnMode: params.spawnMode,
- directOrigin: params.directOrigin,
- requesterIsSubagent: params.requesterIsSubagent,
+ return await runSubagentAnnounceDispatch({
expectsCompletionMessage: params.expectsCompletionMessage,
signal: params.signal,
- bestEffortDeliver: params.bestEffortDeliver,
+ queue: async () =>
+ await maybeQueueSubagentAnnounce({
+ requesterSessionKey: params.requesterSessionKey,
+ announceId: params.announceId,
+ triggerMessage: params.triggerMessage,
+ summaryLine: params.summaryLine,
+ requesterOrigin: params.requesterOrigin,
+ signal: params.signal,
+ }),
+ direct: async () =>
+ await sendSubagentAnnounceDirectly({
+ targetRequesterSessionKey: params.targetRequesterSessionKey,
+ triggerMessage: params.triggerMessage,
+ completionMessage: params.completionMessage,
+ directIdempotencyKey: params.directIdempotencyKey,
+ completionDirectOrigin: params.completionDirectOrigin,
+ completionRouteMode: params.completionRouteMode,
+ spawnMode: params.spawnMode,
+ directOrigin: params.directOrigin,
+ requesterIsSubagent: params.requesterIsSubagent,
+ expectsCompletionMessage: params.expectsCompletionMessage,
+ signal: params.signal,
+ bestEffortDeliver: params.bestEffortDeliver,
+ }),
});
- if (direct.delivered || !params.expectsCompletionMessage) {
- return direct;
- }
-
- // If completion path failed direct delivery, try queueing as a fallback so the
- // report can still be delivered once the requester session is idle.
- const queueOutcome = await maybeQueueSubagentAnnounce({
- requesterSessionKey: params.requesterSessionKey,
- announceId: params.announceId,
- triggerMessage: params.triggerMessage,
- summaryLine: params.summaryLine,
- requesterOrigin: params.requesterOrigin,
- signal: params.signal,
- });
- if (queueOutcome === "steered" || queueOutcome === "queued") {
- return queueOutcomeToDeliveryResult(queueOutcome);
- }
-
- return direct;
}
function loadSessionEntryByKey(sessionKey: string) {
diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts
index 8389c53503c..498b38aaedc 100644
--- a/src/agents/subagent-registry.announce-loop-guard.test.ts
+++ b/src/agents/subagent-registry.announce-loop-guard.test.ts
@@ -155,4 +155,43 @@ describe("announce loop guard (#18264)", () => {
const stored = runs.find((run) => run.runId === entry.runId);
expect(stored?.cleanupCompletedAt).toBeDefined();
});
+
+ test("announce rejection resets cleanupHandled so retries can resume", async () => {
+ announceFn.mockReset();
+ announceFn.mockRejectedValueOnce(new Error("announce failed"));
+ registry.resetSubagentRegistryForTests();
+
+ const now = Date.now();
+ const runId = "test-announce-rejection";
+ loadSubagentRegistryFromDisk.mockReturnValue(
+ new Map([
+ [
+ runId,
+ {
+ runId,
+ childSessionKey: "agent:main:subagent:child-1",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "agent:main:main",
+ task: "rejection test",
+ cleanup: "keep" as const,
+ createdAt: now - 30_000,
+ startedAt: now - 20_000,
+ endedAt: now - 10_000,
+ cleanupHandled: false,
+ },
+ ],
+ ]),
+ );
+
+ registry.initSubagentRegistry();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const runs = registry.listSubagentRunsForRequester("agent:main:main");
+ const stored = runs.find((run) => run.runId === runId);
+ expect(stored?.cleanupHandled).toBe(false);
+ expect(stored?.cleanupCompletedAt).toBeUndefined();
+ expect(stored?.announceRetryCount).toBe(1);
+ expect(stored?.lastAnnounceRetryAt).toBeTypeOf("number");
+ });
});
diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts
index edb8f228b07..072fd91693f 100644
--- a/src/agents/subagent-registry.ts
+++ b/src/agents/subagent-registry.ts
@@ -331,9 +331,16 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor
outcome: entry.outcome,
spawnMode: entry.spawnMode,
expectsCompletionMessage: entry.expectsCompletionMessage,
- }).then((didAnnounce) => {
- void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
- });
+ })
+ .then((didAnnounce) => {
+ void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
+ })
+ .catch((error) => {
+ defaultRuntime.log(
+ `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`,
+ );
+ void finalizeSubagentCleanup(runId, entry.cleanup, false);
+ });
return true;
}
diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts
index c17ff9f9c48..4cfd84dc474 100644
--- a/src/agents/tools/nodes-tool.ts
+++ b/src/agents/tools/nodes-tool.ts
@@ -18,6 +18,7 @@ import {
} from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import type { OpenClawConfig } from "../../config/config.js";
+import { formatExecCommand } from "../../infra/system-run-command.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
@@ -473,7 +474,7 @@ export function createNodesTool(options?: {
// Node requires approval – create a pending approval request on
// the gateway and wait for the user to approve/deny via the UI.
const APPROVAL_TIMEOUT_MS = 120_000;
- const cmdText = command.join(" ");
+ const cmdText = formatExecCommand(command);
const approvalId = crypto.randomUUID();
const approvalResult = await callGatewayTool(
"exec.approval.request",
@@ -481,6 +482,7 @@ export function createNodesTool(options?: {
{
id: approvalId,
command: cmdText,
+ commandArgv: command,
cwd,
nodeId,
host: "node",
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index eb8605ccfe1..32022f95453 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -572,6 +572,22 @@ export async function runAgentTurnWithFallback(params: {
}
}
+ // If the run completed but with an embedded context overflow error that
+ // wasn't recovered from (e.g. compaction reset already attempted), surface
+ // the error to the user instead of silently returning an empty response.
+ // See #26905: Slack DM sessions silently swallowed messages when context
+ // overflow errors were returned as embedded error payloads.
+ const finalEmbeddedError = runResult?.meta?.error;
+ const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim());
+ if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) {
+ return {
+ kind: "final",
+ payload: {
+ text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
+ },
+ };
+ }
+
return {
kind: "success",
runId,
diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts
index 52d1e4550c2..ee8ddc25179 100644
--- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts
+++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts
@@ -1188,6 +1188,54 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
+ it("surfaces overflow fallback when embedded run returns empty payloads", async () => {
+ state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
+ payloads: [],
+ meta: {
+ durationMs: 1,
+ error: {
+ kind: "context_overflow",
+ message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
+ },
+ },
+ }));
+
+ const { run } = createMinimalRun();
+ const res = await run();
+ const payload = Array.isArray(res) ? res[0] : res;
+ expect(payload).toMatchObject({
+ text: expect.stringContaining("conversation is too large"),
+ });
+ if (!payload) {
+ throw new Error("expected payload");
+ }
+ expect(payload.text).toContain("/new");
+ });
+
+ it("surfaces overflow fallback when embedded payload text is whitespace-only", async () => {
+ state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
+ payloads: [{ text: " \n\t ", isError: true }],
+ meta: {
+ durationMs: 1,
+ error: {
+ kind: "context_overflow",
+ message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
+ },
+ },
+ }));
+
+ const { run } = createMinimalRun();
+ const res = await run();
+ const payload = Array.isArray(res) ? res[0] : res;
+ expect(payload).toMatchObject({
+ text: expect.stringContaining("conversation is too large"),
+ });
+ if (!payload) {
+ throw new Error("expected payload");
+ }
+ expect(payload.text).toContain("/new");
+ });
+
it("resets the session after role ordering payloads", async () => {
await withTempStateDir(async (stateDir) => {
const sessionId = "session";
diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts
index da5d55fa9dd..a6e0c9f849a 100644
--- a/src/auto-reply/reply/followup-runner.test.ts
+++ b/src/auto-reply/reply/followup-runner.test.ts
@@ -428,6 +428,87 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
});
+describe("createFollowupRunner typing cleanup", () => {
+ it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
+ const typing = createMockTypingController();
+ runEmbeddedPiAgentMock.mockResolvedValueOnce({
+ payloads: [{ text: "NO_REPLY" }],
+ meta: {},
+ });
+
+ const runner = createFollowupRunner({
+ opts: { onBlockReply: vi.fn(async () => {}) },
+ typing,
+ typingMode: "instant",
+ defaultModel: "anthropic/claude-opus-4-5",
+ });
+
+ await runner(baseQueuedRun());
+
+ expect(typing.markRunComplete).toHaveBeenCalled();
+ expect(typing.markDispatchIdle).toHaveBeenCalled();
+ });
+
+ it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => {
+ const typing = createMockTypingController();
+ runEmbeddedPiAgentMock.mockResolvedValueOnce({
+ payloads: [],
+ meta: {},
+ });
+
+ const runner = createFollowupRunner({
+ opts: { onBlockReply: vi.fn(async () => {}) },
+ typing,
+ typingMode: "instant",
+ defaultModel: "anthropic/claude-opus-4-5",
+ });
+
+ await runner(baseQueuedRun());
+
+ expect(typing.markRunComplete).toHaveBeenCalled();
+ expect(typing.markDispatchIdle).toHaveBeenCalled();
+ });
+
+ it("calls both markRunComplete and markDispatchIdle on agent error", async () => {
+ const typing = createMockTypingController();
+ runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("agent exploded"));
+
+ const runner = createFollowupRunner({
+ opts: { onBlockReply: vi.fn(async () => {}) },
+ typing,
+ typingMode: "instant",
+ defaultModel: "anthropic/claude-opus-4-5",
+ });
+
+ await runner(baseQueuedRun());
+
+ expect(typing.markRunComplete).toHaveBeenCalled();
+ expect(typing.markDispatchIdle).toHaveBeenCalled();
+ });
+
+ it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => {
+ const typing = createMockTypingController();
+ const onBlockReply = vi.fn(async () => {});
+ runEmbeddedPiAgentMock.mockResolvedValueOnce({
+ payloads: [{ text: "hello world!" }],
+ meta: {},
+ });
+
+ const runner = createFollowupRunner({
+ opts: { onBlockReply },
+ typing,
+ typingMode: "instant",
+ defaultModel: "anthropic/claude-opus-4-5",
+ });
+
+ await runner(baseQueuedRun());
+
+ expect(onBlockReply).toHaveBeenCalled();
+ expect(typing.markRunComplete).toHaveBeenCalled();
+ expect(typing.markDispatchIdle).toHaveBeenCalled();
+ });
+});
+
describe("createFollowupRunner agentDir forwarding", () => {
it("passes queued run agentDir to runEmbeddedPiAgent", async () => {
runEmbeddedPiAgentMock.mockClear();
diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts
index 0c91d543d91..3f280d18e52 100644
--- a/src/auto-reply/reply/followup-runner.ts
+++ b/src/auto-reply/reply/followup-runner.ts
@@ -314,7 +314,15 @@ export function createFollowupRunner(params: {
await sendFollowupPayloads(finalPayloads, queued);
} finally {
+ // Both signals are required for the typing controller to clean up.
+ // The main inbound dispatch path calls markDispatchIdle() from the
+ // buffered dispatcher's finally block, but followup turns bypass the
+ // dispatcher entirely — so we must fire both signals here. Without
+ // this, NO_REPLY / empty-payload followups leave the typing indicator
+ // stuck (the keepalive loop keeps sending "typing" to Telegram
+ // indefinitely until the TTL expires).
typing.markRunComplete();
+ typing.markDispatchIdle();
}
};
}
diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts
index 8e9c99667b1..12433057b14 100644
--- a/src/auto-reply/reply/session.test.ts
+++ b/src/auto-reply/reply/session.test.ts
@@ -205,6 +205,135 @@ describe("initSessionState thread forking", () => {
warn.mockRestore();
});
+ it("skips fork and creates fresh session when parent tokens exceed threshold", async () => {
+ const root = await makeCaseDir("openclaw-thread-session-overflow-");
+ const sessionsDir = path.join(root, "sessions");
+ await fs.mkdir(sessionsDir);
+
+ const parentSessionId = "parent-overflow";
+ const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
+ const header = {
+ type: "session",
+ version: 3,
+ id: parentSessionId,
+ timestamp: new Date().toISOString(),
+ cwd: process.cwd(),
+ };
+ const message = {
+ type: "message",
+ id: "m1",
+ parentId: null,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "Parent prompt" },
+ };
+ await fs.writeFile(
+ parentSessionFile,
+ `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
+ "utf-8",
+ );
+
+ const storePath = path.join(root, "sessions.json");
+ const parentSessionKey = "agent:main:slack:channel:c1";
+ // Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000)
+ await saveSessionStore(storePath, {
+ [parentSessionKey]: {
+ sessionId: parentSessionId,
+ sessionFile: parentSessionFile,
+ updatedAt: Date.now(),
+ totalTokens: 170_000,
+ },
+ });
+
+ const cfg = {
+ session: { store: storePath },
+ } as OpenClawConfig;
+
+ const threadSessionKey = "agent:main:slack:channel:c1:thread:456";
+ const result = await initSessionState({
+ ctx: {
+ Body: "Thread reply",
+ SessionKey: threadSessionKey,
+ ParentSessionKey: parentSessionKey,
+ },
+ cfg,
+ commandAuthorized: true,
+ });
+
+ // Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent
+ expect(result.sessionEntry.forkedFromParent).toBe(true);
+ // Session ID should NOT match the parent — it should be a fresh UUID
+ expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
+ // Session file should NOT be the parent's file (it was not forked)
+ expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
+ });
+
+ it("respects session.parentForkMaxTokens override", async () => {
+ const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
+ const sessionsDir = path.join(root, "sessions");
+ await fs.mkdir(sessionsDir);
+
+ const parentSessionId = "parent-override";
+ const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
+ const header = {
+ type: "session",
+ version: 3,
+ id: parentSessionId,
+ timestamp: new Date().toISOString(),
+ cwd: process.cwd(),
+ };
+ const message = {
+ type: "message",
+ id: "m1",
+ parentId: null,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "Parent prompt" },
+ };
+ await fs.writeFile(
+ parentSessionFile,
+ `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
+ "utf-8",
+ );
+
+ const storePath = path.join(root, "sessions.json");
+ const parentSessionKey = "agent:main:slack:channel:c1";
+ await saveSessionStore(storePath, {
+ [parentSessionKey]: {
+ sessionId: parentSessionId,
+ sessionFile: parentSessionFile,
+ updatedAt: Date.now(),
+ totalTokens: 170_000,
+ },
+ });
+
+ const cfg = {
+ session: {
+ store: storePath,
+ parentForkMaxTokens: 200_000,
+ },
+ } as OpenClawConfig;
+
+ const threadSessionKey = "agent:main:slack:channel:c1:thread:789";
+ const result = await initSessionState({
+ ctx: {
+ Body: "Thread reply",
+ SessionKey: threadSessionKey,
+ ParentSessionKey: parentSessionKey,
+ },
+ cfg,
+ commandAuthorized: true,
+ });
+
+ expect(result.sessionEntry.forkedFromParent).toBe(true);
+ expect(result.sessionEntry.sessionFile).toBeTruthy();
+ const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8");
+ const [sessionHeaderLine] = forkedContent.split("\n");
+ const sessionHeader = JSON.parse(sessionHeaderLine ?? "{}") as { parentSession?: string };
+ expect(sessionHeader.parentSession).toBeTruthy();
+ const resolvedParentSession = await fs.realpath(parentSessionFile);
+ const resolvedForkParentSession = await fs.realpath(sessionHeader.parentSession ?? "");
+ expect(resolvedForkParentSession).toBe(resolvedParentSession);
+ });
+
it("records topic-specific session files when MessageThreadId is present", async () => {
const root = await makeCaseDir("openclaw-topic-session-");
const storePath = path.join(root, "sessions.json");
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index 6494192c58b..59b0c7ba379 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -105,6 +105,21 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
+/**
+ * Default max parent token count beyond which thread/session parent forking is skipped.
+ * This prevents new thread sessions from inheriting near-full parent context.
+ * See #26905.
+ */
+const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000;
+
+function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
+ const configured = cfg.session?.parentForkMaxTokens;
+ if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) {
+ return Math.floor(configured);
+ }
+ return DEFAULT_PARENT_FORK_MAX_TOKENS;
+}
+
function forkSessionFromParent(params: {
parentEntry: SessionEntry;
agentId: string;
@@ -171,6 +186,7 @@ export async function initSessionState(params: {
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
+ const parentForkMaxTokens = resolveParentForkMaxTokens(cfg);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
@@ -399,21 +415,33 @@ export async function initSessionState(params: {
sessionStore[parentSessionKey] &&
!alreadyForked
) {
- log.warn(
- `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
- `parentTokens=${sessionStore[parentSessionKey].totalTokens ?? "?"}`,
- );
- const forked = forkSessionFromParent({
- parentEntry: sessionStore[parentSessionKey],
- agentId,
- sessionsDir: path.dirname(storePath),
- });
- if (forked) {
- sessionId = forked.sessionId;
- sessionEntry.sessionId = forked.sessionId;
- sessionEntry.sessionFile = forked.sessionFile;
+ const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
+ if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
+ // Parent context is too large — forking would create a thread session
+ // that immediately overflows the model's context window. Start fresh
+ // instead and mark as forked to prevent re-attempts. See #26905.
+ log.warn(
+ `skipping parent fork (parent too large): parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
+ `parentTokens=${parentTokens} maxTokens=${parentForkMaxTokens}`,
+ );
sessionEntry.forkedFromParent = true;
- log.warn(`forked session created: file=${forked.sessionFile}`);
+ } else {
+ log.warn(
+ `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
+ `parentTokens=${parentTokens}`,
+ );
+ const forked = forkSessionFromParent({
+ parentEntry: sessionStore[parentSessionKey],
+ agentId,
+ sessionsDir: path.dirname(storePath),
+ });
+ if (forked) {
+ sessionId = forked.sessionId;
+ sessionEntry.sessionId = forked.sessionId;
+ sessionEntry.sessionFile = forked.sessionFile;
+ sessionEntry.forkedFromParent = true;
+ log.warn(`forked session created: file=${forked.sessionFile}`);
+ }
}
}
const fallbackSessionFile = !sessionEntry.sessionFile
diff --git a/src/browser/paths.test.ts b/src/browser/paths.test.ts
index 441ee05b869..0fe27fe1e4e 100644
--- a/src/browser/paths.test.ts
+++ b/src/browser/paths.test.ts
@@ -6,6 +6,8 @@ import {
resolveExistingPathsWithinRoot,
resolvePathsWithinRoot,
resolvePathWithinRoot,
+ resolveStrictExistingPathsWithinRoot,
+ resolveWritablePathWithinRoot,
} from "./paths.js";
async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> {
@@ -194,6 +196,29 @@ describe("resolveExistingPathsWithinRoot", () => {
);
});
+describe("resolveStrictExistingPathsWithinRoot", () => {
+ function expectInvalidResult(
+ result: Awaited>,
+ expectedSnippet: string,
+ ) {
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toContain(expectedSnippet);
+ }
+ }
+
+ it("rejects missing files instead of returning lexical fallbacks", async () => {
+ await withFixtureRoot(async ({ uploadsDir }) => {
+ const result = await resolveStrictExistingPathsWithinRoot({
+ rootDir: uploadsDir,
+ requestedPaths: ["missing.txt"],
+ scopeLabel: "uploads directory",
+ });
+ expectInvalidResult(result, "regular non-symlink file");
+ });
+ });
+});
+
describe("resolvePathWithinRoot", () => {
it("uses default file name when requested path is blank", () => {
const result = resolvePathWithinRoot({
@@ -221,6 +246,45 @@ describe("resolvePathWithinRoot", () => {
});
});
+describe("resolveWritablePathWithinRoot", () => {
+ it("accepts a writable path under root when parent is a real directory", async () => {
+ await withFixtureRoot(async ({ uploadsDir }) => {
+ const result = await resolveWritablePathWithinRoot({
+ rootDir: uploadsDir,
+ requestedPath: "safe.txt",
+ scopeLabel: "uploads directory",
+ });
+ expect(result).toEqual({
+ ok: true,
+ path: path.resolve(uploadsDir, "safe.txt"),
+ });
+ });
+ });
+
+ it.runIf(process.platform !== "win32")(
+ "rejects write paths routed through a symlinked parent directory",
+ async () => {
+ await withFixtureRoot(async ({ baseDir, uploadsDir }) => {
+ const outsideDir = path.join(baseDir, "outside");
+ await fs.mkdir(outsideDir, { recursive: true });
+ const symlinkDir = path.join(uploadsDir, "escape-link");
+ await fs.symlink(outsideDir, symlinkDir);
+
+ const result = await resolveWritablePathWithinRoot({
+ rootDir: uploadsDir,
+ requestedPath: "escape-link/pwned.txt",
+ scopeLabel: "uploads directory",
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toContain("must stay within uploads directory");
+ }
+ });
+ },
+ );
+});
+
describe("resolvePathsWithinRoot", () => {
it("resolves all valid in-root paths", () => {
const result = resolvePathsWithinRoot({
diff --git a/src/browser/paths.ts b/src/browser/paths.ts
index 0b458e44dec..e171f40c732 100644
--- a/src/browser/paths.ts
+++ b/src/browser/paths.ts
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
+import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir();
@@ -8,6 +9,58 @@ export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
+type InvalidPathResult = { ok: false; error: string };
+
+function invalidPath(scopeLabel: string): InvalidPathResult {
+ return {
+ ok: false,
+ error: `Invalid path: must stay within ${scopeLabel}`,
+ };
+}
+
+async function resolveRealPathIfExists(targetPath: string): Promise {
+ try {
+ return await fs.realpath(targetPath);
+ } catch {
+ return undefined;
+ }
+}
+
+async function resolveTrustedRootRealPath(rootDir: string): Promise {
+ try {
+ const rootLstat = await fs.lstat(rootDir);
+ if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) {
+ return undefined;
+ }
+ return await fs.realpath(rootDir);
+ } catch {
+ return undefined;
+ }
+}
+
+async function validateCanonicalPathWithinRoot(params: {
+ rootRealPath: string;
+ candidatePath: string;
+ expect: "directory" | "file";
+}): Promise<"ok" | "not-found" | "invalid"> {
+ try {
+ const candidateLstat = await fs.lstat(params.candidatePath);
+ if (candidateLstat.isSymbolicLink()) {
+ return "invalid";
+ }
+ if (params.expect === "directory" && !candidateLstat.isDirectory()) {
+ return "invalid";
+ }
+ if (params.expect === "file" && !candidateLstat.isFile()) {
+ return "invalid";
+ }
+ const candidateRealPath = await fs.realpath(params.candidatePath);
+ return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid";
+ } catch (err) {
+ return isNotFoundPathError(err) ? "not-found" : "invalid";
+ }
+}
+
export function resolvePathWithinRoot(params: {
rootDir: string;
requestedPath: string;
@@ -30,6 +83,46 @@ export function resolvePathWithinRoot(params: {
return { ok: true, path: resolved };
}
+export async function resolveWritablePathWithinRoot(params: {
+ rootDir: string;
+ requestedPath: string;
+ scopeLabel: string;
+ defaultFileName?: string;
+}): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
+ const lexical = resolvePathWithinRoot(params);
+ if (!lexical.ok) {
+ return lexical;
+ }
+
+ const rootDir = path.resolve(params.rootDir);
+ const rootRealPath = await resolveTrustedRootRealPath(rootDir);
+ if (!rootRealPath) {
+ return invalidPath(params.scopeLabel);
+ }
+
+ const requestedPath = lexical.path;
+ const parentDir = path.dirname(requestedPath);
+ const parentStatus = await validateCanonicalPathWithinRoot({
+ rootRealPath,
+ candidatePath: parentDir,
+ expect: "directory",
+ });
+ if (parentStatus !== "ok") {
+ return invalidPath(params.scopeLabel);
+ }
+
+ const targetStatus = await validateCanonicalPathWithinRoot({
+ rootRealPath,
+ candidatePath: requestedPath,
+ expect: "file",
+ });
+ if (targetStatus === "invalid") {
+ return invalidPath(params.scopeLabel);
+ }
+
+ return lexical;
+}
+
export function resolvePathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
@@ -54,15 +147,33 @@ export async function resolveExistingPathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
+}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
+ return await resolveCheckedPathsWithinRoot({
+ ...params,
+ allowMissingFallback: true,
+ });
+}
+
+export async function resolveStrictExistingPathsWithinRoot(params: {
+ rootDir: string;
+ requestedPaths: string[];
+ scopeLabel: string;
+}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
+ return await resolveCheckedPathsWithinRoot({
+ ...params,
+ allowMissingFallback: false,
+ });
+}
+
+async function resolveCheckedPathsWithinRoot(params: {
+ rootDir: string;
+ requestedPaths: string[];
+ scopeLabel: string;
+ allowMissingFallback: boolean;
}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
const rootDir = path.resolve(params.rootDir);
- let rootRealPath: string | undefined;
- try {
- rootRealPath = await fs.realpath(rootDir);
- } catch {
- // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks.
- rootRealPath = undefined;
- }
+ // Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks.
+ const rootRealPath = await resolveRealPathIfExists(rootDir);
const isInRoot = (relativePath: string) =>
Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
@@ -119,7 +230,7 @@ export async function resolveExistingPathsWithinRoot(params: {
});
resolvedPaths.push(opened.realPath);
} catch (err) {
- if (err instanceof SafeOpenError && err.code === "not-found") {
+ if (params.allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") {
// Preserve historical behavior for paths that do not exist yet.
resolvedPaths.push(pathResult.fallbackPath);
continue;
diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts
index 12be321653b..4933c78b5e4 100644
--- a/src/browser/pw-tools-core.downloads.ts
+++ b/src/browser/pw-tools-core.downloads.ts
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
+import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
ensurePageState,
getPageForTargetId,
@@ -166,7 +167,20 @@ export async function armFileUploadViaPlaywright(opts: {
}
return;
}
- await fileChooser.setFiles(opts.paths);
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
+ rootDir: DEFAULT_UPLOAD_DIR,
+ requestedPaths: opts.paths,
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
+ });
+ if (!uploadPathsResult.ok) {
+ try {
+ await page.keyboard.press("Escape");
+ } catch {
+ // Best-effort.
+ }
+ return;
+ }
+ await fileChooser.setFiles(uploadPathsResult.paths);
try {
const input =
typeof fileChooser.element === "function"
diff --git a/src/browser/pw-tools-core.interactions.set-input-files.test.ts b/src/browser/pw-tools-core.interactions.set-input-files.test.ts
new file mode 100644
index 00000000000..dfbd6f58563
--- /dev/null
+++ b/src/browser/pw-tools-core.interactions.set-input-files.test.ts
@@ -0,0 +1,111 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+let page: Record | null = null;
+let locator: Record | null = null;
+
+const getPageForTargetId = vi.fn(async () => {
+ if (!page) {
+ throw new Error("test: page not set");
+ }
+ return page;
+});
+const ensurePageState = vi.fn(() => ({}));
+const restoreRoleRefsForTarget = vi.fn(() => {});
+const refLocator = vi.fn(() => {
+ if (!locator) {
+ throw new Error("test: locator not set");
+ }
+ return locator;
+});
+const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
+
+const resolveStrictExistingPathsWithinRoot =
+ vi.fn();
+
+vi.mock("./pw-session.js", () => {
+ return {
+ ensurePageState,
+ forceDisconnectPlaywrightForTarget,
+ getPageForTargetId,
+ refLocator,
+ restoreRoleRefsForTarget,
+ };
+});
+
+vi.mock("./paths.js", () => {
+ return {
+ DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads",
+ resolveStrictExistingPathsWithinRoot,
+ };
+});
+
+let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
+
+describe("setInputFilesViaPlaywright", () => {
+ beforeAll(async () => {
+ ({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ page = null;
+ locator = null;
+ resolveStrictExistingPathsWithinRoot.mockResolvedValue({
+ ok: true,
+ paths: ["/private/tmp/openclaw/uploads/ok.txt"],
+ });
+ });
+
+ it("revalidates upload paths and uses resolved canonical paths for inputRef", async () => {
+ const setInputFiles = vi.fn(async () => {});
+ locator = {
+ setInputFiles,
+ elementHandle: vi.fn(async () => null),
+ };
+ page = {
+ locator: vi.fn(() => ({ first: () => locator })),
+ };
+
+ await setInputFilesViaPlaywright({
+ cdpUrl: "http://127.0.0.1:18792",
+ targetId: "T1",
+ inputRef: "e7",
+ paths: ["/tmp/openclaw/uploads/ok.txt"],
+ });
+
+ expect(resolveStrictExistingPathsWithinRoot).toHaveBeenCalledWith({
+ rootDir: "/tmp/openclaw/uploads",
+ requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
+ scopeLabel: "uploads directory (/tmp/openclaw/uploads)",
+ });
+ expect(refLocator).toHaveBeenCalledWith(page, "e7");
+ expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
+ });
+
+ it("throws and skips setInputFiles when use-time validation fails", async () => {
+ resolveStrictExistingPathsWithinRoot.mockResolvedValueOnce({
+ ok: false,
+ error: "Invalid path: must stay within uploads directory",
+ });
+
+ const setInputFiles = vi.fn(async () => {});
+ locator = {
+ setInputFiles,
+ elementHandle: vi.fn(async () => null),
+ };
+ page = {
+ locator: vi.fn(() => ({ first: () => locator })),
+ };
+
+ await expect(
+ setInputFilesViaPlaywright({
+ cdpUrl: "http://127.0.0.1:18792",
+ targetId: "T1",
+ element: "input[type=file]",
+ paths: ["/tmp/openclaw/uploads/missing.txt"],
+ }),
+ ).rejects.toThrow("Invalid path: must stay within uploads directory");
+
+ expect(setInputFiles).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts
index 55e130c580e..cd6ad0e165c 100644
--- a/src/browser/pw-tools-core.interactions.ts
+++ b/src/browser/pw-tools-core.interactions.ts
@@ -1,4 +1,5 @@
import type { BrowserFormField } from "./client-actions-core.js";
+import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
ensurePageState,
forceDisconnectPlaywrightForTarget,
@@ -626,9 +627,18 @@ export async function setInputFilesViaPlaywright(opts: {
}
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
+ rootDir: DEFAULT_UPLOAD_DIR,
+ requestedPaths: opts.paths,
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
+ });
+ if (!uploadPathsResult.ok) {
+ throw new Error(uploadPathsResult.error);
+ }
+ const resolvedPaths = uploadPathsResult.paths;
try {
- await locator.setInputFiles(opts.paths);
+ await locator.setInputFiles(resolvedPaths);
} catch (err) {
throw toAIFriendlyError(err, inputRef || element);
}
diff --git a/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts b/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts
index 3afbb2b9d40..16264ba9eb3 100644
--- a/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts
+++ b/src/browser/pw-tools-core.last-file-chooser-arm-wins.test.ts
@@ -1,4 +1,8 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
import { describe, expect, it, vi } from "vitest";
+import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
@@ -9,6 +13,15 @@ const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
it("last file-chooser arm wins", async () => {
+ const firstPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-1-${crypto.randomUUID()}.txt`);
+ const secondPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-arm-2-${crypto.randomUUID()}.txt`);
+ await fs.mkdir(DEFAULT_UPLOAD_DIR, { recursive: true });
+ await Promise.all([
+ fs.writeFile(firstPath, "1", "utf8"),
+ fs.writeFile(secondPath, "2", "utf8"),
+ ]);
+ const secondCanonicalPath = await fs.realpath(secondPath);
+
let resolve1: ((value: unknown) => void) | null = null;
let resolve2: ((value: unknown) => void) | null = null;
@@ -35,24 +48,30 @@ describe("pw-tools-core", () => {
keyboard: { press: vi.fn(async () => {}) },
});
- await mod.armFileUploadViaPlaywright({
- cdpUrl: "http://127.0.0.1:18792",
- paths: ["/tmp/1"],
- });
- await mod.armFileUploadViaPlaywright({
- cdpUrl: "http://127.0.0.1:18792",
- paths: ["/tmp/2"],
- });
+ try {
+ await mod.armFileUploadViaPlaywright({
+ cdpUrl: "http://127.0.0.1:18792",
+ paths: [firstPath],
+ });
+ await mod.armFileUploadViaPlaywright({
+ cdpUrl: "http://127.0.0.1:18792",
+ paths: [secondPath],
+ });
- if (!resolve1 || !resolve2) {
- throw new Error("file chooser handlers were not registered");
+ if (!resolve1 || !resolve2) {
+ throw new Error("file chooser handlers were not registered");
+ }
+ (resolve1 as (value: unknown) => void)(fc1);
+ (resolve2 as (value: unknown) => void)(fc2);
+ await Promise.resolve();
+
+ expect(fc1.setFiles).not.toHaveBeenCalled();
+ await vi.waitFor(() => {
+ expect(fc2.setFiles).toHaveBeenCalledWith([secondCanonicalPath]);
+ });
+ } finally {
+ await Promise.all([fs.rm(firstPath, { force: true }), fs.rm(secondPath, { force: true })]);
}
- (resolve1 as (value: unknown) => void)(fc1);
- (resolve2 as (value: unknown) => void)(fc2);
- await Promise.resolve();
-
- expect(fc1.setFiles).not.toHaveBeenCalled();
- expect(fc2.setFiles).toHaveBeenCalledWith(["/tmp/2"]);
});
it("arms the next dialog and accepts/dismisses (default timeout)", async () => {
const accept = vi.fn(async () => {});
diff --git a/src/browser/pw-tools-core.screenshots-element-selector.test.ts b/src/browser/pw-tools-core.screenshots-element-selector.test.ts
index 843d07050fb..1894d65912f 100644
--- a/src/browser/pw-tools-core.screenshots-element-selector.test.ts
+++ b/src/browser/pw-tools-core.screenshots-element-selector.test.ts
@@ -1,4 +1,8 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
import { describe, expect, it, vi } from "vitest";
+import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
getPwToolsCoreSessionMocks,
installPwToolsCoreTestHooks,
@@ -81,6 +85,10 @@ describe("pw-tools-core", () => {
).rejects.toThrow(/fullPage is not supported/i);
});
it("arms the next file chooser and sets files (default timeout)", async () => {
+ const uploadPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-upload-${crypto.randomUUID()}.txt`);
+ await fs.mkdir(path.dirname(uploadPath), { recursive: true });
+ await fs.writeFile(uploadPath, "fixture", "utf8");
+ const canonicalUploadPath = await fs.realpath(uploadPath);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const waitForEvent = vi.fn(async (_event: string, _opts: unknown) => fileChooser);
setPwToolsCoreCurrentPage({
@@ -88,19 +96,47 @@ describe("pw-tools-core", () => {
keyboard: { press: vi.fn(async () => {}) },
});
+ try {
+ await mod.armFileUploadViaPlaywright({
+ cdpUrl: "http://127.0.0.1:18792",
+ targetId: "T1",
+ paths: [uploadPath],
+ });
+
+ // waitForEvent is awaited immediately; handler continues async.
+ await Promise.resolve();
+
+ expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
+ timeout: 120_000,
+ });
+ await vi.waitFor(() => {
+ expect(fileChooser.setFiles).toHaveBeenCalledWith([canonicalUploadPath]);
+ });
+ } finally {
+ await fs.rm(uploadPath, { force: true });
+ }
+ });
+ it("revalidates file-chooser paths at use-time and cancels missing files", async () => {
+ const missingPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-missing-${crypto.randomUUID()}.txt`);
+ const fileChooser = { setFiles: vi.fn(async () => {}) };
+ const press = vi.fn(async () => {});
+ const waitForEvent = vi.fn(async () => fileChooser);
+ setPwToolsCoreCurrentPage({
+ waitForEvent,
+ keyboard: { press },
+ });
+
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
- paths: ["/tmp/a.txt"],
+ paths: [missingPath],
});
-
- // waitForEvent is awaited immediately; handler continues async.
await Promise.resolve();
- expect(waitForEvent).toHaveBeenCalledWith("filechooser", {
- timeout: 120_000,
+ await vi.waitFor(() => {
+ expect(press).toHaveBeenCalledWith("Escape");
});
- expect(fileChooser.setFiles).toHaveBeenCalledWith(["/tmp/a.txt"]);
+ expect(fileChooser.setFiles).not.toHaveBeenCalled();
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
diff --git a/src/browser/routes/agent.act.download.ts b/src/browser/routes/agent.act.download.ts
new file mode 100644
index 00000000000..d08287fea59
--- /dev/null
+++ b/src/browser/routes/agent.act.download.ts
@@ -0,0 +1,97 @@
+import type { BrowserRouteContext } from "../server-context.js";
+import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } from "./agent.shared.js";
+import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
+import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
+import type { BrowserRouteRegistrar } from "./types.js";
+import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
+
+function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
+ return {
+ cdpUrl,
+ targetId,
+ timeoutMs: timeoutMs ?? undefined,
+ };
+}
+
+export function registerBrowserAgentActDownloadRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
+ app.post("/wait/download", async (req, res) => {
+ const body = readBody(req);
+ const targetId = resolveTargetIdFromBody(body);
+ const out = toStringOrEmpty(body.path) || "";
+ const timeoutMs = toNumber(body.timeoutMs);
+
+ await withPlaywrightRouteContext({
+ req,
+ res,
+ ctx,
+ targetId,
+ feature: "wait for download",
+ run: async ({ cdpUrl, tab, pw }) => {
+ await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
+ let downloadPath: string | undefined;
+ if (out.trim()) {
+ const resolvedDownloadPath = await resolveWritableOutputPathOrRespond({
+ res,
+ rootDir: DEFAULT_DOWNLOAD_DIR,
+ requestedPath: out,
+ scopeLabel: "downloads directory",
+ });
+ if (!resolvedDownloadPath) {
+ return;
+ }
+ downloadPath = resolvedDownloadPath;
+ }
+ const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
+ const result = await pw.waitForDownloadViaPlaywright({
+ ...requestBase,
+ path: downloadPath,
+ });
+ res.json({ ok: true, targetId: tab.targetId, download: result });
+ },
+ });
+ });
+
+ app.post("/download", async (req, res) => {
+ const body = readBody(req);
+ const targetId = resolveTargetIdFromBody(body);
+ const ref = toStringOrEmpty(body.ref);
+ const out = toStringOrEmpty(body.path);
+ const timeoutMs = toNumber(body.timeoutMs);
+ if (!ref) {
+ return jsonError(res, 400, "ref is required");
+ }
+ if (!out) {
+ return jsonError(res, 400, "path is required");
+ }
+
+ await withPlaywrightRouteContext({
+ req,
+ res,
+ ctx,
+ targetId,
+ feature: "download",
+ run: async ({ cdpUrl, tab, pw }) => {
+ await ensureOutputRootDir(DEFAULT_DOWNLOAD_DIR);
+ const downloadPath = await resolveWritableOutputPathOrRespond({
+ res,
+ rootDir: DEFAULT_DOWNLOAD_DIR,
+ requestedPath: out,
+ scopeLabel: "downloads directory",
+ });
+ if (!downloadPath) {
+ return;
+ }
+ const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
+ const result = await pw.downloadViaPlaywright({
+ ...requestBase,
+ ref,
+ path: downloadPath,
+ });
+ res.json({ ok: true, targetId: tab.targetId, download: result });
+ },
+ });
+ });
+}
diff --git a/src/browser/routes/agent.act.hooks.ts b/src/browser/routes/agent.act.hooks.ts
new file mode 100644
index 00000000000..56d97bb03d3
--- /dev/null
+++ b/src/browser/routes/agent.act.hooks.ts
@@ -0,0 +1,100 @@
+import type { BrowserRouteContext } from "../server-context.js";
+import { readBody, resolveTargetIdFromBody, withPlaywrightRouteContext } from "./agent.shared.js";
+import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
+import type { BrowserRouteRegistrar } from "./types.js";
+import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
+
+export function registerBrowserAgentActHookRoutes(
+ app: BrowserRouteRegistrar,
+ ctx: BrowserRouteContext,
+) {
+ app.post("/hooks/file-chooser", async (req, res) => {
+ const body = readBody(req);
+ const targetId = resolveTargetIdFromBody(body);
+ const ref = toStringOrEmpty(body.ref) || undefined;
+ const inputRef = toStringOrEmpty(body.inputRef) || undefined;
+ const element = toStringOrEmpty(body.element) || undefined;
+ const paths = toStringArray(body.paths) ?? [];
+ const timeoutMs = toNumber(body.timeoutMs);
+ if (!paths.length) {
+ return jsonError(res, 400, "paths are required");
+ }
+
+ await withPlaywrightRouteContext({
+ req,
+ res,
+ ctx,
+ targetId,
+ feature: "file chooser hook",
+ run: async ({ cdpUrl, tab, pw }) => {
+ const uploadPathsResult = await resolveExistingPathsWithinRoot({
+ rootDir: DEFAULT_UPLOAD_DIR,
+ requestedPaths: paths,
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
+ });
+ if (!uploadPathsResult.ok) {
+ res.status(400).json({ error: uploadPathsResult.error });
+ return;
+ }
+ const resolvedPaths = uploadPathsResult.paths;
+
+ if (inputRef || element) {
+ if (ref) {
+ return jsonError(res, 400, "ref cannot be combined with inputRef/element");
+ }
+ await pw.setInputFilesViaPlaywright({
+ cdpUrl,
+ targetId: tab.targetId,
+ inputRef,
+ element,
+ paths: resolvedPaths,
+ });
+ } else {
+ await pw.armFileUploadViaPlaywright({
+ cdpUrl,
+ targetId: tab.targetId,
+ paths: resolvedPaths,
+ timeoutMs: timeoutMs ?? undefined,
+ });
+ if (ref) {
+ await pw.clickViaPlaywright({
+ cdpUrl,
+ targetId: tab.targetId,
+ ref,
+ });
+ }
+ }
+ res.json({ ok: true });
+ },
+ });
+ });
+
+ app.post("/hooks/dialog", async (req, res) => {
+ const body = readBody(req);
+ const targetId = resolveTargetIdFromBody(body);
+ const accept = toBoolean(body.accept);
+ const promptText = toStringOrEmpty(body.promptText) || undefined;
+ const timeoutMs = toNumber(body.timeoutMs);
+ if (accept === undefined) {
+ return jsonError(res, 400, "accept is required");
+ }
+
+ await withPlaywrightRouteContext({
+ req,
+ res,
+ ctx,
+ targetId,
+ feature: "dialog hook",
+ run: async ({ cdpUrl, tab, pw }) => {
+ await pw.armDialogViaPlaywright({
+ cdpUrl,
+ targetId: tab.targetId,
+ accept,
+ promptText,
+ timeoutMs: timeoutMs ?? undefined,
+ });
+ res.json({ ok: true });
+ },
+ });
+ });
+}
diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts
index 78fa2f6856c..7bbd29de42e 100644
--- a/src/browser/routes/agent.act.ts
+++ b/src/browser/routes/agent.act.ts
@@ -1,5 +1,7 @@
import type { BrowserFormField } from "../client-actions-core.js";
import type { BrowserRouteContext } from "../server-context.js";
+import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js";
+import { registerBrowserAgentActHookRoutes } from "./agent.act.hooks.js";
import {
type ActKind,
isActKind,
@@ -12,40 +14,9 @@ import {
withPlaywrightRouteContext,
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
-import {
- DEFAULT_DOWNLOAD_DIR,
- DEFAULT_UPLOAD_DIR,
- resolvePathWithinRoot,
- resolveExistingPathsWithinRoot,
-} from "./path-output.js";
-import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
+import type { BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
-function resolveDownloadPathOrRespond(res: BrowserResponse, requestedPath: string): string | null {
- const downloadPathResult = resolvePathWithinRoot({
- rootDir: DEFAULT_DOWNLOAD_DIR,
- requestedPath,
- scopeLabel: "downloads directory",
- });
- if (!downloadPathResult.ok) {
- res.status(400).json({ error: downloadPathResult.error });
- return null;
- }
- return downloadPathResult.path;
-}
-
-function buildDownloadRequestBase(cdpUrl: string, targetId: string, timeoutMs: number | undefined) {
- return {
- cdpUrl,
- targetId,
- timeoutMs: timeoutMs ?? undefined,
- };
-}
-
-function respondWithDownloadResult(res: BrowserResponse, targetId: string, result: unknown) {
- res.json({ ok: true, targetId, download: result });
-}
-
export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
@@ -363,161 +334,8 @@ export function registerBrowserAgentActRoutes(
});
});
- app.post("/hooks/file-chooser", async (req, res) => {
- const body = readBody(req);
- const targetId = resolveTargetIdFromBody(body);
- const ref = toStringOrEmpty(body.ref) || undefined;
- const inputRef = toStringOrEmpty(body.inputRef) || undefined;
- const element = toStringOrEmpty(body.element) || undefined;
- const paths = toStringArray(body.paths) ?? [];
- const timeoutMs = toNumber(body.timeoutMs);
- if (!paths.length) {
- return jsonError(res, 400, "paths are required");
- }
-
- await withPlaywrightRouteContext({
- req,
- res,
- ctx,
- targetId,
- feature: "file chooser hook",
- run: async ({ cdpUrl, tab, pw }) => {
- const uploadPathsResult = await resolveExistingPathsWithinRoot({
- rootDir: DEFAULT_UPLOAD_DIR,
- requestedPaths: paths,
- scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
- });
- if (!uploadPathsResult.ok) {
- res.status(400).json({ error: uploadPathsResult.error });
- return;
- }
- const resolvedPaths = uploadPathsResult.paths;
-
- if (inputRef || element) {
- if (ref) {
- return jsonError(res, 400, "ref cannot be combined with inputRef/element");
- }
- await pw.setInputFilesViaPlaywright({
- cdpUrl,
- targetId: tab.targetId,
- inputRef,
- element,
- paths: resolvedPaths,
- });
- } else {
- await pw.armFileUploadViaPlaywright({
- cdpUrl,
- targetId: tab.targetId,
- paths: resolvedPaths,
- timeoutMs: timeoutMs ?? undefined,
- });
- if (ref) {
- await pw.clickViaPlaywright({
- cdpUrl,
- targetId: tab.targetId,
- ref,
- });
- }
- }
- res.json({ ok: true });
- },
- });
- });
-
- app.post("/hooks/dialog", async (req, res) => {
- const body = readBody(req);
- const targetId = resolveTargetIdFromBody(body);
- const accept = toBoolean(body.accept);
- const promptText = toStringOrEmpty(body.promptText) || undefined;
- const timeoutMs = toNumber(body.timeoutMs);
- if (accept === undefined) {
- return jsonError(res, 400, "accept is required");
- }
-
- await withPlaywrightRouteContext({
- req,
- res,
- ctx,
- targetId,
- feature: "dialog hook",
- run: async ({ cdpUrl, tab, pw }) => {
- await pw.armDialogViaPlaywright({
- cdpUrl,
- targetId: tab.targetId,
- accept,
- promptText,
- timeoutMs: timeoutMs ?? undefined,
- });
- res.json({ ok: true });
- },
- });
- });
-
- app.post("/wait/download", async (req, res) => {
- const body = readBody(req);
- const targetId = resolveTargetIdFromBody(body);
- const out = toStringOrEmpty(body.path) || "";
- const timeoutMs = toNumber(body.timeoutMs);
-
- await withPlaywrightRouteContext({
- req,
- res,
- ctx,
- targetId,
- feature: "wait for download",
- run: async ({ cdpUrl, tab, pw }) => {
- let downloadPath: string | undefined;
- if (out.trim()) {
- const resolvedDownloadPath = resolveDownloadPathOrRespond(res, out);
- if (!resolvedDownloadPath) {
- return;
- }
- downloadPath = resolvedDownloadPath;
- }
- const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
- const result = await pw.waitForDownloadViaPlaywright({
- ...requestBase,
- path: downloadPath,
- });
- respondWithDownloadResult(res, tab.targetId, result);
- },
- });
- });
-
- app.post("/download", async (req, res) => {
- const body = readBody(req);
- const targetId = resolveTargetIdFromBody(body);
- const ref = toStringOrEmpty(body.ref);
- const out = toStringOrEmpty(body.path);
- const timeoutMs = toNumber(body.timeoutMs);
- if (!ref) {
- return jsonError(res, 400, "ref is required");
- }
- if (!out) {
- return jsonError(res, 400, "path is required");
- }
-
- await withPlaywrightRouteContext({
- req,
- res,
- ctx,
- targetId,
- feature: "download",
- run: async ({ cdpUrl, tab, pw }) => {
- const downloadPath = resolveDownloadPathOrRespond(res, out);
- if (!downloadPath) {
- return;
- }
- const requestBase = buildDownloadRequestBase(cdpUrl, tab.targetId, timeoutMs);
- const result = await pw.downloadViaPlaywright({
- ...requestBase,
- ref,
- path: downloadPath,
- });
- respondWithDownloadResult(res, tab.targetId, result);
- },
- });
- });
+ registerBrowserAgentActHookRoutes(app, ctx);
+ registerBrowserAgentActDownloadRoutes(app, ctx);
app.post("/response/body", async (req, res) => {
const body = readBody(req);
diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts
index fab517d9589..f5c0d7b2030 100644
--- a/src/browser/routes/agent.debug.ts
+++ b/src/browser/routes/agent.debug.ts
@@ -1,5 +1,4 @@
import crypto from "node:crypto";
-import fs from "node:fs/promises";
import path from "node:path";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -8,7 +7,8 @@ import {
resolveTargetIdFromQuery,
withPlaywrightRouteContext,
} from "./agent.shared.js";
-import { DEFAULT_TRACE_DIR, resolvePathWithinRoot } from "./path-output.js";
+import { resolveWritableOutputPathOrRespond } from "./output-paths.js";
+import { DEFAULT_TRACE_DIR } from "./path-output.js";
import type { BrowserRouteRegistrar } from "./types.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
@@ -120,19 +120,17 @@ export function registerBrowserAgentDebugRoutes(
feature: "trace stop",
run: async ({ cdpUrl, tab, pw }) => {
const id = crypto.randomUUID();
- const dir = DEFAULT_TRACE_DIR;
- await fs.mkdir(dir, { recursive: true });
- const tracePathResult = resolvePathWithinRoot({
- rootDir: dir,
+ const tracePath = await resolveWritableOutputPathOrRespond({
+ res,
+ rootDir: DEFAULT_TRACE_DIR,
requestedPath: out,
scopeLabel: "trace directory",
defaultFileName: `browser-trace-${id}.zip`,
+ ensureRootDir: true,
});
- if (!tracePathResult.ok) {
- res.status(400).json({ error: tracePathResult.error });
+ if (!tracePath) {
return;
}
- const tracePath = tracePathResult.path;
await pw.traceStopViaPlaywright({
cdpUrl,
targetId: tab.targetId,
diff --git a/src/browser/routes/output-paths.ts b/src/browser/routes/output-paths.ts
new file mode 100644
index 00000000000..4a11d3dc816
--- /dev/null
+++ b/src/browser/routes/output-paths.ts
@@ -0,0 +1,31 @@
+import fs from "node:fs/promises";
+import { resolveWritablePathWithinRoot } from "./path-output.js";
+import type { BrowserResponse } from "./types.js";
+
+export async function ensureOutputRootDir(rootDir: string): Promise {
+ await fs.mkdir(rootDir, { recursive: true });
+}
+
+export async function resolveWritableOutputPathOrRespond(params: {
+ res: BrowserResponse;
+ rootDir: string;
+ requestedPath: string;
+ scopeLabel: string;
+ defaultFileName?: string;
+ ensureRootDir?: boolean;
+}): Promise {
+ if (params.ensureRootDir) {
+ await ensureOutputRootDir(params.rootDir);
+ }
+ const pathResult = await resolveWritablePathWithinRoot({
+ rootDir: params.rootDir,
+ requestedPath: params.requestedPath,
+ scopeLabel: params.scopeLabel,
+ defaultFileName: params.defaultFileName,
+ });
+ if (!pathResult.ok) {
+ params.res.status(400).json({ error: pathResult.error });
+ return null;
+ }
+ return pathResult.path;
+}
diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts
index 0328736eade..e96193e5995 100644
--- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts
+++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts
@@ -1,7 +1,9 @@
+import fs from "node:fs/promises";
+import os from "node:os";
import path from "node:path";
import { fetch as realFetch } from "undici";
import { describe, expect, it } from "vitest";
-import { DEFAULT_UPLOAD_DIR } from "./paths.js";
+import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installAgentContractHooks,
postJson,
@@ -16,6 +18,23 @@ import {
const state = getBrowserControlServerTestState();
const pwMocks = getPwMocks();
+async function withSymlinkPathEscape(params: {
+ rootDir: string;
+ run: (relativePath: string) => Promise;
+}): Promise {
+ const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-route-escape-"));
+ const linkName = `escape-link-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const linkPath = path.join(params.rootDir, linkName);
+ await fs.mkdir(params.rootDir, { recursive: true });
+ await fs.symlink(outsideDir, linkPath);
+ try {
+ return await params.run(`${linkName}/pwned.zip`);
+ } finally {
+ await fs.unlink(linkPath).catch(() => {});
+ await fs.rm(outsideDir, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
describe("browser control server", () => {
installAgentContractHooks();
@@ -268,6 +287,58 @@ describe("browser control server", () => {
expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled();
});
+ it.runIf(process.platform !== "win32")(
+ "trace stop rejects symlinked write path escape under trace dir",
+ async () => {
+ const base = await startServerAndBase();
+ await withSymlinkPathEscape({
+ rootDir: DEFAULT_TRACE_DIR,
+ run: async (pathEscape) => {
+ const res = await postJson<{ error?: string }>(`${base}/trace/stop`, {
+ path: pathEscape,
+ });
+ expect(res.error).toContain("Invalid path");
+ expect(pwMocks.traceStopViaPlaywright).not.toHaveBeenCalled();
+ },
+ });
+ },
+ );
+
+ it.runIf(process.platform !== "win32")(
+ "wait/download rejects symlinked write path escape under downloads dir",
+ async () => {
+ const base = await startServerAndBase();
+ await withSymlinkPathEscape({
+ rootDir: DEFAULT_DOWNLOAD_DIR,
+ run: async (pathEscape) => {
+ const res = await postJson<{ error?: string }>(`${base}/wait/download`, {
+ path: pathEscape,
+ });
+ expect(res.error).toContain("Invalid path");
+ expect(pwMocks.waitForDownloadViaPlaywright).not.toHaveBeenCalled();
+ },
+ });
+ },
+ );
+
+ it.runIf(process.platform !== "win32")(
+ "download rejects symlinked write path escape under downloads dir",
+ async () => {
+ const base = await startServerAndBase();
+ await withSymlinkPathEscape({
+ rootDir: DEFAULT_DOWNLOAD_DIR,
+ run: async (pathEscape) => {
+ const res = await postJson<{ error?: string }>(`${base}/download`, {
+ ref: "e12",
+ path: pathEscape,
+ });
+ expect(res.error).toContain("Invalid path");
+ expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled();
+ },
+ });
+ },
+ );
+
it("wait/download accepts in-root relative output path", async () => {
const base = await startServerAndBase();
const res = await postJson<{ ok?: boolean; download?: { path?: string } }>(
diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts
index b8ddc75308c..cd3fe62857d 100644
--- a/src/cli/nodes-cli.coverage.test.ts
+++ b/src/cli/nodes-cli.coverage.test.ts
@@ -83,6 +83,11 @@ describe("nodes-cli coverage", () => {
const getNodeInvokeCall = () =>
callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall;
+ const getApprovalRequestCall = () =>
+ callGateway.mock.calls.find((call) => call[0]?.method === "exec.approval.request")?.[0] as {
+ params?: Record;
+ };
+
const createNodesProgram = () => {
const program = new Command();
program.exitOverride();
@@ -140,6 +145,8 @@ describe("nodes-cli coverage", () => {
runId: expect.any(String),
});
expect(invoke?.params?.timeoutMs).toBe(5000);
+ const approval = getApprovalRequestCall();
+ expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
});
it("invokes system.run with raw command", async () => {
@@ -165,6 +172,8 @@ describe("nodes-cli coverage", () => {
approvalDecision: "allow-once",
runId: expect.any(String),
});
+ const approval = getApprovalRequestCall();
+ expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
});
it("invokes system.notify with provided fields", async () => {
diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts
index a53cc783041..e644d754d12 100644
--- a/src/cli/nodes-cli/register.invoke.ts
+++ b/src/cli/nodes-cli/register.invoke.ts
@@ -252,6 +252,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
{
id: approvalId,
command: rawCommand ?? argv.join(" "),
+ commandArgv: argv,
cwd: opts.cwd,
nodeId,
host: "node",
diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts
new file mode 100644
index 00000000000..f660a4e82a2
--- /dev/null
+++ b/src/commands/doctor-auth.hints.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { resolveUnusableProfileHint } from "./doctor-auth.js";
+
+describe("resolveUnusableProfileHint", () => {
+ it("returns billing guidance for disabled billing profiles", () => {
+ expect(resolveUnusableProfileHint({ kind: "disabled", reason: "billing" })).toBe(
+ "Top up credits (provider billing) or switch provider.",
+ );
+ });
+
+ it("returns credential guidance for permanent auth disables", () => {
+ expect(resolveUnusableProfileHint({ kind: "disabled", reason: "auth_permanent" })).toBe(
+ "Refresh or replace credentials, then retry.",
+ );
+ });
+
+ it("falls back to cooldown guidance for non-billing disable reasons", () => {
+ expect(resolveUnusableProfileHint({ kind: "disabled", reason: "unknown" })).toBe(
+ "Wait for cooldown or switch provider.",
+ );
+ });
+
+ it("returns cooldown guidance for cooldown windows", () => {
+ expect(resolveUnusableProfileHint({ kind: "cooldown" })).toBe(
+ "Wait for cooldown or switch provider.",
+ );
+ });
+});
diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts
index a12ab384a20..f408dc43f93 100644
--- a/src/commands/doctor-auth.ts
+++ b/src/commands/doctor-auth.ts
@@ -206,6 +206,21 @@ type AuthIssue = {
remainingMs?: number;
};
+export function resolveUnusableProfileHint(params: {
+ kind: "cooldown" | "disabled";
+ reason?: string;
+}): string {
+ if (params.kind === "disabled") {
+ if (params.reason === "billing") {
+ return "Top up credits (provider billing) or switch provider.";
+ }
+ if (params.reason === "auth_permanent" || params.reason === "auth") {
+ return "Refresh or replace credentials, then retry.";
+ }
+ }
+ return "Wait for cooldown or switch provider.";
+}
+
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand(
@@ -245,13 +260,14 @@ export async function noteAuthProfileHealth(params: {
}
const stats = store.usageStats?.[profileId];
const remaining = formatRemainingShort(until - now);
- const kind =
- typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
- ? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
- : "cooldown";
- const hint = kind.startsWith("disabled:billing")
- ? "Top up credits (provider billing) or switch provider."
- : "Wait for cooldown or switch provider.";
+ const disabledActive = typeof stats?.disabledUntil === "number" && now < stats.disabledUntil;
+ const kind = disabledActive
+ ? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
+ : "cooldown";
+ const hint = resolveUnusableProfileHint({
+ kind: disabledActive ? "disabled" : "cooldown",
+ reason: stats?.disabledReason,
+ });
out.push(`- ${profileId}: ${kind} (${remaining})${hint ? ` — ${hint}` : ""}`);
}
return out;
diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts
new file mode 100644
index 00000000000..55c5ef064f3
--- /dev/null
+++ b/src/commands/models/list.probe.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { mapFailoverReasonToProbeStatus } from "./list.probe.js";
+
+describe("mapFailoverReasonToProbeStatus", () => {
+ it("maps auth_permanent to auth", () => {
+ expect(mapFailoverReasonToProbeStatus("auth_permanent")).toBe("auth");
+ });
+
+ it("keeps existing failover reason mappings", () => {
+ expect(mapFailoverReasonToProbeStatus("auth")).toBe("auth");
+ expect(mapFailoverReasonToProbeStatus("rate_limit")).toBe("rate_limit");
+ expect(mapFailoverReasonToProbeStatus("billing")).toBe("billing");
+ expect(mapFailoverReasonToProbeStatus("timeout")).toBe("timeout");
+ expect(mapFailoverReasonToProbeStatus("format")).toBe("format");
+ });
+
+ it("falls back to unknown for unrecognized values", () => {
+ expect(mapFailoverReasonToProbeStatus(undefined)).toBe("unknown");
+ expect(mapFailoverReasonToProbeStatus(null)).toBe("unknown");
+ expect(mapFailoverReasonToProbeStatus("model_not_found")).toBe("unknown");
+ });
+});
diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts
index 60b38316117..ef48564df88 100644
--- a/src/commands/models/list.probe.ts
+++ b/src/commands/models/list.probe.ts
@@ -82,11 +82,13 @@ export type AuthProbeOptions = {
maxTokens: number;
};
-const toStatus = (reason?: string | null): AuthProbeStatus => {
+export function mapFailoverReasonToProbeStatus(reason?: string | null): AuthProbeStatus {
if (!reason) {
return "unknown";
}
- if (reason === "auth") {
+ if (reason === "auth" || reason === "auth_permanent") {
+ // Keep probe output backward-compatible: permanent auth failures still
+ // surface in the auth bucket instead of showing as unknown.
return "auth";
}
if (reason === "rate_limit") {
@@ -102,7 +104,7 @@ const toStatus = (reason?: string | null): AuthProbeStatus => {
return "format";
}
return "unknown";
-};
+}
function buildCandidateMap(modelCandidates: string[]): Map {
const map = new Map();
@@ -346,7 +348,7 @@ async function probeTarget(params: {
label: target.label,
source: target.source,
mode: target.mode,
- status: toStatus(described.reason),
+ status: mapFailoverReasonToProbeStatus(described.reason),
error: redactSecrets(described.message),
latencyMs: Date.now() - start,
};
diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts
index d9e6b3190e1..62584f138de 100644
--- a/src/config/config.plugin-validation.test.ts
+++ b/src/config/config.plugin-validation.test.ts
@@ -234,4 +234,32 @@ describe("config plugin validation", () => {
});
}
});
+
+ it("accepts heartbeat directPolicy enum values", async () => {
+ const home = await createCaseHome();
+ const res = validateInHome(home, {
+ agents: {
+ defaults: { heartbeat: { target: "last", directPolicy: "block" } },
+ list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }],
+ },
+ });
+ expect(res.ok).toBe(true);
+ });
+
+ it("rejects invalid heartbeat directPolicy values", async () => {
+ const home = await createCaseHome();
+ const res = validateInHome(home, {
+ agents: {
+ defaults: { heartbeat: { directPolicy: "maybe" } },
+ list: [{ id: "pi" }],
+ },
+ });
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ const hasIssue = res.issues.some(
+ (issue) => issue.path === "agents.defaults.heartbeat.directPolicy",
+ );
+ expect(hasIssue).toBe(true);
+ }
+ });
});
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index e5fcb3aa6b7..f32433e1333 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -330,7 +330,7 @@ export const FIELD_HELP: Record = {
"gateway.nodes.allowCommands":
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.",
"gateway.nodes.denyCommands":
- "Commands to block even if present in node claims or default allowlist.",
+ "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).",
nodeHost:
"Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.",
"nodeHost.browserProxy":
@@ -973,6 +973,8 @@ export const FIELD_HELP: Record = {
"Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.",
"session.typingMode":
'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.',
+ "session.parentForkMaxTokens":
+ "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.",
"session.mainKey":
'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.',
"session.sendPolicy":
@@ -1236,6 +1238,10 @@ export const FIELD_HELP: Record = {
"Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.",
"channels.defaults.heartbeat.useIndicator":
"Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.",
+ "agents.defaults.heartbeat.directPolicy":
+ 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.',
+ "agents.list.*.heartbeat.directPolicy":
+ 'Per-agent override for heartbeat direct/DM delivery policy; use "block" for agents that should only send heartbeat alerts to non-DM destinations.',
"channels.telegram.configWrites":
"Allow Telegram to write config in response to channel events/commands (default: true).",
"channels.telegram.botToken":
diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts
index 7a12e9293ba..8c0c6350d7b 100644
--- a/src/config/schema.labels.ts
+++ b/src/config/schema.labels.ts
@@ -402,6 +402,8 @@ export const FIELD_LABELS: Record = {
"Compaction Memory Flush Soft Threshold",
"agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt",
"agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt",
+ "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy",
+ "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy",
"agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
"agents.defaults.sandbox.browser.network": "Sandbox Browser Network",
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",
@@ -455,6 +457,7 @@ export const FIELD_LABELS: Record = {
"session.store": "Session Store Path",
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
"session.typingMode": "Session Typing Mode",
+ "session.parentForkMaxTokens": "Session Parent Fork Max Tokens",
"session.mainKey": "Session Main Key",
"session.sendPolicy": "Session Send Policy",
"session.sendPolicy.default": "Session Send Policy Default Action",
diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts
index e8eac685086..afc65e3daec 100644
--- a/src/config/types.agent-defaults.ts
+++ b/src/config/types.agent-defaults.ts
@@ -213,6 +213,8 @@ export type AgentDefaultsConfig = {
session?: string;
/** Delivery target ("last", "none", or a channel id). */
target?: "last" | "none" | ChannelId;
+ /** Direct/DM delivery policy. Default: "allow". */
+ directPolicy?: "allow" | "block";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). Supports :topic:NNN suffix for Telegram topics. */
to?: string;
/** Optional account id for multi-account channels. */
diff --git a/src/config/types.base.ts b/src/config/types.base.ts
index cb1b926b53f..676767fc901 100644
--- a/src/config/types.base.ts
+++ b/src/config/types.base.ts
@@ -112,6 +112,12 @@ export type SessionConfig = {
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;
+ /**
+ * Max parent transcript token count allowed for thread/session forking.
+ * If parent totalTokens is above this value, OpenClaw skips parent fork and
+ * starts a fresh thread session instead. Set to 0 to disable this guard.
+ */
+ parentForkMaxTokens?: number;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: {
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index c477cc1743b..9df0776b956 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -26,6 +26,7 @@ export const HeartbeatSchema = z
session: z.string().optional(),
includeReasoning: z.boolean().optional(),
target: z.string().optional(),
+ directPolicy: z.union([z.literal("allow"), z.literal("block")]).optional(),
to: z.string().optional(),
accountId: z.string().optional(),
prompt: z.string().optional(),
diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts
index 6efe8b39907..deb86999934 100644
--- a/src/config/zod-schema.session-maintenance-extensions.test.ts
+++ b/src/config/zod-schema.session-maintenance-extensions.test.ts
@@ -14,6 +14,19 @@ describe("SessionSchema maintenance extensions", () => {
).not.toThrow();
});
+ it("accepts parentForkMaxTokens including 0 to disable the guard", () => {
+ expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow();
+ expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow();
+ });
+
+ it("rejects negative parentForkMaxTokens", () => {
+ expect(() =>
+ SessionSchema.parse({
+ parentForkMaxTokens: -1,
+ }),
+ ).toThrow(/parentForkMaxTokens/i);
+ });
+
it("accepts disabling reset archive cleanup", () => {
expect(() =>
SessionSchema.parse({
diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts
index 5af707b2804..de23c50846e 100644
--- a/src/config/zod-schema.session.ts
+++ b/src/config/zod-schema.session.ts
@@ -52,6 +52,7 @@ export const SessionSchema = z
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: TypingModeSchema.optional(),
+ parentForkMaxTokens: z.number().int().nonnegative().optional(),
mainKey: z.string().optional(),
sendPolicy: SessionSendPolicySchema.optional(),
agentToAgent: z
diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts
index 6eaa5c66707..495e99d0039 100644
--- a/src/cron/delivery.test.ts
+++ b/src/cron/delivery.test.ts
@@ -54,4 +54,22 @@ describe("resolveCronDeliveryPlan", () => {
expect(plan.channel).toBeUndefined();
expect(plan.to).toBe("https://example.invalid/cron");
});
+
+ it("threads delivery.accountId when explicitly configured", () => {
+ const plan = resolveCronDeliveryPlan(
+ makeJob({
+ delivery: {
+ mode: "announce",
+ channel: "telegram",
+ to: "123",
+ accountId: " bot-a ",
+ },
+ }),
+ );
+ expect(plan.mode).toBe("announce");
+ expect(plan.requested).toBe(true);
+ expect(plan.channel).toBe("telegram");
+ expect(plan.to).toBe("123");
+ expect(plan.accountId).toBe("bot-a");
+ });
});
diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts
index 377cdb49b2f..9022d09fd5f 100644
--- a/src/cron/delivery.ts
+++ b/src/cron/delivery.ts
@@ -4,6 +4,7 @@ export type CronDeliveryPlan = {
mode: CronDeliveryMode;
channel?: CronMessageChannel;
to?: string;
+ accountId?: string;
source: "delivery" | "payload";
requested: boolean;
};
@@ -27,6 +28,14 @@ function normalizeTo(value: unknown): string | undefined {
return trimmed ? trimmed : undefined;
}
+function normalizeAccountId(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const payload = job.payload.kind === "agentTurn" ? job.payload : null;
const delivery = job.delivery;
@@ -50,6 +59,9 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
(delivery as { channel?: unknown } | undefined)?.channel,
);
const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to);
+ const deliveryAccountId = normalizeAccountId(
+ (delivery as { accountId?: unknown } | undefined)?.accountId,
+ );
const channel = deliveryChannel ?? payloadChannel ?? "last";
const to = deliveryTo ?? payloadTo;
@@ -59,6 +71,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
mode: resolvedMode,
channel: resolvedMode === "announce" ? channel : undefined,
to,
+ accountId: deliveryAccountId,
source: "delivery",
requested: resolvedMode === "announce",
};
diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
index 7d2dc3cf07a..01a407692e0 100644
--- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
+++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
@@ -56,6 +56,7 @@ async function expectBestEffortTelegramNotDelivered(
expect(res.status).toBe("ok");
expect(res.delivered).toBe(false);
+ expect(res.deliveryAttempted).toBe(true);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
});
@@ -287,6 +288,33 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
+ it("marks attempted when announce delivery reports false and best-effort is enabled", async () => {
+ await withTempCronHome(async (home) => {
+ const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
+ const deps = createCliDeps();
+ mockAgentPayloads([{ text: "hello from cron" }]);
+ vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
+
+ const res = await runTelegramAnnounceTurn({
+ home,
+ storePath,
+ deps,
+ delivery: {
+ mode: "announce",
+ channel: "telegram",
+ to: "123",
+ bestEffort: true,
+ },
+ });
+
+ expect(res.status).toBe("ok");
+ expect(res.delivered).toBe(false);
+ expect(res.deliveryAttempted).toBe(true);
+ expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
+ expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
+ });
+ });
+
it("ignores structured direct delivery failures when best-effort is enabled", async () => {
await expectBestEffortTelegramNotDelivered({
text: "hello from cron",
diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts
index 697c0e2b8a8..1feae211df8 100644
--- a/src/cron/isolated-agent/delivery-dispatch.ts
+++ b/src/cron/isolated-agent/delivery-dispatch.ts
@@ -117,6 +117,7 @@ type DispatchCronDeliveryParams = {
export type DispatchCronDeliveryState = {
result?: RunCronAgentTurnResult;
delivered: boolean;
+ deliveryAttempted: boolean;
summary?: string;
outputText?: string;
synthesizedText?: string;
@@ -134,6 +135,7 @@ export async function dispatchCronDelivery(
// `true` means we confirmed at least one outbound send reached the target.
// Keep this strict so timer fallback can safely decide whether to wake main.
let delivered = params.skipMessagingToolDelivery;
+ let deliveryAttempted = params.skipMessagingToolDelivery;
const failDeliveryTarget = (error: string) =>
params.withRunSession({
status: "error",
@@ -141,6 +143,7 @@ export async function dispatchCronDelivery(
errorKind: "delivery-target",
summary,
outputText,
+ deliveryAttempted,
...params.telemetry,
});
@@ -162,9 +165,11 @@ export async function dispatchCronDelivery(
return params.withRunSession({
status: "error",
error: params.abortReason(),
+ deliveryAttempted,
...params.telemetry,
});
}
+ deliveryAttempted = true;
const deliveryResults = await deliverOutboundPayloads({
cfg: params.cfgWithAgentDefaults,
channel: delivery.channel,
@@ -187,6 +192,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: String(err),
+ deliveryAttempted,
...params.telemetry,
});
}
@@ -277,9 +283,11 @@ export async function dispatchCronDelivery(
return params.withRunSession({
status: "error",
error: params.abortReason(),
+ deliveryAttempted,
...params.telemetry,
});
}
+ deliveryAttempted = true;
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: params.agentSessionKey,
childRunId: `${params.job.id}:${params.runSessionId}:${params.runStartedAt}`,
@@ -315,6 +323,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: message,
+ deliveryAttempted,
...params.telemetry,
});
}
@@ -327,6 +336,7 @@ export async function dispatchCronDelivery(
summary,
outputText,
error: String(err),
+ deliveryAttempted,
...params.telemetry,
});
}
@@ -345,6 +355,7 @@ export async function dispatchCronDelivery(
return {
result: failDeliveryTarget(params.resolvedDelivery.error.message),
delivered,
+ deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -357,9 +368,11 @@ export async function dispatchCronDelivery(
status: "ok",
summary,
outputText,
+ deliveryAttempted,
...params.telemetry,
}),
delivered,
+ deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -383,6 +396,7 @@ export async function dispatchCronDelivery(
return {
result: directResult,
delivered,
+ deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -395,6 +409,7 @@ export async function dispatchCronDelivery(
return {
result: announceResult,
delivered,
+ deliveryAttempted,
summary,
outputText,
synthesizedText,
@@ -406,6 +421,7 @@ export async function dispatchCronDelivery(
return {
delivered,
+ deliveryAttempted,
summary,
outputText,
synthesizedText,
diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts
index ad1df42bb47..b28239adda8 100644
--- a/src/cron/isolated-agent/delivery-target.test.ts
+++ b/src/cron/isolated-agent/delivery-target.test.ts
@@ -299,4 +299,39 @@ describe("resolveDeliveryTarget", () => {
expect(result.to).toBe("987654");
expect(result.ok).toBe(true);
});
+
+ it("explicit delivery.accountId overrides session-derived accountId", async () => {
+ setMainSessionEntry({
+ sessionId: "sess-5",
+ updatedAt: 1000,
+ lastChannel: "telegram",
+ lastTo: "chat-999",
+ lastAccountId: "default",
+ });
+
+ const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
+ channel: "telegram",
+ to: "chat-999",
+ accountId: "bot-b",
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.accountId).toBe("bot-b");
+ });
+
+ it("explicit delivery.accountId overrides bindings-derived accountId", async () => {
+ setMainSessionEntry(undefined);
+ const cfg = makeCfg({
+ bindings: [{ agentId: AGENT_ID, match: { channel: "telegram", accountId: "bound" } }],
+ });
+
+ const result = await resolveDeliveryTarget(cfg, AGENT_ID, {
+ channel: "telegram",
+ to: "chat-777",
+ accountId: "explicit",
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.accountId).toBe("explicit");
+ });
});
diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts
index 0aa26188120..1af69ee027a 100644
--- a/src/cron/isolated-agent/delivery-target.ts
+++ b/src/cron/isolated-agent/delivery-target.ts
@@ -43,6 +43,7 @@ export async function resolveDeliveryTarget(
channel?: "last" | ChannelId;
to?: string;
sessionKey?: string;
+ accountId?: string;
},
): Promise {
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
@@ -114,6 +115,11 @@ export async function resolveDeliveryTarget(
}
}
+ // Explicit delivery account should override inferred session/binding account.
+ if (jobPayload.accountId) {
+ accountId = jobPayload.accountId;
+ }
+
// Carry threadId when it was explicitly set (from :topic: parsing or config)
// or when delivering to the same recipient as the session's last conversation.
// Session-derived threadIds are dropped when the target differs to prevent
diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts
index 02d986819d9..2b6e4bbf7be 100644
--- a/src/cron/isolated-agent/run.skill-filter.test.ts
+++ b/src/cron/isolated-agent/run.skill-filter.test.ts
@@ -6,6 +6,13 @@ import { runWithModelFallback } from "../../agents/model-fallback.js";
const buildWorkspaceSkillSnapshotMock = vi.fn();
const resolveAgentConfigMock = vi.fn();
const resolveAgentSkillsFilterMock = vi.fn();
+const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false });
+const isCliProviderMock = vi.fn().mockReturnValue(false);
+const resolveAllowedModelRefMock = vi.fn();
+const resolveConfiguredModelRefMock = vi.fn();
+const resolveHooksGmailModelMock = vi.fn();
+const resolveThinkingDefaultMock = vi.fn();
+const logWarnMock = vi.fn();
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: resolveAgentConfigMock,
@@ -36,14 +43,12 @@ vi.mock("../../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
- isCliProvider: vi.fn().mockReturnValue(false),
- resolveAllowedModelRef: vi
- .fn()
- .mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }),
- resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }),
- resolveHooksGmailModel: vi.fn().mockReturnValue(null),
- resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
+ getModelRefStatus: getModelRefStatusMock,
+ isCliProvider: isCliProviderMock,
+ resolveAllowedModelRef: resolveAllowedModelRefMock,
+ resolveConfiguredModelRef: resolveConfiguredModelRefMock,
+ resolveHooksGmailModel: resolveHooksGmailModelMock,
+ resolveThinkingDefault: resolveThinkingDefaultMock,
};
});
@@ -138,7 +143,7 @@ vi.mock("../../infra/skills-remote.js", () => ({
}));
vi.mock("../../logger.js", () => ({
- logWarn: vi.fn(),
+ logWarn: (...args: unknown[]) => logWarnMock(...args),
}));
vi.mock("../../security/external-content.js", () => ({
@@ -222,6 +227,13 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
resolveAgentConfigMock.mockReturnValue(undefined);
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
+ resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
+ resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
+ resolveHooksGmailModelMock.mockReturnValue(null);
+ resolveThinkingDefaultMock.mockReturnValue(undefined);
+ getModelRefStatusMock.mockReturnValue({ allowed: false });
+ isCliProviderMock.mockReturnValue(false);
+ logWarnMock.mockReset();
// Fresh session object per test — prevents mutation leaking between tests
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
@@ -408,5 +420,78 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
it("preserves defaults when agent overrides primary in object form", async () => {
await expectPrimaryOverridePreservesDefaults({ primary: "anthropic/claude-sonnet-4-5" });
});
+
+ it("applies payload.model override when model is allowed", async () => {
+ resolveAllowedModelRefMock.mockReturnValueOnce({
+ ref: { provider: "anthropic", model: "claude-sonnet-4-6" },
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(logWarnMock).not.toHaveBeenCalled();
+ expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
+ const runParams = runWithModelFallbackMock.mock.calls[0][0];
+ expect(runParams.provider).toBe("anthropic");
+ expect(runParams.model).toBe("claude-sonnet-4-6");
+ });
+
+ it("falls back to agent defaults when payload.model is not allowed", async () => {
+ resolveAllowedModelRefMock.mockReturnValueOnce({
+ error: "model not allowed: anthropic/claude-sonnet-4-6",
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ cfg: {
+ agents: {
+ defaults: {
+ model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
+ },
+ },
+ },
+ job: makeJob({
+ payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(logWarnMock).toHaveBeenCalledWith(
+ "cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults",
+ );
+ expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
+ const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
+ const model = callCfg?.agents?.defaults?.model as
+ | { primary?: string; fallbacks?: string[] }
+ | undefined;
+ expect(model?.primary).toBe("openai-codex/gpt-5.3-codex");
+ expect(model?.fallbacks).toEqual(defaultFallbacks);
+ });
+
+ it("returns an error when payload.model is invalid", async () => {
+ resolveAllowedModelRefMock.mockReturnValueOnce({
+ error: "invalid model: openai/",
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: { kind: "agentTurn", message: "test", model: "openai/" },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("error");
+ expect(result.error).toBe("invalid model: openai/");
+ expect(logWarnMock).not.toHaveBeenCalled();
+ expect(runWithModelFallbackMock).not.toHaveBeenCalled();
+ });
});
});
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index dd5c28ae616..10b8b5c7414 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -77,6 +77,12 @@ export type RunCronAgentTurnResult = {
* messages. See: https://github.com/openclaw/openclaw/issues/15692
*/
delivered?: boolean;
+ /**
+ * `true` when cron attempted announce/direct delivery for this run.
+ * This is tracked separately from `delivered` because some announce paths
+ * cannot guarantee a final delivery ack synchronously.
+ */
+ deliveryAttempted?: boolean;
} & CronRunOutcome &
CronRunTelemetry;
@@ -198,10 +204,17 @@ export async function runCronIsolatedAgentTurn(params: {
defaultModel: resolvedDefault.model,
});
if ("error" in resolvedOverride) {
- return { status: "error", error: resolvedOverride.error };
+ if (resolvedOverride.error.startsWith("model not allowed:")) {
+ logWarn(
+ `cron: payload.model '${modelOverride}' not allowed, falling back to agent defaults`,
+ );
+ } else {
+ return { status: "error", error: resolvedOverride.error };
+ }
+ } else {
+ provider = resolvedOverride.ref.provider;
+ model = resolvedOverride.ref.model;
}
- provider = resolvedOverride.ref.provider;
- model = resolvedOverride.ref.model;
}
const now = Date.now();
const cronSession = resolveCronSession({
@@ -307,6 +320,7 @@ export async function runCronIsolatedAgentTurn(params: {
channel: deliveryPlan.channel ?? "last",
to: deliveryPlan.to,
sessionKey: params.job.sessionKey,
+ accountId: deliveryPlan.accountId,
});
const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now);
@@ -557,7 +571,7 @@ export async function runCronIsolatedAgentTurn(params: {
const embeddedRunError = hasErrorPayload
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
: undefined;
- const resolveRunOutcome = (params?: { delivered?: boolean }) =>
+ const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) =>
withRunSession({
status: hasErrorPayload ? "error" : "ok",
...(hasErrorPayload
@@ -566,6 +580,7 @@ export async function runCronIsolatedAgentTurn(params: {
summary,
outputText,
delivered: params?.delivered,
+ deliveryAttempted: params?.deliveryAttempted,
...telemetry,
});
@@ -611,14 +626,23 @@ export async function runCronIsolatedAgentTurn(params: {
withRunSession,
});
if (deliveryResult.result) {
+ const resultWithDeliveryMeta: RunCronAgentTurnResult = {
+ ...deliveryResult.result,
+ deliveryAttempted:
+ deliveryResult.result.deliveryAttempted ?? deliveryResult.deliveryAttempted,
+ };
if (!hasErrorPayload || deliveryResult.result.status !== "ok") {
- return deliveryResult.result;
+ return resultWithDeliveryMeta;
}
- return resolveRunOutcome({ delivered: deliveryResult.result.delivered });
+ return resolveRunOutcome({
+ delivered: deliveryResult.result.delivered,
+ deliveryAttempted: resultWithDeliveryMeta.deliveryAttempted,
+ });
}
const delivered = deliveryResult.delivered;
+ const deliveryAttempted = deliveryResult.deliveryAttempted;
summary = deliveryResult.summary;
outputText = deliveryResult.outputText;
- return resolveRunOutcome({ delivered });
+ return resolveRunOutcome({ delivered, deliveryAttempted });
}
diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts
index 027a464357d..37079addef0 100644
--- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts
+++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts
@@ -625,6 +625,28 @@ describe("CronService", () => {
await store.cleanup();
});
+ it("does not post isolated summary to main when announce delivery was attempted", async () => {
+ const runIsolatedAgentJob = vi.fn(async () => ({
+ status: "ok" as const,
+ summary: "done",
+ delivered: false,
+ deliveryAttempted: true,
+ }));
+ const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
+ await createIsolatedAnnounceHarness(runIsolatedAgentJob);
+ await runIsolatedAnnounceJobAndWait({
+ cron,
+ events,
+ name: "weekly attempted",
+ status: "ok",
+ });
+ expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
+ expect(requestHeartbeatNow).not.toHaveBeenCalled();
+ cron.stop();
+ await store.cleanup();
+ });
+
it("migrates legacy payload.provider to payload.channel on load", async () => {
const rawJob = createLegacyDeliveryMigrationJob({
id: "legacy-1",
diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts
index 19b139b3703..3ad9cc1f591 100644
--- a/src/cron/service/state.ts
+++ b/src/cron/service/state.ts
@@ -80,6 +80,11 @@ export type CronServiceDeps = {
* https://github.com/openclaw/openclaw/issues/15692
*/
delivered?: boolean;
+ /**
+ * `true` when announce/direct delivery was attempted for this run, even
+ * if the final per-message ack status is uncertain.
+ */
+ deliveryAttempted?: boolean;
} & CronRunOutcome &
CronRunTelemetry
>;
diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts
index 34cdab97f5a..acb3f3037d3 100644
--- a/src/cron/service/timer.ts
+++ b/src/cron/service/timer.ts
@@ -41,6 +41,7 @@ type TimedCronRunOutcome = CronRunOutcome &
CronRunTelemetry & {
jobId: string;
delivered?: boolean;
+ deliveryAttempted?: boolean;
startedAt: number;
endedAt: number;
};
@@ -606,7 +607,9 @@ export async function executeJobCore(
state: CronServiceState,
job: CronJob,
abortSignal?: AbortSignal,
-): Promise {
+): Promise<
+ CronRunOutcome & CronRunTelemetry & { delivered?: boolean; deliveryAttempted?: boolean }
+> {
const resolveAbortError = () => ({
status: "error" as const,
error: timeoutErrorMessage(),
@@ -729,17 +732,22 @@ export async function executeJobCore(
return { status: "error", error: timeoutErrorMessage() };
}
- // Post a short summary back to the main session — but only when the
- // isolated run did NOT already deliver its output to the target channel.
- // When `res.delivered` is true the announce flow (or direct outbound
- // delivery) already sent the result, so posting the summary to main
- // would wake the main agent and cause a duplicate message.
+ // Post a short summary back to the main session only when announce
+ // delivery was requested and we are confident no outbound delivery path
+ // ran. If delivery was attempted but final ack is uncertain, suppress the
+ // main summary to avoid duplicate user-facing sends.
// See: https://github.com/openclaw/openclaw/issues/15692
const summaryText = res.summary?.trim();
const deliveryPlan = resolveCronDeliveryPlan(job);
const suppressMainSummary =
res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested;
- if (summaryText && deliveryPlan.requested && !res.delivered && !suppressMainSummary) {
+ if (
+ summaryText &&
+ deliveryPlan.requested &&
+ !res.delivered &&
+ res.deliveryAttempted !== true &&
+ !suppressMainSummary
+ ) {
const prefix = "Cron";
const label =
res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
@@ -762,6 +770,7 @@ export async function executeJobCore(
error: res.error,
summary: res.summary,
delivered: res.delivered,
+ deliveryAttempted: res.deliveryAttempted,
sessionId: res.sessionId,
sessionKey: res.sessionKey,
model: res.model,
diff --git a/src/cron/types.ts b/src/cron/types.ts
index 837cba2168e..4480b22ae6b 100644
--- a/src/cron/types.ts
+++ b/src/cron/types.ts
@@ -22,6 +22,7 @@ export type CronDelivery = {
mode: CronDeliveryMode;
channel?: CronMessageChannel;
to?: string;
+ accountId?: string;
bestEffort?: boolean;
};
diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts
index 222911894a9..4e185d96574 100644
--- a/src/discord/monitor.test.ts
+++ b/src/discord/monitor.test.ts
@@ -1,5 +1,5 @@
import { ChannelType, type Guild } from "@buape/carbon";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { typedCases } from "../test-utils/typed-cases.js";
import {
allowListMatches,
@@ -20,6 +20,12 @@ import {
} from "./monitor.js";
import { DiscordMessageListener, DiscordReactionListener } from "./monitor/listeners.js";
+const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
+
+vi.mock("../pairing/pairing-store.js", () => ({
+ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
+}));
+
const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild;
const makeEntries = (
@@ -899,6 +905,12 @@ function makeReactionClient(options?: {
function makeReactionListenerParams(overrides?: {
botUserId?: string;
+ dmEnabled?: boolean;
+ groupDmEnabled?: boolean;
+ groupDmChannels?: string[];
+ dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
+ allowFrom?: string[];
+ groupPolicy?: "open" | "allowlist" | "disabled";
allowNameMatching?: boolean;
guildEntries?: Record;
}) {
@@ -907,6 +919,12 @@ function makeReactionListenerParams(overrides?: {
accountId: "acc-1",
runtime: {} as import("../runtime.js").RuntimeEnv,
botUserId: overrides?.botUserId ?? "bot-1",
+ dmEnabled: overrides?.dmEnabled ?? true,
+ groupDmEnabled: overrides?.groupDmEnabled ?? true,
+ groupDmChannels: overrides?.groupDmChannels ?? [],
+ dmPolicy: overrides?.dmPolicy ?? "open",
+ allowFrom: overrides?.allowFrom ?? [],
+ groupPolicy: overrides?.groupPolicy ?? "open",
allowNameMatching: overrides?.allowNameMatching ?? false,
guildEntries: overrides?.guildEntries,
logger: {
@@ -919,6 +937,12 @@ function makeReactionListenerParams(overrides?: {
}
describe("discord DM reaction handling", () => {
+ beforeEach(() => {
+ enqueueSystemEventSpy.mockClear();
+ resolveAgentRouteMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ });
+
it("processes DM reactions with or without guild allowlists", async () => {
const cases = [
{ name: "no guild allowlist", guildEntries: undefined },
@@ -952,9 +976,77 @@ describe("discord DM reaction handling", () => {
}
});
+ it("blocks DM reactions when dmPolicy is disabled", async () => {
+ const data = makeReactionEvent({ botAsAuthor: true });
+ const client = makeReactionClient({ channelType: ChannelType.DM });
+ const listener = new DiscordReactionListener(
+ makeReactionListenerParams({ dmPolicy: "disabled" }),
+ );
+
+ await listener.handle(data, client);
+
+ expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
+ });
+
+ it("blocks DM reactions for unauthorized sender in allowlist mode", async () => {
+ const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
+ const client = makeReactionClient({ channelType: ChannelType.DM });
+ const listener = new DiscordReactionListener(
+ makeReactionListenerParams({
+ dmPolicy: "allowlist",
+ allowFrom: ["user:user-2"],
+ }),
+ );
+
+ await listener.handle(data, client);
+
+ expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
+ });
+
+ it("allows DM reactions for authorized sender in allowlist mode", async () => {
+ const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
+ const client = makeReactionClient({ channelType: ChannelType.DM });
+ const listener = new DiscordReactionListener(
+ makeReactionListenerParams({
+ dmPolicy: "allowlist",
+ allowFrom: ["user:user-1"],
+ }),
+ );
+
+ await listener.handle(data, client);
+
+ expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("blocks group DM reactions when group DMs are disabled", async () => {
+ const data = makeReactionEvent({ botAsAuthor: true });
+ const client = makeReactionClient({ channelType: ChannelType.GroupDM });
+ const listener = new DiscordReactionListener(
+ makeReactionListenerParams({ groupDmEnabled: false }),
+ );
+
+ await listener.handle(data, client);
+
+ expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
+ });
+
+ it("blocks guild reactions when groupPolicy is disabled", async () => {
+ const data = makeReactionEvent({
+ guildId: "guild-123",
+ botAsAuthor: true,
+ guild: { id: "guild-123", name: "Guild" },
+ });
+ const client = makeReactionClient({ channelType: ChannelType.GuildText });
+ const listener = new DiscordReactionListener(
+ makeReactionListenerParams({ groupPolicy: "disabled" }),
+ );
+
+ await listener.handle(data, client);
+
+ expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
+ });
+
it("still processes guild reactions (no regression)", async () => {
- enqueueSystemEventSpy.mockClear();
- resolveAgentRouteMock.mockClear();
resolveAgentRouteMock.mockReturnValueOnce({
agentId: "default",
channel: "discord",
diff --git a/src/discord/monitor/gateway-error-guard.test.ts b/src/discord/monitor/gateway-error-guard.test.ts
new file mode 100644
index 00000000000..783fcc6a712
--- /dev/null
+++ b/src/discord/monitor/gateway-error-guard.test.ts
@@ -0,0 +1,33 @@
+import { EventEmitter } from "node:events";
+import { describe, expect, it, vi } from "vitest";
+import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
+
+describe("attachEarlyGatewayErrorGuard", () => {
+ it("captures gateway errors until released", () => {
+ const emitter = new EventEmitter();
+ const fallbackErrorListener = vi.fn();
+ emitter.on("error", fallbackErrorListener);
+ const client = {
+ getPlugin: vi.fn(() => ({ emitter })),
+ };
+
+ const guard = attachEarlyGatewayErrorGuard(client as never);
+ emitter.emit("error", new Error("Fatal Gateway error: 4014"));
+ expect(guard.pendingErrors).toHaveLength(1);
+
+ guard.release();
+ emitter.emit("error", new Error("Fatal Gateway error: 4000"));
+ expect(guard.pendingErrors).toHaveLength(1);
+ expect(fallbackErrorListener).toHaveBeenCalledTimes(2);
+ });
+
+ it("returns noop guard when gateway emitter is unavailable", () => {
+ const client = {
+ getPlugin: vi.fn(() => undefined),
+ };
+
+ const guard = attachEarlyGatewayErrorGuard(client as never);
+ expect(guard.pendingErrors).toEqual([]);
+ expect(() => guard.release()).not.toThrow();
+ });
+});
diff --git a/src/discord/monitor/gateway-error-guard.ts b/src/discord/monitor/gateway-error-guard.ts
new file mode 100644
index 00000000000..5cb79753325
--- /dev/null
+++ b/src/discord/monitor/gateway-error-guard.ts
@@ -0,0 +1,36 @@
+import type { Client } from "@buape/carbon";
+import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
+
+export type EarlyGatewayErrorGuard = {
+ pendingErrors: unknown[];
+ release: () => void;
+};
+
+export function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard {
+ const pendingErrors: unknown[] = [];
+ const gateway = client.getPlugin("gateway");
+ const emitter = getDiscordGatewayEmitter(gateway);
+ if (!emitter) {
+ return {
+ pendingErrors,
+ release: () => {},
+ };
+ }
+
+ let released = false;
+ const onGatewayError = (err: unknown) => {
+ pendingErrors.push(err);
+ };
+ emitter.on("error", onGatewayError);
+
+ return {
+ pendingErrors,
+ release: () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ emitter.removeListener("error", onGatewayError);
+ },
+ };
+}
diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts
index 9bdc7331224..c8af895ad25 100644
--- a/src/discord/monitor/listeners.ts
+++ b/src/discord/monitor/listeners.ts
@@ -7,14 +7,20 @@ import {
PresenceUpdateListener,
type User,
} from "@buape/carbon";
-import { danger } from "../../globals.js";
+import { danger, logVerbose } from "../../globals.js";
import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
+import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
+import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js";
import {
+ isDiscordGroupAllowedByPolicy,
+ normalizeDiscordAllowList,
normalizeDiscordSlug,
+ resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
+ resolveGroupDmAllow,
resolveDiscordGuildEntry,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
@@ -37,6 +43,12 @@ type DiscordReactionListenerParams = {
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
+ dmEnabled: boolean;
+ groupDmEnabled: boolean;
+ groupDmChannels: string[];
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
+ allowFrom: string[];
+ groupPolicy: "open" | "allowlist" | "disabled";
allowNameMatching: boolean;
guildEntries?: Record;
logger: Logger;
@@ -179,6 +191,12 @@ async function runDiscordReactionHandler(params: {
cfg: params.handlerParams.cfg,
accountId: params.handlerParams.accountId,
botUserId: params.handlerParams.botUserId,
+ dmEnabled: params.handlerParams.dmEnabled,
+ groupDmEnabled: params.handlerParams.groupDmEnabled,
+ groupDmChannels: params.handlerParams.groupDmChannels,
+ dmPolicy: params.handlerParams.dmPolicy,
+ allowFrom: params.handlerParams.allowFrom,
+ groupPolicy: params.handlerParams.groupPolicy,
allowNameMatching: params.handlerParams.allowNameMatching,
guildEntries: params.handlerParams.guildEntries,
logger: params.handlerParams.logger,
@@ -186,6 +204,99 @@ async function runDiscordReactionHandler(params: {
});
}
+type DiscordReactionIngressAuthorizationParams = {
+ user: User;
+ isDirectMessage: boolean;
+ isGroupDm: boolean;
+ isGuildMessage: boolean;
+ channelId: string;
+ channelName?: string;
+ channelSlug: string;
+ dmEnabled: boolean;
+ groupDmEnabled: boolean;
+ groupDmChannels: string[];
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
+ allowFrom: string[];
+ groupPolicy: "open" | "allowlist" | "disabled";
+ allowNameMatching: boolean;
+ guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null;
+ channelConfig?: { allowed?: boolean } | null;
+};
+
+async function authorizeDiscordReactionIngress(
+ params: DiscordReactionIngressAuthorizationParams,
+): Promise<{ allowed: true } | { allowed: false; reason: string }> {
+ if (params.isDirectMessage && !params.dmEnabled) {
+ return { allowed: false, reason: "dm-disabled" };
+ }
+ if (params.isGroupDm && !params.groupDmEnabled) {
+ return { allowed: false, reason: "group-dm-disabled" };
+ }
+ if (params.isDirectMessage) {
+ const storeAllowFrom =
+ params.dmPolicy === "allowlist"
+ ? []
+ : await readChannelAllowFromStore("discord").catch(() => []);
+ const access = resolveDmGroupAccessWithLists({
+ isGroup: false,
+ dmPolicy: params.dmPolicy,
+ groupPolicy: params.groupPolicy,
+ allowFrom: params.allowFrom,
+ groupAllowFrom: [],
+ storeAllowFrom,
+ isSenderAllowed: (allowEntries) => {
+ const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]);
+ const allowMatch = allowList
+ ? resolveDiscordAllowListMatch({
+ allowList,
+ candidate: {
+ id: params.user.id,
+ name: params.user.username,
+ tag: formatDiscordUserTag(params.user),
+ },
+ allowNameMatching: params.allowNameMatching,
+ })
+ : { allowed: false };
+ return allowMatch.allowed;
+ },
+ });
+ if (access.decision !== "allow") {
+ return { allowed: false, reason: access.reason };
+ }
+ }
+ if (
+ params.isGroupDm &&
+ !resolveGroupDmAllow({
+ channels: params.groupDmChannels,
+ channelId: params.channelId,
+ channelName: params.channelName,
+ channelSlug: params.channelSlug,
+ })
+ ) {
+ return { allowed: false, reason: "group-dm-not-allowlisted" };
+ }
+ if (!params.isGuildMessage) {
+ return { allowed: true };
+ }
+ const channelAllowlistConfigured =
+ Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0;
+ const channelAllowed = params.channelConfig?.allowed !== false;
+ if (
+ !isDiscordGroupAllowedByPolicy({
+ groupPolicy: params.groupPolicy,
+ guildAllowlisted: Boolean(params.guildInfo),
+ channelAllowlistConfigured,
+ channelAllowed,
+ })
+ ) {
+ return { allowed: false, reason: "guild-policy" };
+ }
+ if (params.channelConfig?.allowed === false) {
+ return { allowed: false, reason: "guild-channel-denied" };
+ }
+ return { allowed: true };
+}
+
async function handleDiscordReactionEvent(params: {
data: DiscordReactionEvent;
client: Client;
@@ -193,6 +304,12 @@ async function handleDiscordReactionEvent(params: {
cfg: LoadedConfig;
accountId: string;
botUserId?: string;
+ dmEnabled: boolean;
+ groupDmEnabled: boolean;
+ groupDmChannels: string[];
+ dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
+ allowFrom: string[];
+ groupPolicy: "open" | "allowlist" | "disabled";
allowNameMatching: boolean;
guildEntries?: Record;
logger: Logger;
@@ -236,6 +353,27 @@ async function handleDiscordReactionEvent(params: {
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
+ const ingressAccess = await authorizeDiscordReactionIngress({
+ user,
+ isDirectMessage,
+ isGroupDm,
+ isGuildMessage,
+ channelId: data.channel_id,
+ channelName,
+ channelSlug,
+ dmEnabled: params.dmEnabled,
+ groupDmEnabled: params.groupDmEnabled,
+ groupDmChannels: params.groupDmChannels,
+ dmPolicy: params.dmPolicy,
+ allowFrom: params.allowFrom,
+ groupPolicy: params.groupPolicy,
+ allowNameMatching: params.allowNameMatching,
+ guildInfo,
+ });
+ if (!ingressAccess.allowed) {
+ logVerbose(`discord reaction blocked sender=${user.id} (reason=${ingressAccess.reason})`);
+ return;
+ }
let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
let parentName: string | undefined;
let parentSlug = "";
@@ -343,7 +481,25 @@ async function handleDiscordReactionEvent(params: {
await loadThreadParentInfo();
const channelConfig = resolveThreadChannelConfig();
- if (channelConfig?.allowed === false) {
+ const threadAccess = await authorizeDiscordReactionIngress({
+ user,
+ isDirectMessage,
+ isGroupDm,
+ isGuildMessage,
+ channelId: data.channel_id,
+ channelName,
+ channelSlug,
+ dmEnabled: params.dmEnabled,
+ groupDmEnabled: params.groupDmEnabled,
+ groupDmChannels: params.groupDmChannels,
+ dmPolicy: params.dmPolicy,
+ allowFrom: params.allowFrom,
+ groupPolicy: params.groupPolicy,
+ allowNameMatching: params.allowNameMatching,
+ guildInfo,
+ channelConfig,
+ });
+ if (!threadAccess.allowed) {
return;
}
@@ -367,7 +523,25 @@ async function handleDiscordReactionEvent(params: {
await loadThreadParentInfo();
const channelConfig = resolveThreadChannelConfig();
- if (channelConfig?.allowed === false) {
+ const threadAccess = await authorizeDiscordReactionIngress({
+ user,
+ isDirectMessage,
+ isGroupDm,
+ isGuildMessage,
+ channelId: data.channel_id,
+ channelName,
+ channelSlug,
+ dmEnabled: params.dmEnabled,
+ groupDmEnabled: params.groupDmEnabled,
+ groupDmChannels: params.groupDmChannels,
+ dmPolicy: params.dmPolicy,
+ allowFrom: params.allowFrom,
+ groupPolicy: params.groupPolicy,
+ allowNameMatching: params.allowNameMatching,
+ guildInfo,
+ channelConfig,
+ });
+ if (!threadAccess.allowed) {
return;
}
@@ -391,8 +565,28 @@ async function handleDiscordReactionEvent(params: {
parentSlug,
scope: "channel",
});
- if (channelConfig?.allowed === false) {
- return;
+ if (isGuildMessage) {
+ const channelAccess = await authorizeDiscordReactionIngress({
+ user,
+ isDirectMessage,
+ isGroupDm,
+ isGuildMessage,
+ channelId: data.channel_id,
+ channelName,
+ channelSlug,
+ dmEnabled: params.dmEnabled,
+ groupDmEnabled: params.groupDmEnabled,
+ groupDmChannels: params.groupDmChannels,
+ dmPolicy: params.dmPolicy,
+ allowFrom: params.allowFrom,
+ groupPolicy: params.groupPolicy,
+ allowNameMatching: params.allowNameMatching,
+ guildInfo,
+ channelConfig,
+ });
+ if (!channelAccess.allowed) {
+ return;
+ }
}
const reactionMode = guildInfo?.reactionNotifications ?? "own";
diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts
index de8976ce5d2..28dd142a1e4 100644
--- a/src/discord/monitor/message-utils.test.ts
+++ b/src/discord/monitor/message-utils.test.ts
@@ -323,6 +323,78 @@ describe("resolveDiscordMessageText", () => {
expect(text).toBe(" (1 sticker)");
});
+
+ it("uses embed title when content is empty", () => {
+ const text = resolveDiscordMessageText(
+ asMessage({
+ content: "",
+ embeds: [{ title: "Breaking" }],
+ }),
+ );
+
+ expect(text).toBe("Breaking");
+ });
+
+ it("uses embed description when content is empty", () => {
+ const text = resolveDiscordMessageText(
+ asMessage({
+ content: "",
+ embeds: [{ description: "Details" }],
+ }),
+ );
+
+ expect(text).toBe("Details");
+ });
+
+ it("joins embed title and description when content is empty", () => {
+ const text = resolveDiscordMessageText(
+ asMessage({
+ content: "",
+ embeds: [{ title: "Breaking", description: "Details" }],
+ }),
+ );
+
+ expect(text).toBe("Breaking\nDetails");
+ });
+
+ it("prefers message content over embed fallback text", () => {
+ const text = resolveDiscordMessageText(
+ asMessage({
+ content: "hello from content",
+ embeds: [{ title: "Breaking", description: "Details" }],
+ }),
+ );
+
+ expect(text).toBe("hello from content");
+ });
+
+ it("joins forwarded snapshot embed title and description when content is empty", () => {
+ const text = resolveDiscordMessageText(
+ asMessage({
+ content: "",
+ rawData: {
+ message_snapshots: [
+ {
+ message: {
+ content: "",
+ embeds: [{ title: "Forwarded title", description: "Forwarded details" }],
+ attachments: [],
+ author: {
+ id: "u2",
+ username: "Bob",
+ discriminator: "0",
+ },
+ },
+ },
+ ],
+ },
+ }),
+ { includeForwarded: true },
+ );
+
+ expect(text).toContain("[Forwarded message from @Bob]");
+ expect(text).toContain("Forwarded title\nForwarded details");
+ });
});
describe("resolveDiscordChannelInfo", () => {
diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts
index 3c523d277ef..b18e877b1ce 100644
--- a/src/discord/monitor/message-utils.ts
+++ b/src/discord/monitor/message-utils.ts
@@ -403,17 +403,32 @@ function buildDiscordMediaPlaceholder(params: {
return attachmentText || stickerText || "";
}
+export function resolveDiscordEmbedText(
+ embed?: { title?: string | null; description?: string | null } | null,
+): string {
+ const title = embed?.title?.trim() || "";
+ const description = embed?.description?.trim() || "";
+ if (title && description) {
+ return `${title}\n${description}`;
+ }
+ return title || description || "";
+}
+
export function resolveDiscordMessageText(
message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean },
): string {
+ const embedText = resolveDiscordEmbedText(
+ (message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
+ null,
+ );
const baseText =
message.content?.trim() ||
buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
stickers: resolveDiscordMessageStickers(message),
}) ||
- message.embeds?.[0]?.description ||
+ embedText ||
options?.fallbackText?.trim() ||
"";
if (!options?.includeForwarded) {
@@ -477,8 +492,7 @@ function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): st
attachments: snapshot.attachments ?? undefined,
stickers: resolveDiscordSnapshotStickers(snapshot),
});
- const embed = snapshot.embeds?.[0];
- const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
+ const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
return content || attachmentText || embedText || "";
}
diff --git a/src/discord/monitor/provider.lifecycle.test.ts b/src/discord/monitor/provider.lifecycle.test.ts
index 9b74a0badfb..f29bd8e8cc1 100644
--- a/src/discord/monitor/provider.lifecycle.test.ts
+++ b/src/discord/monitor/provider.lifecycle.test.ts
@@ -49,23 +49,38 @@ describe("runDiscordGatewayLifecycle", () => {
accountId?: string;
start?: () => Promise;
stop?: () => Promise;
+ isDisallowedIntentsError?: (err: unknown) => boolean;
+ pendingGatewayErrors?: unknown[];
}) => {
const start = vi.fn(params?.start ?? (async () => undefined));
const stop = vi.fn(params?.stop ?? (async () => undefined));
const threadStop = vi.fn();
+ const runtimeLog = vi.fn();
+ const runtimeError = vi.fn();
+ const runtimeExit = vi.fn();
+ const releaseEarlyGatewayErrorGuard = vi.fn();
+ const runtime: RuntimeEnv = {
+ log: runtimeLog,
+ error: runtimeError,
+ exit: runtimeExit,
+ };
return {
start,
stop,
threadStop,
+ runtimeError,
+ releaseEarlyGatewayErrorGuard,
lifecycleParams: {
accountId: params?.accountId ?? "default",
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
- runtime: {} as RuntimeEnv,
- isDisallowedIntentsError: () => false,
+ runtime,
+ isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
voiceManagerRef: { current: null },
execApprovalsHandler: { start, stop },
threadBindings: { stop: threadStop },
+ pendingGatewayErrors: params?.pendingGatewayErrors,
+ releaseEarlyGatewayErrorGuard,
},
};
};
@@ -75,6 +90,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop: ReturnType;
threadStop: ReturnType;
waitCalls: number;
+ releaseEarlyGatewayErrorGuard: ReturnType;
}) {
expect(params.start).toHaveBeenCalledTimes(1);
expect(params.stop).toHaveBeenCalledTimes(1);
@@ -82,39 +98,109 @@ describe("runDiscordGatewayLifecycle", () => {
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(params.threadStop).toHaveBeenCalledTimes(1);
+ expect(params.releaseEarlyGatewayErrorGuard).toHaveBeenCalledTimes(1);
}
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
- const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness({
- start: async () => {
- throw new Error("startup failed");
- },
- });
+ const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
+ createLifecycleHarness({
+ start: async () => {
+ throw new Error("startup failed");
+ },
+ });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
- expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 0 });
+ expectLifecycleCleanup({
+ start,
+ stop,
+ threadStop,
+ waitCalls: 0,
+ releaseEarlyGatewayErrorGuard,
+ });
});
it("cleans up when gateway wait fails after startup", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
- const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness();
+ const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
+ createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"gateway wait failed",
);
- expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
+ expectLifecycleCleanup({
+ start,
+ stop,
+ threadStop,
+ waitCalls: 1,
+ releaseEarlyGatewayErrorGuard,
+ });
});
it("cleans up after successful gateway wait", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
- const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness();
+ const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
+ createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
- expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
+ expectLifecycleCleanup({
+ start,
+ stop,
+ threadStop,
+ waitCalls: 1,
+ releaseEarlyGatewayErrorGuard,
+ });
+ });
+
+ it("handles queued disallowed intents errors without waiting for gateway events", async () => {
+ const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
+ const {
+ lifecycleParams,
+ start,
+ stop,
+ threadStop,
+ runtimeError,
+ releaseEarlyGatewayErrorGuard,
+ } = createLifecycleHarness({
+ pendingGatewayErrors: [new Error("Fatal Gateway error: 4014")],
+ isDisallowedIntentsError: (err) => String(err).includes("4014"),
+ });
+
+ await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
+
+ expect(runtimeError).toHaveBeenCalledWith(
+ expect.stringContaining("discord: gateway closed with code 4014"),
+ );
+ expectLifecycleCleanup({
+ start,
+ stop,
+ threadStop,
+ waitCalls: 0,
+ releaseEarlyGatewayErrorGuard,
+ });
+ });
+
+ it("throws queued non-disallowed fatal gateway errors", async () => {
+ const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
+ const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
+ createLifecycleHarness({
+ pendingGatewayErrors: [new Error("Fatal Gateway error: 4000")],
+ });
+
+ await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
+ "Fatal Gateway error: 4000",
+ );
+
+ expectLifecycleCleanup({
+ start,
+ stop,
+ threadStop,
+ waitCalls: 0,
+ releaseEarlyGatewayErrorGuard,
+ });
});
});
diff --git a/src/discord/monitor/provider.lifecycle.ts b/src/discord/monitor/provider.lifecycle.ts
index 8e5177bb945..489657d08bd 100644
--- a/src/discord/monitor/provider.lifecycle.ts
+++ b/src/discord/monitor/provider.lifecycle.ts
@@ -22,6 +22,8 @@ export async function runDiscordGatewayLifecycle(params: {
voiceManagerRef: { current: DiscordVoiceManager | null };
execApprovalsHandler: ExecApprovalsHandler | null;
threadBindings: { stop: () => void };
+ pendingGatewayErrors?: unknown[];
+ releaseEarlyGatewayErrorGuard?: () => void;
}) {
const gateway = params.client.getPlugin("gateway");
if (gateway) {
@@ -74,11 +76,48 @@ export async function runDiscordGatewayLifecycle(params: {
gatewayEmitter?.on("debug", onGatewayDebug);
let sawDisallowedIntents = false;
+ const logGatewayError = (err: unknown) => {
+ if (params.isDisallowedIntentsError(err)) {
+ sawDisallowedIntents = true;
+ params.runtime.error?.(
+ danger(
+ "discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
+ ),
+ );
+ return;
+ }
+ params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
+ };
+ const shouldStopOnGatewayError = (err: unknown) => {
+ const message = String(err);
+ return (
+ message.includes("Max reconnect attempts") ||
+ message.includes("Fatal Gateway error") ||
+ params.isDisallowedIntentsError(err)
+ );
+ };
try {
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.start();
}
+ // Drain gateway errors emitted before lifecycle listeners were attached.
+ const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
+ if (pendingGatewayErrors.length > 0) {
+ const queuedErrors = [...pendingGatewayErrors];
+ pendingGatewayErrors.length = 0;
+ for (const err of queuedErrors) {
+ logGatewayError(err);
+ if (!shouldStopOnGatewayError(err)) {
+ continue;
+ }
+ if (params.isDisallowedIntentsError(err)) {
+ return;
+ }
+ throw err;
+ }
+ }
+
await waitForDiscordGatewayStop({
gateway: gateway
? {
@@ -87,32 +126,15 @@ export async function runDiscordGatewayLifecycle(params: {
}
: undefined,
abortSignal: params.abortSignal,
- onGatewayError: (err) => {
- if (params.isDisallowedIntentsError(err)) {
- sawDisallowedIntents = true;
- params.runtime.error?.(
- danger(
- "discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
- ),
- );
- return;
- }
- params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
- },
- shouldStopOnError: (err) => {
- const message = String(err);
- return (
- message.includes("Max reconnect attempts") ||
- message.includes("Fatal Gateway error") ||
- params.isDisallowedIntentsError(err)
- );
- },
+ onGatewayError: logGatewayError,
+ shouldStopOnError: shouldStopOnGatewayError,
});
} catch (err) {
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
throw err;
}
} finally {
+ params.releaseEarlyGatewayErrorGuard?.();
unregisterGateway(params.accountId);
stopGatewayLogging();
if (helloTimeoutId) {
diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts
index 14b137fd1bd..75552749fda 100644
--- a/src/discord/monitor/provider.test.ts
+++ b/src/discord/monitor/provider.test.ts
@@ -1,8 +1,11 @@
+import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
const {
+ clientFetchUserMock,
+ clientGetPluginMock,
createDiscordNativeCommandMock,
createNoopThreadBindingManagerMock,
createThreadBindingManagerMock,
@@ -17,6 +20,8 @@ const {
} = vi.hoisted(() => {
const createdBindingManagers: Array<{ stop: ReturnType }> = [];
return {
+ clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })),
+ clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined),
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
createNoopThreadBindingManagerMock: vi.fn(() => {
const manager = { stop: vi.fn() };
@@ -65,11 +70,11 @@ vi.mock("@buape/carbon", () => {
async handleDeployRequest() {
return undefined;
}
- async fetchUser(_target: string) {
- return { id: "bot-1" };
+ async fetchUser(target: string) {
+ return await clientFetchUserMock(target);
}
- getPlugin(_name: string) {
- return undefined;
+ getPlugin(name: string) {
+ return clientGetPluginMock(name);
}
}
return { Client, ReadyListener };
@@ -242,6 +247,8 @@ describe("monitorDiscordProvider", () => {
}) as OpenClawConfig;
beforeEach(() => {
+ clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" });
+ clientGetPluginMock.mockClear().mockReturnValue(undefined);
createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" });
createNoopThreadBindingManagerMock.mockClear();
createThreadBindingManagerMock.mockClear();
@@ -290,4 +297,28 @@ describe("monitorDiscordProvider", () => {
expect(createdBindingManagers).toHaveLength(1);
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
+
+ it("captures gateway errors emitted before lifecycle wait starts", async () => {
+ const { monitorDiscordProvider } = await import("./provider.js");
+ const emitter = new EventEmitter();
+ clientGetPluginMock.mockImplementation((name: string) =>
+ name === "gateway" ? { emitter, disconnect: vi.fn() } : undefined,
+ );
+ clientFetchUserMock.mockImplementationOnce(async () => {
+ emitter.emit("error", new Error("Fatal Gateway error: 4014"));
+ return { id: "bot-1" };
+ });
+
+ await monitorDiscordProvider({
+ config: baseConfig(),
+ runtime: baseRuntime(),
+ });
+
+ expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
+ const lifecycleArgs = monitorLifecycleMock.mock.calls[0]?.[0] as {
+ pendingGatewayErrors?: unknown[];
+ };
+ expect(lifecycleArgs.pendingGatewayErrors).toHaveLength(1);
+ expect(String(lifecycleArgs.pendingGatewayErrors?.[0])).toContain("4014");
+ });
});
diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts
index 15c8e2aa7b4..8243da5a246 100644
--- a/src/discord/monitor/provider.ts
+++ b/src/discord/monitor/provider.ts
@@ -51,6 +51,7 @@ import {
} from "./agent-components.js";
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
+import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import {
DiscordMessageListener,
@@ -365,6 +366,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
})
: createNoopThreadBindingManager(account.accountId);
let lifecycleStarted = false;
+ let releaseEarlyGatewayErrorGuard = () => {};
try {
const commands: BaseCommand[] = commandSpecs.map((spec) =>
createDiscordNativeCommand({
@@ -496,6 +498,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
clientPlugins,
);
+ const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client);
+ releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release;
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
@@ -561,6 +565,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId,
runtime,
botUserId,
+ dmEnabled,
+ groupDmEnabled,
+ groupDmChannels: groupDmChannels ?? [],
+ dmPolicy,
+ allowFrom: allowFrom ?? [],
+ groupPolicy,
allowNameMatching: isDangerousNameMatchingEnabled(discordCfg),
guildEntries,
logger,
@@ -573,6 +583,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId,
runtime,
botUserId,
+ dmEnabled,
+ groupDmEnabled,
+ groupDmChannels: groupDmChannels ?? [],
+ dmPolicy,
+ allowFrom: allowFrom ?? [],
+ groupPolicy,
allowNameMatching: isDangerousNameMatchingEnabled(discordCfg),
guildEntries,
logger,
@@ -600,8 +616,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
voiceManagerRef,
execApprovalsHandler,
threadBindings,
+ pendingGatewayErrors: earlyGatewayErrorGuard.pendingErrors,
+ releaseEarlyGatewayErrorGuard,
});
} finally {
+ releaseEarlyGatewayErrorGuard();
if (!lifecycleStarted) {
threadBindings.stop();
}
diff --git a/src/discord/monitor/threading.starter.test.ts b/src/discord/monitor/threading.starter.test.ts
new file mode 100644
index 00000000000..07268d7fae9
--- /dev/null
+++ b/src/discord/monitor/threading.starter.test.ts
@@ -0,0 +1,55 @@
+import { ChannelType, type Client } from "@buape/carbon";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ __resetDiscordThreadStarterCacheForTest,
+ resolveDiscordThreadStarter,
+} from "./threading.js";
+
+describe("resolveDiscordThreadStarter", () => {
+ beforeEach(() => {
+ __resetDiscordThreadStarterCacheForTest();
+ });
+
+ it("falls back to joined embed title and description when content is empty", async () => {
+ const get = vi.fn().mockResolvedValue({
+ content: " ",
+ embeds: [{ title: "Alert", description: "Details" }],
+ author: { username: "Alice", discriminator: "0" },
+ timestamp: "2026-02-24T12:00:00.000Z",
+ });
+ const client = { rest: { get } } as unknown as Client;
+
+ const result = await resolveDiscordThreadStarter({
+ channel: { id: "thread-1" },
+ client,
+ parentId: "parent-1",
+ parentType: ChannelType.GuildText,
+ resolveTimestampMs: () => 123,
+ });
+
+ expect(result).toEqual({
+ text: "Alert\nDetails",
+ author: "Alice",
+ timestamp: 123,
+ });
+ });
+
+ it("prefers starter content over embed fallback text", async () => {
+ const get = vi.fn().mockResolvedValue({
+ content: "starter content",
+ embeds: [{ title: "Alert", description: "Details" }],
+ author: { username: "Alice", discriminator: "0" },
+ });
+ const client = { rest: { get } } as unknown as Client;
+
+ const result = await resolveDiscordThreadStarter({
+ channel: { id: "thread-1" },
+ client,
+ parentId: "parent-1",
+ parentType: ChannelType.GuildText,
+ resolveTimestampMs: () => undefined,
+ });
+
+ expect(result?.text).toBe("starter content");
+ });
+});
diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts
index 877329c2995..14377d8e644 100644
--- a/src/discord/monitor/threading.ts
+++ b/src/discord/monitor/threading.ts
@@ -7,7 +7,11 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import type { DiscordMessageEvent } from "./listeners.js";
-import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js";
+import {
+ resolveDiscordChannelInfo,
+ resolveDiscordEmbedText,
+ resolveDiscordMessageChannelId,
+} from "./message-utils.js";
export type DiscordThreadChannel = {
id: string;
@@ -172,7 +176,7 @@ export async function resolveDiscordThreadStarter(params: {
Routes.channelMessage(messageChannelId, params.channel.id),
)) as {
content?: string | null;
- embeds?: Array<{ description?: string | null }>;
+ embeds?: Array<{ title?: string | null; description?: string | null }>;
member?: { nick?: string | null; displayName?: string | null };
author?: {
id?: string | null;
@@ -184,7 +188,9 @@ export async function resolveDiscordThreadStarter(params: {
if (!starter) {
return null;
}
- const text = starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
+ const content = starter.content?.trim() ?? "";
+ const embedText = resolveDiscordEmbedText(starter.embeds?.[0]);
+ const text = content || embedText;
if (!text) {
return null;
}
diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts
index 5e582d42a03..320b4da0b1f 100644
--- a/src/gateway/exec-approval-manager.ts
+++ b/src/gateway/exec-approval-manager.ts
@@ -1,20 +1,13 @@
import { randomUUID } from "node:crypto";
-import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
+import type {
+ ExecApprovalDecision,
+ ExecApprovalRequestPayload as InfraExecApprovalRequestPayload,
+} from "../infra/exec-approvals.js";
// Grace period to keep resolved entries for late awaitDecision calls
const RESOLVED_ENTRY_GRACE_MS = 15_000;
-export type ExecApprovalRequestPayload = {
- command: string;
- cwd?: string | null;
- nodeId?: string | null;
- host?: string | null;
- security?: string | null;
- ask?: string | null;
- agentId?: string | null;
- resolvedPath?: string | null;
- sessionKey?: string | null;
-};
+export type ExecApprovalRequestPayload = InfraExecApprovalRequestPayload;
export type ExecApprovalRecord = {
id: string;
diff --git a/src/gateway/node-invoke-system-run-approval-match.test.ts b/src/gateway/node-invoke-system-run-approval-match.test.ts
new file mode 100644
index 00000000000..f5f093426c6
--- /dev/null
+++ b/src/gateway/node-invoke-system-run-approval-match.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "vitest";
+import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js";
+
+describe("approvalMatchesSystemRunRequest", () => {
+ test("matches legacy command text when binding fields match", () => {
+ const result = approvalMatchesSystemRunRequest({
+ cmdText: "echo SAFE",
+ argv: ["echo", "SAFE"],
+ request: {
+ host: "node",
+ command: "echo SAFE",
+ cwd: "/tmp",
+ agentId: "agent-1",
+ sessionKey: "session-1",
+ },
+ binding: {
+ cwd: "/tmp",
+ agentId: "agent-1",
+ sessionKey: "session-1",
+ },
+ });
+ expect(result).toBe(true);
+ });
+
+ test("rejects legacy command mismatch", () => {
+ const result = approvalMatchesSystemRunRequest({
+ cmdText: "echo PWNED",
+ argv: ["echo", "PWNED"],
+ request: {
+ host: "node",
+ command: "echo SAFE",
+ },
+ binding: {
+ cwd: null,
+ agentId: null,
+ sessionKey: null,
+ },
+ });
+ expect(result).toBe(false);
+ });
+
+ test("enforces exact argv binding when commandArgv is set", () => {
+ const result = approvalMatchesSystemRunRequest({
+ cmdText: "echo SAFE",
+ argv: ["echo", "SAFE"],
+ request: {
+ host: "node",
+ command: "echo SAFE",
+ commandArgv: ["echo", "SAFE"],
+ },
+ binding: {
+ cwd: null,
+ agentId: null,
+ sessionKey: null,
+ },
+ });
+ expect(result).toBe(true);
+ });
+
+ test("rejects argv mismatch even when command text matches", () => {
+ const result = approvalMatchesSystemRunRequest({
+ cmdText: "echo SAFE",
+ argv: ["echo", "SAFE"],
+ request: {
+ host: "node",
+ command: "echo SAFE",
+ commandArgv: ["echo SAFE"],
+ },
+ binding: {
+ cwd: null,
+ agentId: null,
+ sessionKey: null,
+ },
+ });
+ expect(result).toBe(false);
+ });
+
+ test("rejects non-node host requests", () => {
+ const result = approvalMatchesSystemRunRequest({
+ cmdText: "echo SAFE",
+ argv: ["echo", "SAFE"],
+ request: {
+ host: "gateway",
+ command: "echo SAFE",
+ },
+ binding: {
+ cwd: null,
+ agentId: null,
+ sessionKey: null,
+ },
+ });
+ expect(result).toBe(false);
+ });
+});
diff --git a/src/gateway/node-invoke-system-run-approval-match.ts b/src/gateway/node-invoke-system-run-approval-match.ts
new file mode 100644
index 00000000000..3dccc9b793d
--- /dev/null
+++ b/src/gateway/node-invoke-system-run-approval-match.ts
@@ -0,0 +1,51 @@
+import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js";
+
+export type SystemRunApprovalBinding = {
+ cwd: string | null;
+ agentId: string | null;
+ sessionKey: string | null;
+};
+
+function argvMatchesRequest(requestedArgv: string[], argv: string[]): boolean {
+ if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) {
+ return false;
+ }
+ for (let i = 0; i < requestedArgv.length; i += 1) {
+ if (requestedArgv[i] !== argv[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function approvalMatchesSystemRunRequest(params: {
+ cmdText: string;
+ argv: string[];
+ request: ExecApprovalRequestPayload;
+ binding: SystemRunApprovalBinding;
+}): boolean {
+ if (params.request.host !== "node") {
+ return false;
+ }
+
+ const requestedArgv = params.request.commandArgv;
+ if (Array.isArray(requestedArgv)) {
+ if (!argvMatchesRequest(requestedArgv, params.argv)) {
+ return false;
+ }
+ } else if (!params.cmdText || params.request.command !== params.cmdText) {
+ return false;
+ }
+
+ if ((params.request.cwd ?? null) !== params.binding.cwd) {
+ return false;
+ }
+ if ((params.request.agentId ?? null) !== params.binding.agentId) {
+ return false;
+ }
+ if ((params.request.sessionKey ?? null) !== params.binding.sessionKey) {
+ return false;
+ }
+
+ return true;
+}
diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts
index 196b5947f45..833bbf6f3cf 100644
--- a/src/gateway/node-invoke-system-run-approval.test.ts
+++ b/src/gateway/node-invoke-system-run-approval.test.ts
@@ -13,13 +13,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
},
};
- function makeRecord(command: string): ExecApprovalRecord {
+ function makeRecord(command: string, commandArgv?: string[]): ExecApprovalRecord {
return {
id: "approval-1",
request: {
host: "node",
nodeId: "node-1",
command,
+ commandArgv,
cwd: null,
agentId: null,
sessionKey: null,
@@ -139,6 +140,64 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
});
expectAllowOnceForwardingResult(result);
});
+
+ test("rejects trailing-space argv mismatch against legacy command-only approval", () => {
+ const result = sanitizeSystemRunParamsForForwarding({
+ rawParams: {
+ command: ["runner "],
+ runId: "approval-1",
+ approved: true,
+ approvalDecision: "allow-once",
+ },
+ nodeId: "node-1",
+ client,
+ execApprovalManager: manager(makeRecord("runner")),
+ nowMs: now,
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) {
+ throw new Error("unreachable");
+ }
+ expect(result.message).toContain("approval id does not match request");
+ expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH");
+ });
+
+ test("enforces commandArgv identity when approval includes argv binding", () => {
+ const result = sanitizeSystemRunParamsForForwarding({
+ rawParams: {
+ command: ["echo", "SAFE"],
+ runId: "approval-1",
+ approved: true,
+ approvalDecision: "allow-once",
+ },
+ nodeId: "node-1",
+ client,
+ execApprovalManager: manager(makeRecord("echo SAFE", ["echo SAFE"])),
+ nowMs: now,
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) {
+ throw new Error("unreachable");
+ }
+ expect(result.message).toContain("approval id does not match request");
+ expect(result.details?.code).toBe("APPROVAL_REQUEST_MISMATCH");
+ });
+
+ test("accepts matching commandArgv binding for trailing-space argv", () => {
+ const result = sanitizeSystemRunParamsForForwarding({
+ rawParams: {
+ command: ["runner "],
+ runId: "approval-1",
+ approved: true,
+ approvalDecision: "allow-once",
+ },
+ nodeId: "node-1",
+ client,
+ execApprovalManager: manager(makeRecord('"runner "', ["runner "])),
+ nowMs: now,
+ });
+ expectAllowOnceForwardingResult(result);
+ });
test("consumes allow-once approvals and blocks same runId replay", async () => {
const approvalManager = new ExecApprovalManager();
const runId = "approval-replay-1";
diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts
index d5600adf032..35cd18c66b9 100644
--- a/src/gateway/node-invoke-system-run-approval.ts
+++ b/src/gateway/node-invoke-system-run-approval.ts
@@ -1,5 +1,6 @@
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
+import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js";
type SystemRunParamsLike = {
command?: unknown;
@@ -53,40 +54,6 @@ function clientHasApprovals(client: ApprovalClient | null): boolean {
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
}
-function approvalMatchesRequest(
- cmdText: string,
- params: SystemRunParamsLike,
- record: ExecApprovalRecord,
-): boolean {
- if (record.request.host !== "node") {
- return false;
- }
-
- if (!cmdText || record.request.command !== cmdText) {
- return false;
- }
-
- const reqCwd = record.request.cwd ?? null;
- const runCwd = normalizeString(params.cwd) ?? null;
- if (reqCwd !== runCwd) {
- return false;
- }
-
- const reqAgentId = record.request.agentId ?? null;
- const runAgentId = normalizeString(params.agentId) ?? null;
- if (reqAgentId !== runAgentId) {
- return false;
- }
-
- const reqSessionKey = record.request.sessionKey ?? null;
- const runSessionKey = normalizeString(params.sessionKey) ?? null;
- if (reqSessionKey !== runSessionKey) {
- return false;
- }
-
- return true;
-}
-
function pickSystemRunParams(raw: Record): Record {
// Defensive allowlist: only forward fields that the node-host `system.run` handler understands.
// This prevents future internal control fields from being smuggled through the gateway.
@@ -237,7 +204,18 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
};
}
- if (!approvalMatchesRequest(cmdText, p, snapshot)) {
+ if (
+ !approvalMatchesSystemRunRequest({
+ cmdText,
+ argv: cmdTextResolution.argv,
+ request: snapshot.request,
+ binding: {
+ cwd: normalizeString(p.cwd) ?? null,
+ agentId: normalizeString(p.agentId) ?? null,
+ sessionKey: normalizeString(p.sessionKey) ?? null,
+ },
+ })
+ ) {
return {
ok: false,
message: "approval id does not match request",
diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts
index b8c883f7f53..1508c38f70e 100644
--- a/src/gateway/protocol/schema/agent.ts
+++ b/src/gateway/protocol/schema/agent.ts
@@ -22,6 +22,8 @@ export const SendParamsSchema = Type.Object(
gifPlayback: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
+ /** Optional agent id for per-agent media root resolution on gateway sends. */
+ agentId: Type.Optional(Type.String()),
/** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */
threadId: Type.Optional(Type.String()),
/** Optional session key for mirroring delivered output back into the transcript. */
diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts
index dae3b340d7e..7e0ebe54917 100644
--- a/src/gateway/protocol/schema/cron.ts
+++ b/src/gateway/protocol/schema/cron.ts
@@ -138,6 +138,7 @@ export const CronPayloadPatchSchema = Type.Union([
const CronDeliverySharedProperties = {
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
+ accountId: Type.Optional(NonEmptyString),
bestEffort: Type.Optional(Type.Boolean()),
};
diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts
index a7c5fcf09bb..083a445a4cf 100644
--- a/src/gateway/protocol/schema/exec-approvals.ts
+++ b/src/gateway/protocol/schema/exec-approvals.ts
@@ -89,6 +89,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
command: NonEmptyString,
+ commandArgv: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nodeId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts
index 448707eb1c7..577ffe1ab43 100644
--- a/src/gateway/server-http.hooks-request-timeout.test.ts
+++ b/src/gateway/server-http.hooks-request-timeout.test.ts
@@ -41,10 +41,11 @@ function createHooksConfig(): HooksConfigResolved {
function createRequest(params?: {
authorization?: string;
remoteAddress?: string;
+ url?: string;
}): IncomingMessage {
return {
method: "POST",
- url: "/hooks/wake",
+ url: params?.url ?? "/hooks/wake",
headers: {
host: "127.0.0.1:18789",
authorization: params?.authorization ?? "Bearer hook-secret",
@@ -71,10 +72,11 @@ function createResponse(): {
function createHandler(params?: {
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
+ bindHost?: string;
}) {
return createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
- bindHost: "127.0.0.1",
+ bindHost: params?.bindHost ?? "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
@@ -139,4 +141,18 @@ describe("createHooksRequestHandler timeout status mapping", () => {
expect(mappedRes.statusCode).toBe(429);
expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String));
});
+
+ test.each(["0.0.0.0", "::"])(
+ "does not throw when bindHost=%s while parsing non-hook request URL",
+ async (bindHost) => {
+ const handler = createHandler({ bindHost });
+ const req = createRequest({ url: "/" });
+ const { res, end } = createResponse();
+
+ const handled = await handler(req, res);
+
+ expect(handled).toBe(false);
+ expect(end).not.toHaveBeenCalled();
+ },
+ );
});
diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts
index 72a81a769ad..41d04d5d3ac 100644
--- a/src/gateway/server-http.ts
+++ b/src/gateway/server-http.ts
@@ -208,7 +208,7 @@ export function createHooksRequestHandler(
logHooks: SubsystemLogger;
} & HookDispatchers,
): HooksRequestHandler {
- const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
+ const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
const hookAuthLimiter = createAuthRateLimiter({
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
@@ -227,7 +227,9 @@ export function createHooksRequestHandler(
if (!hooksConfig) {
return false;
}
- const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
+ // Only pathname/search are used here; keep the base host fixed so bind-host
+ // representation (e.g. IPv6 wildcards) cannot break request parsing.
+ const url = new URL(req.url ?? "/", "http://localhost");
const basePath = hooksConfig.basePath;
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
return false;
diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts
index 5d65d262735..dbf21b0efbf 100644
--- a/src/gateway/server-methods/agent.test.ts
+++ b/src/gateway/server-methods/agent.test.ts
@@ -43,9 +43,14 @@ vi.mock("../../commands/agent.js", () => ({
agentCommand: mocks.agentCommand,
}));
-vi.mock("../../config/config.js", () => ({
- loadConfig: () => mocks.loadConfigReturn,
-}));
+vi.mock("../../config/config.js", async () => {
+ const actual =
+ await vi.importActual("../../config/config.js");
+ return {
+ ...actual,
+ loadConfig: () => mocks.loadConfigReturn,
+ };
+});
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts
index 54c285203f3..a4fddea633a 100644
--- a/src/gateway/server-methods/agents-mutate.test.ts
+++ b/src/gateway/server-methods/agents-mutate.test.ts
@@ -26,7 +26,10 @@ const mocks = vi.hoisted(() => ({
fsMkdir: vi.fn(async () => undefined),
fsAppendFile: vi.fn(async () => {}),
fsReadFile: vi.fn(async () => ""),
- fsStat: vi.fn(async () => null),
+ fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
+ fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
+ fsRealpath: vi.fn(async (p: string) => p),
+ fsOpen: vi.fn(async () => ({}) as unknown),
}));
vi.mock("../../config/config.js", () => ({
@@ -85,6 +88,9 @@ vi.mock("node:fs/promises", async () => {
appendFile: mocks.fsAppendFile,
readFile: mocks.fsReadFile,
stat: mocks.fsStat,
+ lstat: mocks.fsLstat,
+ realpath: mocks.fsRealpath,
+ open: mocks.fsOpen,
};
return { ...patched, default: patched };
});
@@ -125,6 +131,33 @@ function createErrnoError(code: string) {
return err;
}
+function makeFileStat(params?: {
+ size?: number;
+ mtimeMs?: number;
+ dev?: number;
+ ino?: number;
+}): import("node:fs").Stats {
+ return {
+ isFile: () => true,
+ isSymbolicLink: () => false,
+ size: params?.size ?? 10,
+ mtimeMs: params?.mtimeMs ?? 1234,
+ dev: params?.dev ?? 1,
+ ino: params?.ino ?? 1,
+ } as unknown as import("node:fs").Stats;
+}
+
+function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
+ return {
+ isFile: () => false,
+ isSymbolicLink: () => true,
+ size: 0,
+ mtimeMs: 0,
+ dev: params?.dev ?? 1,
+ ino: params?.ino ?? 2,
+ } as unknown as import("node:fs").Stats;
+}
+
function mockWorkspaceStateRead(params: {
onboardingCompletedAt?: string;
errorCode?: string;
@@ -172,6 +205,19 @@ beforeEach(() => {
mocks.fsStat.mockImplementation(async () => {
throw createEnoentError();
});
+ mocks.fsLstat.mockImplementation(async () => {
+ throw createEnoentError();
+ });
+ mocks.fsRealpath.mockImplementation(async (p: string) => p);
+ mocks.fsOpen.mockImplementation(
+ async () =>
+ ({
+ stat: async () => makeFileStat(),
+ readFile: async () => Buffer.from(""),
+ writeFile: async () => {},
+ close: async () => {},
+ }) as unknown,
+ );
});
/* ------------------------------------------------------------------ */
@@ -459,3 +505,147 @@ describe("agents.files.list", () => {
expect(names).toContain("BOOTSTRAP.md");
});
});
+
+describe("agents.files.get/set symlink safety", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.loadConfigReturn = {};
+ mocks.fsMkdir.mockResolvedValue(undefined);
+ });
+
+ it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
+ const workspace = "/workspace/test-agent";
+ const candidate = `${workspace}/AGENTS.md`;
+ mocks.fsRealpath.mockImplementation(async (p: string) => {
+ if (p === workspace) {
+ return workspace;
+ }
+ if (p === candidate) {
+ return "/outside/secret.txt";
+ }
+ return p;
+ });
+ mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
+ const p = typeof args[0] === "string" ? args[0] : "";
+ if (p === candidate) {
+ return makeSymlinkStat();
+ }
+ throw createEnoentError();
+ });
+
+ const { respond, promise } = makeCall("agents.files.get", {
+ agentId: "main",
+ name: "AGENTS.md",
+ });
+ await promise;
+
+ expect(respond).toHaveBeenCalledWith(
+ false,
+ undefined,
+ expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
+ );
+ });
+
+ it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
+ const workspace = "/workspace/test-agent";
+ const candidate = `${workspace}/AGENTS.md`;
+ mocks.fsRealpath.mockImplementation(async (p: string) => {
+ if (p === workspace) {
+ return workspace;
+ }
+ if (p === candidate) {
+ return "/outside/secret.txt";
+ }
+ return p;
+ });
+ mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
+ const p = typeof args[0] === "string" ? args[0] : "";
+ if (p === candidate) {
+ return makeSymlinkStat();
+ }
+ throw createEnoentError();
+ });
+
+ const { respond, promise } = makeCall("agents.files.set", {
+ agentId: "main",
+ name: "AGENTS.md",
+ content: "x",
+ });
+ await promise;
+
+ expect(respond).toHaveBeenCalledWith(
+ false,
+ undefined,
+ expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
+ );
+ expect(mocks.fsOpen).not.toHaveBeenCalled();
+ });
+
+ it("allows in-workspace symlink targets for get/set", async () => {
+ const workspace = "/workspace/test-agent";
+ const candidate = `${workspace}/AGENTS.md`;
+ const target = `${workspace}/policies/AGENTS.md`;
+ const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
+
+ mocks.fsRealpath.mockImplementation(async (p: string) => {
+ if (p === workspace) {
+ return workspace;
+ }
+ if (p === candidate) {
+ return target;
+ }
+ return p;
+ });
+ mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
+ const p = typeof args[0] === "string" ? args[0] : "";
+ if (p === candidate) {
+ return makeSymlinkStat({ dev: 9, ino: 41 });
+ }
+ if (p === target) {
+ return targetStat;
+ }
+ throw createEnoentError();
+ });
+ mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
+ const p = typeof args[0] === "string" ? args[0] : "";
+ if (p === target) {
+ return targetStat;
+ }
+ throw createEnoentError();
+ });
+ mocks.fsOpen.mockImplementation(
+ async () =>
+ ({
+ stat: async () => targetStat,
+ readFile: async () => Buffer.from("inside\n"),
+ writeFile: async () => {},
+ close: async () => {},
+ }) as unknown,
+ );
+
+ const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
+ await getCall.promise;
+ expect(getCall.respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({
+ file: expect.objectContaining({ missing: false, content: "inside\n" }),
+ }),
+ undefined,
+ );
+
+ const setCall = makeCall("agents.files.set", {
+ agentId: "main",
+ name: "AGENTS.md",
+ content: "updated\n",
+ });
+ await setCall.promise;
+ expect(setCall.respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({
+ ok: true,
+ file: expect.objectContaining({ missing: false, content: "updated\n" }),
+ }),
+ undefined,
+ );
+ });
+});
diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts
index 04a716e077e..413ffddc877 100644
--- a/src/gateway/server-methods/agents.ts
+++ b/src/gateway/server-methods/agents.ts
@@ -1,3 +1,4 @@
+import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
@@ -27,6 +28,9 @@ import {
} from "../../commands/agents.config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
+import { sameFileIdentity } from "../../infra/file-identity.js";
+import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js";
+import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
@@ -97,10 +101,113 @@ type FileMeta = {
updatedAtMs: number;
};
-async function statFile(filePath: string): Promise {
+type ResolvedAgentWorkspaceFilePath =
+ | {
+ kind: "ready";
+ requestPath: string;
+ ioPath: string;
+ workspaceReal: string;
+ }
+ | {
+ kind: "missing";
+ requestPath: string;
+ ioPath: string;
+ workspaceReal: string;
+ }
+ | {
+ kind: "invalid";
+ requestPath: string;
+ reason: string;
+ };
+
+const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
+const OPEN_WRITE_FLAGS =
+ fsConstants.O_WRONLY |
+ fsConstants.O_CREAT |
+ fsConstants.O_TRUNC |
+ (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
+
+async function resolveWorkspaceRealPath(workspaceDir: string): Promise {
try {
- const stat = await fs.stat(filePath);
- if (!stat.isFile()) {
+ return await fs.realpath(workspaceDir);
+ } catch {
+ return path.resolve(workspaceDir);
+ }
+}
+
+async function resolveAgentWorkspaceFilePath(params: {
+ workspaceDir: string;
+ name: string;
+ allowMissing: boolean;
+}): Promise {
+ const requestPath = path.join(params.workspaceDir, params.name);
+ const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
+ const candidatePath = path.resolve(workspaceReal, params.name);
+ if (!isPathInside(workspaceReal, candidatePath)) {
+ return { kind: "invalid", requestPath, reason: "path escapes workspace root" };
+ }
+
+ let candidateLstat: Awaited>;
+ try {
+ candidateLstat = await fs.lstat(candidatePath);
+ } catch (err) {
+ if (isNotFoundPathError(err)) {
+ if (params.allowMissing) {
+ return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
+ }
+ return { kind: "invalid", requestPath, reason: "file not found" };
+ }
+ throw err;
+ }
+
+ if (candidateLstat.isSymbolicLink()) {
+ let targetReal: string;
+ try {
+ targetReal = await fs.realpath(candidatePath);
+ } catch (err) {
+ if (isNotFoundPathError(err)) {
+ if (params.allowMissing) {
+ return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
+ }
+ return { kind: "invalid", requestPath, reason: "symlink target not found" };
+ }
+ throw err;
+ }
+ if (!isPathInside(workspaceReal, targetReal)) {
+ return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" };
+ }
+ try {
+ const targetStat = await fs.stat(targetReal);
+ if (!targetStat.isFile()) {
+ return { kind: "invalid", requestPath, reason: "symlink target is not a file" };
+ }
+ } catch (err) {
+ if (isNotFoundPathError(err) && params.allowMissing) {
+ return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal };
+ }
+ throw err;
+ }
+ return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
+ }
+
+ if (!candidateLstat.isFile()) {
+ return { kind: "invalid", requestPath, reason: "path is not a regular file" };
+ }
+
+ const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath);
+ if (!isPathInside(workspaceReal, candidateReal)) {
+ return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" };
+ }
+ return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal };
+}
+
+async function statFileSafely(filePath: string): Promise {
+ try {
+ const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]);
+ if (lstat.isSymbolicLink() || !stat.isFile()) {
+ return null;
+ }
+ if (!sameFileIdentity(stat, lstat)) {
return null;
}
return {
@@ -112,6 +219,22 @@ async function statFile(filePath: string): Promise {
}
}
+async function writeFileSafely(filePath: string, content: string): Promise {
+ const handle = await fs.open(filePath, OPEN_WRITE_FLAGS, 0o600);
+ try {
+ const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]);
+ if (lstat.isSymbolicLink() || !stat.isFile()) {
+ throw new Error("unsafe file path");
+ }
+ if (!sameFileIdentity(stat, lstat)) {
+ throw new Error("path changed during write");
+ }
+ await handle.writeFile(content, "utf-8");
+ } finally {
+ await handle.close().catch(() => {});
+ }
+}
+
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
@@ -125,8 +248,18 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
- const filePath = path.join(workspaceDir, name);
- const meta = await statFile(filePath);
+ const resolved = await resolveAgentWorkspaceFilePath({
+ workspaceDir,
+ name,
+ allowMissing: true,
+ });
+ const filePath = resolved.requestPath;
+ const meta =
+ resolved.kind === "ready"
+ ? await statFileSafely(resolved.ioPath)
+ : resolved.kind === "missing"
+ ? null
+ : null;
if (meta) {
files.push({
name,
@@ -140,29 +273,43 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
}
}
- const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME);
- const primaryMeta = await statFile(primaryMemoryPath);
+ const primaryResolved = await resolveAgentWorkspaceFilePath({
+ workspaceDir,
+ name: DEFAULT_MEMORY_FILENAME,
+ allowMissing: true,
+ });
+ const primaryMeta =
+ primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null;
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
- path: primaryMemoryPath,
+ path: primaryResolved.requestPath,
missing: false,
size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs,
});
} else {
- const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
- const altMeta = await statFile(altMemoryPath);
+ const altMemoryResolved = await resolveAgentWorkspaceFilePath({
+ workspaceDir,
+ name: DEFAULT_MEMORY_ALT_FILENAME,
+ allowMissing: true,
+ });
+ const altMeta =
+ altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null;
if (altMeta) {
files.push({
name: DEFAULT_MEMORY_ALT_FILENAME,
- path: altMemoryPath,
+ path: altMemoryResolved.requestPath,
missing: false,
size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs,
});
} else {
- files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true });
+ files.push({
+ name: DEFAULT_MEMORY_FILENAME,
+ path: primaryResolved.requestPath,
+ missing: true,
+ });
}
}
@@ -453,8 +600,23 @@ export const agentsHandlers: GatewayRequestHandlers = {
}
const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name);
- const meta = await statFile(filePath);
- if (!meta) {
+ const resolvedPath = await resolveAgentWorkspaceFilePath({
+ workspaceDir,
+ name,
+ allowMissing: true,
+ });
+ if (resolvedPath.kind === "invalid") {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ `unsafe workspace file "${name}" (${resolvedPath.reason})`,
+ ),
+ );
+ return;
+ }
+ if (resolvedPath.kind === "missing") {
respond(
true,
{
@@ -466,7 +628,29 @@ export const agentsHandlers: GatewayRequestHandlers = {
);
return;
}
- const content = await fs.readFile(filePath, "utf-8");
+ let safeRead: Awaited>;
+ try {
+ safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
+ } catch (err) {
+ if (err instanceof SafeOpenError && err.code === "not-found") {
+ respond(
+ true,
+ {
+ agentId,
+ workspace: workspaceDir,
+ file: { name, path: filePath, missing: true },
+ },
+ undefined,
+ );
+ return;
+ }
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
+ );
+ return;
+ }
respond(
true,
{
@@ -476,9 +660,9 @@ export const agentsHandlers: GatewayRequestHandlers = {
name,
path: filePath,
missing: false,
- size: meta.size,
- updatedAtMs: meta.updatedAtMs,
- content,
+ size: safeRead.stat.size,
+ updatedAtMs: Math.floor(safeRead.stat.mtimeMs),
+ content: safeRead.buffer.toString("utf-8"),
},
},
undefined,
@@ -505,9 +689,34 @@ export const agentsHandlers: GatewayRequestHandlers = {
const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
+ const resolvedPath = await resolveAgentWorkspaceFilePath({
+ workspaceDir,
+ name,
+ allowMissing: true,
+ });
+ if (resolvedPath.kind === "invalid") {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ `unsafe workspace file "${name}" (${resolvedPath.reason})`,
+ ),
+ );
+ return;
+ }
const content = String(params.content ?? "");
- await fs.writeFile(filePath, content, "utf-8");
- const meta = await statFile(filePath);
+ try {
+ await writeFileSafely(resolvedPath.ioPath, content);
+ } catch {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
+ );
+ return;
+ }
+ const meta = await statFileSafely(resolvedPath.ioPath);
respond(
true,
{
diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts
index d1cfc9ec0d9..a9b3db150ce 100644
--- a/src/gateway/server-methods/exec-approval.ts
+++ b/src/gateway/server-methods/exec-approval.ts
@@ -43,6 +43,7 @@ export function createExecApprovalHandlers(
const p = params as {
id?: string;
command: string;
+ commandArgv?: string[];
cwd?: string;
nodeId?: string;
host?: string;
@@ -60,6 +61,9 @@ export function createExecApprovalHandlers(
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
const host = typeof p.host === "string" ? p.host.trim() : "";
const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : "";
+ const commandArgv = Array.isArray(p.commandArgv)
+ ? p.commandArgv.map((entry) => String(entry))
+ : undefined;
if (host === "node" && !nodeId) {
respond(
false,
@@ -78,6 +82,7 @@ export function createExecApprovalHandlers(
}
const request = {
command: p.command,
+ commandArgv,
cwd: p.cwd ?? null,
nodeId: host === "node" ? nodeId : null,
host: host || null,
diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts
index 7209d3e6176..0e3bfba668c 100644
--- a/src/gateway/server-methods/send.test.ts
+++ b/src/gateway/server-methods/send.test.ts
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
+import { setActivePluginRegistry } from "../../plugins/runtime.js";
+import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { sendHandlers } from "./send.js";
import type { GatewayRequestContext } from "./types.js";
@@ -10,6 +12,8 @@ const mocks = vi.hoisted(() => ({
resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })),
resolveMessageChannelSelection: vi.fn(),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
+ getChannelPlugin: vi.fn(),
+ loadOpenClawPlugins: vi.fn(),
}));
vi.mock("../../config/config.js", async () => {
@@ -22,10 +26,38 @@ vi.mock("../../config/config.js", async () => {
});
vi.mock("../../channels/plugins/index.js", () => ({
- getChannelPlugin: () => ({ outbound: { sendPoll: mocks.sendPoll } }),
+ getChannelPlugin: mocks.getChannelPlugin,
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
}));
+vi.mock("../../agents/agent-scope.js", () => ({
+ resolveSessionAgentId: ({
+ sessionKey,
+ }: {
+ sessionKey?: string;
+ config?: unknown;
+ agentId?: string;
+ }) => {
+ if (typeof sessionKey === "string") {
+ const match = sessionKey.match(/^agent:([^:]+)/i);
+ if (match?.[1]) {
+ return match[1];
+ }
+ }
+ return "main";
+ },
+ resolveDefaultAgentId: () => "main",
+ resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
+}));
+
+vi.mock("../../config/plugin-auto-enable.js", () => ({
+ applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
+}));
+
+vi.mock("../../plugins/loader.js", () => ({
+ loadOpenClawPlugins: mocks.loadOpenClawPlugins,
+}));
+
vi.mock("../../infra/outbound/targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
@@ -85,14 +117,19 @@ function mockDeliverySuccess(messageId: string) {
}
describe("gateway send mirroring", () => {
+ let registrySeq = 0;
+
beforeEach(() => {
vi.clearAllMocks();
+ registrySeq += 1;
+ setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`);
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "slack",
configured: ["slack"],
});
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
+ mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } });
});
it("accepts media-only sends without message", async () => {
@@ -342,6 +379,96 @@ describe("gateway send mirroring", () => {
);
});
+ it("uses explicit agentId for delivery when sessionKey is not provided", async () => {
+ mockDeliverySuccess("m-agent");
+
+ await runSend({
+ to: "channel:C1",
+ message: "hello",
+ channel: "slack",
+ agentId: "work",
+ idempotencyKey: "idem-agent-explicit",
+ });
+
+ expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: "work",
+ mirror: expect.objectContaining({
+ sessionKey: "agent:work:slack:channel:resolved",
+ agentId: "work",
+ }),
+ }),
+ );
+ });
+
+ it("uses sessionKey agentId when explicit agentId is omitted", async () => {
+ mockDeliverySuccess("m-session-agent");
+
+ await runSend({
+ to: "channel:C1",
+ message: "hello",
+ channel: "slack",
+ sessionKey: "agent:work:slack:channel:c1",
+ idempotencyKey: "idem-session-agent",
+ });
+
+ expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: "work",
+ mirror: expect.objectContaining({
+ sessionKey: "agent:work:slack:channel:c1",
+ agentId: "work",
+ }),
+ }),
+ );
+ });
+
+ it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => {
+ mockDeliverySuccess("m-agent-precedence");
+
+ await runSend({
+ to: "channel:C1",
+ message: "hello",
+ channel: "slack",
+ agentId: "work",
+ sessionKey: "agent:main:slack:channel:c1",
+ idempotencyKey: "idem-agent-precedence",
+ });
+
+ expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: "work",
+ mirror: expect.objectContaining({
+ sessionKey: "agent:main:slack:channel:c1",
+ agentId: "work",
+ }),
+ }),
+ );
+ });
+
+ it("ignores blank explicit agentId and falls back to sessionKey agent", async () => {
+ mockDeliverySuccess("m-agent-blank");
+
+ await runSend({
+ to: "channel:C1",
+ message: "hello",
+ channel: "slack",
+ agentId: " ",
+ sessionKey: "agent:work:slack:channel:c1",
+ idempotencyKey: "idem-agent-blank",
+ });
+
+ expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agentId: "work",
+ mirror: expect.objectContaining({
+ sessionKey: "agent:work:slack:channel:c1",
+ agentId: "work",
+ }),
+ }),
+ );
+ });
+
it("forwards threadId to outbound delivery when provided", async () => {
mockDeliverySuccess("m-thread");
@@ -385,4 +512,39 @@ describe("gateway send mirroring", () => {
}),
);
});
+
+ it("recovers cold plugin resolution for telegram threaded sends", async () => {
+ mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" });
+ mocks.deliverOutboundPayloads.mockResolvedValue([
+ { messageId: "m-telegram", channel: "telegram" },
+ ]);
+ const telegramPlugin = { outbound: { sendPoll: mocks.sendPoll } };
+ mocks.getChannelPlugin
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(telegramPlugin)
+ .mockReturnValue(telegramPlugin);
+
+ const { respond } = await runSend({
+ to: "123",
+ message: "forum completion",
+ channel: "telegram",
+ threadId: "42",
+ idempotencyKey: "idem-cold-telegram-thread",
+ });
+
+ expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
+ expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channel: "telegram",
+ to: "123",
+ threadId: "42",
+ }),
+ );
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({ messageId: "m-telegram" }),
+ undefined,
+ expect.objectContaining({ channel: "telegram" }),
+ );
+ });
});
diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts
index c404a47032a..f398d94aae4 100644
--- a/src/gateway/server-methods/send.ts
+++ b/src/gateway/server-methods/send.ts
@@ -1,7 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
-import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
+import { normalizeChannelId } from "../../channels/plugins/index.js";
import { createOutboundSendDeps } from "../../cli/deps.js";
import { loadConfig } from "../../config/config.js";
+import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import {
@@ -106,6 +107,7 @@ export const sendHandlers: GatewayRequestHandlers = {
gifPlayback?: boolean;
channel?: string;
accountId?: string;
+ agentId?: string;
threadId?: string;
sessionKey?: string;
idempotencyKey: string;
@@ -165,7 +167,7 @@ export const sendHandlers: GatewayRequestHandlers = {
? request.threadId.trim()
: undefined;
const outboundChannel = channel;
- const plugin = getChannelPlugin(channel);
+ const plugin = resolveOutboundChannelPlugin({ channel, cfg });
if (!plugin) {
respond(
false,
@@ -206,13 +208,21 @@ export const sendHandlers: GatewayRequestHandlers = {
typeof request.sessionKey === "string" && request.sessionKey.trim()
? request.sessionKey.trim().toLowerCase()
: undefined;
- const derivedAgentId = resolveSessionAgentId({ config: cfg });
+ const explicitAgentId =
+ typeof request.agentId === "string" && request.agentId.trim()
+ ? request.agentId.trim()
+ : undefined;
+ const sessionAgentId = providedSessionKey
+ ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg })
+ : undefined;
+ const defaultAgentId = resolveSessionAgentId({ config: cfg });
+ const effectiveAgentId = explicitAgentId ?? sessionAgentId ?? defaultAgentId;
// If callers omit sessionKey, derive a target session key from the outbound route.
const derivedRoute = !providedSessionKey
? await resolveOutboundSessionRoute({
cfg,
channel,
- agentId: derivedAgentId,
+ agentId: effectiveAgentId,
accountId,
target: resolved.to,
threadId,
@@ -221,7 +231,7 @@ export const sendHandlers: GatewayRequestHandlers = {
if (derivedRoute) {
await ensureOutboundSessionEntry({
cfg,
- agentId: derivedAgentId,
+ agentId: effectiveAgentId,
channel,
accountId,
route: derivedRoute,
@@ -233,23 +243,21 @@ export const sendHandlers: GatewayRequestHandlers = {
to: resolved.to,
accountId,
payloads: [{ text: message, mediaUrl, mediaUrls }],
- agentId: providedSessionKey
- ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg })
- : derivedAgentId,
+ agentId: effectiveAgentId,
gifPlayback: request.gifPlayback,
threadId: threadId ?? null,
deps: outboundDeps,
mirror: providedSessionKey
? {
sessionKey: providedSessionKey,
- agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }),
+ agentId: effectiveAgentId,
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}
: derivedRoute
? {
sessionKey: derivedRoute.sessionKey,
- agentId: derivedAgentId,
+ agentId: effectiveAgentId,
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}
@@ -386,7 +394,7 @@ export const sendHandlers: GatewayRequestHandlers = {
? request.accountId.trim()
: undefined;
try {
- const plugin = getChannelPlugin(channel);
+ const plugin = resolveOutboundChannelPlugin({ channel, cfg });
const outbound = plugin?.outbound;
if (!outbound?.sendPoll) {
respond(
diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts
index 9c14794a58e..f03235daddf 100644
--- a/src/gateway/server-ws-runtime.ts
+++ b/src/gateway/server-ws-runtime.ts
@@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
+ /** Browser-origin fallback limiter (loopback is never exempt). */
+ browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
logGateway: ReturnType;
@@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: {
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,
rateLimiter: params.rateLimiter,
+ browserRateLimiter: params.browserRateLimiter,
gatewayMethods: params.gatewayMethods,
events: params.events,
logGateway: params.logGateway,
diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts
new file mode 100644
index 00000000000..070addbdc53
--- /dev/null
+++ b/src/gateway/server.auth.browser-hardening.test.ts
@@ -0,0 +1,155 @@
+import { randomUUID } from "node:crypto";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, test } from "vitest";
+import { WebSocket } from "ws";
+import {
+ loadOrCreateDeviceIdentity,
+ publicKeyRawBase64UrlFromPem,
+ signDevicePayload,
+} from "../infra/device-identity.js";
+import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
+import { buildDeviceAuthPayload } from "./device-auth.js";
+import {
+ connectReq,
+ installGatewayTestHooks,
+ readConnectChallengeNonce,
+ testState,
+ trackConnectChallengeNonce,
+ withGatewayServer,
+} from "./test-helpers.js";
+
+installGatewayTestHooks({ scope: "suite" });
+
+const TEST_OPERATOR_CLIENT = {
+ id: GATEWAY_CLIENT_NAMES.TEST,
+ version: "1.0.0",
+ platform: "test",
+ mode: GATEWAY_CLIENT_MODES.TEST,
+};
+
+const originForPort = (port: number) => `http://127.0.0.1:${port}`;
+
+const openWs = async (port: number, headers?: Record) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined);
+ trackConnectChallengeNonce(ws);
+ await new Promise((resolve) => ws.once("open", resolve));
+ return ws;
+};
+
+async function createSignedDevice(params: {
+ token: string;
+ scopes: string[];
+ clientId: string;
+ clientMode: string;
+ identityPath?: string;
+ nonce: string;
+ signedAtMs?: number;
+}) {
+ const identity = params.identityPath
+ ? loadOrCreateDeviceIdentity(params.identityPath)
+ : loadOrCreateDeviceIdentity();
+ const signedAtMs = params.signedAtMs ?? Date.now();
+ const payload = buildDeviceAuthPayload({
+ deviceId: identity.deviceId,
+ clientId: params.clientId,
+ clientMode: params.clientMode,
+ role: "operator",
+ scopes: params.scopes,
+ signedAtMs,
+ token: params.token,
+ nonce: params.nonce,
+ });
+ return {
+ identity,
+ device: {
+ id: identity.deviceId,
+ publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
+ signature: signDevicePayload(identity.privateKeyPem, payload),
+ signedAt: signedAtMs,
+ nonce: params.nonce,
+ },
+ };
+}
+
+describe("gateway auth browser hardening", () => {
+ test("rejects non-local browser origins for non-control-ui clients", async () => {
+ testState.gatewayAuth = { mode: "token", token: "secret" };
+ await withGatewayServer(async ({ port }) => {
+ const ws = await openWs(port, { origin: "https://attacker.example" });
+ try {
+ const res = await connectReq(ws, {
+ token: "secret",
+ client: TEST_OPERATOR_CLIENT,
+ });
+ expect(res.ok).toBe(false);
+ expect(res.error?.message ?? "").toContain("origin not allowed");
+ } finally {
+ ws.close();
+ }
+ });
+ });
+
+ test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => {
+ testState.gatewayAuth = {
+ mode: "token",
+ token: "secret",
+ rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true },
+ };
+ await withGatewayServer(async ({ port }) => {
+ const firstWs = await openWs(port, { origin: originForPort(port) });
+ try {
+ const first = await connectReq(firstWs, { token: "wrong" });
+ expect(first.ok).toBe(false);
+ expect(first.error?.message ?? "").not.toContain("retry later");
+ } finally {
+ firstWs.close();
+ }
+
+ const secondWs = await openWs(port, { origin: originForPort(port) });
+ try {
+ const second = await connectReq(secondWs, { token: "wrong" });
+ expect(second.ok).toBe(false);
+ expect(second.error?.message ?? "").toContain("retry later");
+ } finally {
+ secondWs.close();
+ }
+ });
+ });
+
+ test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
+ const { listDevicePairing } = await import("../infra/device-pairing.js");
+ testState.gatewayAuth = { mode: "token", token: "secret" };
+
+ await withGatewayServer(async ({ port }) => {
+ const browserWs = await openWs(port, { origin: originForPort(port) });
+ try {
+ const nonce = await readConnectChallengeNonce(browserWs);
+ expect(typeof nonce).toBe("string");
+ const { identity, device } = await createSignedDevice({
+ token: "secret",
+ scopes: ["operator.admin"],
+ clientId: TEST_OPERATOR_CLIENT.id,
+ clientMode: TEST_OPERATOR_CLIENT.mode,
+ identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`),
+ nonce: String(nonce ?? ""),
+ });
+ const res = await connectReq(browserWs, {
+ token: "secret",
+ scopes: ["operator.admin"],
+ client: TEST_OPERATOR_CLIENT,
+ device,
+ });
+ expect(res.ok).toBe(false);
+ expect(res.error?.message ?? "").toContain("pairing required");
+
+ const pairing = await listDevicePairing();
+ const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
+ expect(pending).toBeTruthy();
+ expect(pending?.silent).toBe(false);
+ } finally {
+ browserWs.close();
+ }
+ });
+ });
+});
diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts
index 8da0e18ef31..a0cbf5d9c1e 100644
--- a/src/gateway/server.auth.test.ts
+++ b/src/gateway/server.auth.test.ts
@@ -105,6 +105,13 @@ const CONTROL_UI_CLIENT = {
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
};
+const TRUSTED_PROXY_CONTROL_UI_HEADERS = {
+ origin: "https://localhost",
+ "x-forwarded-for": "203.0.113.10",
+ "x-forwarded-proto": "https",
+ "x-forwarded-user": "peter@example.com",
+} as const;
+
const NODE_CLIENT = {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
version: "1.0.0",
@@ -131,10 +138,11 @@ async function expectHelloOkServerVersion(port: number, expectedVersion: string)
}
async function createSignedDevice(params: {
- token: string;
+ token?: string | null;
scopes: string[];
clientId: string;
clientMode: string;
+ role?: "operator" | "node";
identityPath?: string;
nonce: string;
signedAtMs?: number;
@@ -149,10 +157,10 @@ async function createSignedDevice(params: {
deviceId: identity.deviceId,
clientId: params.clientId,
clientMode: params.clientMode,
- role: "operator",
+ role: params.role ?? "operator",
scopes: params.scopes,
signedAtMs,
- token: params.token,
+ token: params.token ?? null,
nonce: params.nonce,
});
return {
@@ -187,6 +195,23 @@ async function approvePendingPairingIfNeeded() {
}
}
+async function configureTrustedProxyControlUiAuth() {
+ testState.gatewayAuth = {
+ mode: "trusted-proxy",
+ trustedProxy: {
+ userHeader: "x-forwarded-user",
+ requiredHeaders: ["x-forwarded-proto"],
+ },
+ };
+ const { writeConfigFile } = await import("../config/config.js");
+ await writeConfigFile({
+ gateway: {
+ trustedProxies: ["127.0.0.1"],
+ },
+ // oxlint-disable-next-line typescript/no-explicit-any
+ } as any);
+}
+
function isConnectResMessage(id: string) {
return (o: unknown) => {
if (!o || typeof o !== "object" || Array.isArray(o)) {
@@ -776,6 +801,93 @@ describe("gateway server auth/connect", () => {
});
});
+ const trustedProxyControlUiCases: Array<{
+ name: string;
+ role: "operator" | "node";
+ withUnpairedNodeDevice: boolean;
+ expectedOk: boolean;
+ expectedErrorSubstring?: string;
+ expectedErrorCode?: string;
+ expectStatusChecks: boolean;
+ }> = [
+ {
+ name: "allows trusted-proxy control ui operator without device identity",
+ role: "operator",
+ withUnpairedNodeDevice: false,
+ expectedOk: true,
+ expectStatusChecks: true,
+ },
+ {
+ name: "rejects trusted-proxy control ui node role without device identity",
+ role: "node",
+ withUnpairedNodeDevice: false,
+ expectedOk: false,
+ expectedErrorSubstring: "control ui requires device identity",
+ expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
+ expectStatusChecks: false,
+ },
+ {
+ name: "requires pairing for trusted-proxy control ui node role with unpaired device",
+ role: "node",
+ withUnpairedNodeDevice: true,
+ expectedOk: false,
+ expectedErrorSubstring: "pairing required",
+ expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
+ expectStatusChecks: false,
+ },
+ ];
+
+ for (const tc of trustedProxyControlUiCases) {
+ test(tc.name, async () => {
+ await configureTrustedProxyControlUiAuth();
+ await withGatewayServer(async ({ port }) => {
+ const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
+ const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
+ let device: Awaited>["device"] | null = null;
+ if (tc.withUnpairedNodeDevice) {
+ const challengeNonce = await readConnectChallengeNonce(ws);
+ expect(challengeNonce).toBeTruthy();
+ ({ device } = await createSignedDevice({
+ token: null,
+ role: "node",
+ scopes: [],
+ clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
+ clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
+ nonce: String(challengeNonce),
+ }));
+ }
+ const res = await connectReq(ws, {
+ skipDefaultAuth: true,
+ role: tc.role,
+ scopes,
+ device,
+ client: { ...CONTROL_UI_CLIENT },
+ });
+ expect(res.ok).toBe(tc.expectedOk);
+ if (!tc.expectedOk) {
+ if (tc.expectedErrorSubstring) {
+ expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring);
+ }
+ if (tc.expectedErrorCode) {
+ expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
+ tc.expectedErrorCode,
+ );
+ }
+ ws.close();
+ return;
+ }
+ if (tc.expectStatusChecks) {
+ const status = await rpcReq(ws, "status");
+ expect(status.ok).toBe(false);
+ expect(status.error?.message ?? "").toContain("missing scope");
+ const health = await rpcReq(ws, "health");
+ expect(health.ok).toBe(true);
+ }
+ ws.close();
+ });
+ });
+ }
+
test("allows localhost control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret", {
@@ -1065,7 +1177,7 @@ describe("gateway server auth/connect", () => {
}
});
- test("skips pairing for operator scope upgrades when shared token auth is valid", async () => {
+ test("requires pairing for remote operator device identity with shared token auth", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1102,21 +1214,29 @@ describe("gateway server auth/connect", () => {
nonce,
};
};
- const initialNonce = await readConnectChallengeNonce(ws);
- const initial = await connectReq(ws, {
+ ws.close();
+
+ const wsRemoteRead = await openWs(port, { host: "gateway.example" });
+ const initialNonce = await readConnectChallengeNonce(wsRemoteRead);
+ const initial = await connectReq(wsRemoteRead, {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"], initialNonce),
});
- expect(initial.ok).toBe(true);
+ expect(initial.ok).toBe(false);
+ expect(initial.error?.message ?? "").toContain("pairing required");
let pairing = await listDevicePairing();
- expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
+ const pendingAfterRead = pairing.pending.filter(
+ (entry) => entry.deviceId === identity.deviceId,
+ );
+ expect(pendingAfterRead).toHaveLength(1);
+ expect(pendingAfterRead[0]?.role).toBe("operator");
+ expect(pendingAfterRead[0]?.scopes ?? []).toContain("operator.read");
expect(await getPairedDevice(identity.deviceId)).toBeNull();
+ wsRemoteRead.close();
- ws.close();
-
- const ws2 = await openWs(port);
+ const ws2 = await openWs(port, { host: "gateway.example" });
const nonce2 = await readConnectChallengeNonce(ws2);
const res = await connectReq(ws2, {
token: "secret",
@@ -1124,9 +1244,16 @@ describe("gateway server auth/connect", () => {
client,
device: buildDevice(["operator.admin"], nonce2),
});
- expect(res.ok).toBe(true);
+ expect(res.ok).toBe(false);
+ expect(res.error?.message ?? "").toContain("pairing required");
pairing = await listDevicePairing();
- expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
+ const pendingAfterAdmin = pairing.pending.filter(
+ (entry) => entry.deviceId === identity.deviceId,
+ );
+ expect(pendingAfterAdmin).toHaveLength(1);
+ expect(pendingAfterAdmin[0]?.scopes ?? []).toEqual(
+ expect.arrayContaining(["operator.read", "operator.admin"]),
+ );
expect(await getPairedDevice(identity.deviceId)).toBeNull();
ws2.close();
await server.close();
@@ -1199,7 +1326,7 @@ describe("gateway server auth/connect", () => {
restoreGatewayToken(prevToken);
});
- test("still requires node pairing while operator shared auth succeeds for the same device", async () => {
+ test("merges remote node/operator pairing requests for the same unpaired device", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1266,23 +1393,25 @@ describe("gateway server auth/connect", () => {
expect(nodeConnect.error?.message ?? "").toContain("pairing required");
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
- expect(operatorConnect.ok).toBe(true);
+ expect(operatorConnect.ok).toBe(false);
+ expect(operatorConnect.error?.message ?? "").toContain("pairing required");
const pending = await listDevicePairing();
const pendingForTestDevice = pending.pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingForTestDevice).toHaveLength(1);
- expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node"]));
- expect(pendingForTestDevice[0]?.roles ?? []).not.toContain("operator");
+ expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
+ expect(pendingForTestDevice[0]?.scopes ?? []).toEqual(
+ expect.arrayContaining(["operator.read", "operator.write"]),
+ );
if (!pendingForTestDevice[0]) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pendingForTestDevice[0].requestId);
const paired = await getPairedDevice(identity.deviceId);
- expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
- expect(paired?.roles ?? []).not.toContain("operator");
+ expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
expect(approvedOperatorConnect.ok).toBe(true);
@@ -1438,8 +1567,8 @@ describe("gateway server auth/connect", () => {
expect(reconnect.ok).toBe(true);
const repaired = await getPairedDevice(deviceId);
- expect(repaired?.roles).toBeUndefined();
- expect(repaired?.scopes).toBeUndefined();
+ expect(repaired?.roles ?? []).toContain("operator");
+ expect(repaired?.scopes ?? []).toContain("operator.read");
const list = await listDevicePairing();
expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]);
} finally {
@@ -1450,7 +1579,7 @@ describe("gateway server auth/connect", () => {
}
});
- test("allows shared-auth scope escalation even when paired metadata is legacy-shaped", async () => {
+ test("auto-approves local scope upgrades even when paired metadata is legacy-shaped", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1539,9 +1668,13 @@ describe("gateway server auth/connect", () => {
expect(pendingUpgrade).toBeUndefined();
const repaired = await getPairedDevice(identity.deviceId);
expect(repaired?.role).toBe("operator");
- expect(repaired?.roles).toBeUndefined();
- expect(repaired?.scopes).toBeUndefined();
- expect(repaired?.approvedScopes).not.toContain("operator.admin");
+ expect(repaired?.roles ?? []).toContain("operator");
+ expect(repaired?.scopes ?? []).toEqual(
+ expect.arrayContaining(["operator.read", "operator.admin"]),
+ );
+ expect(repaired?.approvedScopes ?? []).toEqual(
+ expect.arrayContaining(["operator.read", "operator.admin"]),
+ );
} finally {
ws.close();
ws2?.close();
diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts
index fdca08c2677..3dbd86e1e5e 100644
--- a/src/gateway/server.impl.ts
+++ b/src/gateway/server.impl.ts
@@ -110,6 +110,21 @@ const logWsControl = log.child("ws");
const gatewayRuntime = runtimeForLogger(log);
const canvasRuntime = runtimeForLogger(logCanvas);
+type AuthRateLimitConfig = Parameters[0];
+
+function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): {
+ rateLimiter?: AuthRateLimiter;
+ browserRateLimiter: AuthRateLimiter;
+} {
+ const rateLimiter = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined;
+ // Browser-origin WS auth attempts always use loopback-non-exempt throttling.
+ const browserRateLimiter = createAuthRateLimiter({
+ ...rateLimitConfig,
+ exemptLoopback: false,
+ });
+ return { rateLimiter, browserRateLimiter };
+}
+
export type GatewayServer = {
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise;
};
@@ -311,11 +326,10 @@ export async function startGatewayServer(
let hooksConfig = runtimeConfig.hooksConfig;
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
- // Create auth rate limiter only when explicitly configured.
+ // Create auth rate limiters used by connect/auth flows.
const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit;
- const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig
- ? createAuthRateLimiter(rateLimitConfig)
- : undefined;
+ const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } =
+ createGatewayAuthRateLimiters(rateLimitConfig);
let controlUiRootState: ControlUiRootState | undefined;
if (controlUiRootOverride) {
@@ -574,6 +588,7 @@ export async function startGatewayServer(
canvasHostServerPort,
resolvedAuth,
rateLimiter: authRateLimiter,
+ browserRateLimiter: browserAuthRateLimiter,
gatewayMethods,
events: GATEWAY_EVENTS,
logGateway: log,
@@ -777,6 +792,7 @@ export async function startGatewayServer(
}
skillsChangeUnsub();
authRateLimiter?.dispose();
+ browserAuthRateLimiter.dispose();
channelHealthMonitor?.stop();
await close(opts);
},
diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts
index 25568d4803e..79093169c6a 100644
--- a/src/gateway/server.plugin-http-auth.test.ts
+++ b/src/gateway/server.plugin-http-auth.test.ts
@@ -1,7 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, test, vi } from "vitest";
+import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedGatewayAuth } from "./auth.js";
-import { createGatewayHttpServer } from "./server-http.js";
+import type { HooksConfigResolved } from "./hooks.js";
+import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";
function createRequest(params: {
@@ -65,6 +67,25 @@ async function dispatchRequest(
await new Promise((resolve) => setImmediate(resolve));
}
+function createHooksConfig(): HooksConfigResolved {
+ return {
+ basePath: "/hooks",
+ token: "hook-secret",
+ maxBodyBytes: 1024,
+ mappings: [],
+ agentPolicy: {
+ defaultAgentId: "main",
+ knownAgentIds: new Set(["main"]),
+ allowedAgentIds: undefined,
+ },
+ sessionPolicy: {
+ allowRequestSessionKey: false,
+ defaultSessionKey: undefined,
+ allowedSessionKeyPrefixes: undefined,
+ },
+ };
+}
+
describe("gateway plugin HTTP auth boundary", () => {
test("applies default security headers and optional strict transport security", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
@@ -220,4 +241,101 @@ describe("gateway plugin HTTP auth boundary", () => {
},
});
});
+
+ test.each(["0.0.0.0", "::"])(
+ "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s",
+ async (bindHost) => {
+ const resolvedAuth: ResolvedGatewayAuth = {
+ mode: "none",
+ token: undefined,
+ password: undefined,
+ allowTailscale: false,
+ };
+
+ await withTempConfig({
+ cfg: { gateway: { trustedProxies: [] } },
+ prefix: "openclaw-plugin-http-hooks-bindhost-",
+ run: async () => {
+ const handleHooksRequest = createHooksRequestHandler({
+ getHooksConfig: () => createHooksConfig(),
+ bindHost,
+ port: 18789,
+ logHooks: {
+ warn: vi.fn(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ } as unknown as ReturnType,
+ dispatchWakeHook: () => {},
+ dispatchAgentHook: () => "run-1",
+ });
+ const server = createGatewayHttpServer({
+ canvasHost: null,
+ clients: new Set(),
+ controlUiEnabled: false,
+ controlUiBasePath: "/__control__",
+ openAiChatCompletionsEnabled: false,
+ openResponsesEnabled: false,
+ handleHooksRequest,
+ resolvedAuth,
+ });
+
+ const response = createResponse();
+ await dispatchRequest(server, createRequest({ path: "/" }), response.res);
+
+ expect(response.res.statusCode).toBe(404);
+ expect(response.getBody()).toBe("Not Found");
+ },
+ });
+ },
+ );
+
+ test("rejects query-token hooks requests with bindHost=::", async () => {
+ const resolvedAuth: ResolvedGatewayAuth = {
+ mode: "none",
+ token: undefined,
+ password: undefined,
+ allowTailscale: false,
+ };
+
+ await withTempConfig({
+ cfg: { gateway: { trustedProxies: [] } },
+ prefix: "openclaw-plugin-http-hooks-query-token-",
+ run: async () => {
+ const handleHooksRequest = createHooksRequestHandler({
+ getHooksConfig: () => createHooksConfig(),
+ bindHost: "::",
+ port: 18789,
+ logHooks: {
+ warn: vi.fn(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ } as unknown as ReturnType,
+ dispatchWakeHook: () => {},
+ dispatchAgentHook: () => "run-1",
+ });
+ const server = createGatewayHttpServer({
+ canvasHost: null,
+ clients: new Set(),
+ controlUiEnabled: false,
+ controlUiBasePath: "/__control__",
+ openAiChatCompletionsEnabled: false,
+ openResponsesEnabled: false,
+ handleHooksRequest,
+ resolvedAuth,
+ });
+
+ const response = createResponse();
+ await dispatchRequest(
+ server,
+ createRequest({ path: "/hooks/wake?token=bad" }),
+ response.res,
+ );
+
+ expect(response.res.statusCode).toBe(400);
+ expect(response.getBody()).toContain("Hook token must be provided");
+ },
+ });
+ });
});
diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts
index e7c9d458f8f..3abc8d6e1b9 100644
--- a/src/gateway/server/ws-connection.ts
+++ b/src/gateway/server/ws-connection.ts
@@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
+ /** Browser-origin fallback limiter (loopback is never exempt). */
+ browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
logGateway: SubsystemLogger;
@@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: {
canvasHostServerPort,
resolvedAuth,
rateLimiter,
+ browserRateLimiter,
gatewayMethods,
events,
logGateway,
@@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: {
connectNonce,
resolvedAuth,
rateLimiter,
+ browserRateLimiter,
gatewayMethods,
events,
extraHandlers,
diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts
index 320f90537ce..88813663a85 100644
--- a/src/gateway/server/ws-connection/connect-policy.test.ts
+++ b/src/gateway/server/ws-connection/connect-policy.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
evaluateMissingDeviceIdentity,
+ isTrustedProxyControlUiOperatorAuth,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
@@ -186,4 +187,55 @@ describe("ws connect policy", () => {
expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true);
});
+
+ test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
+ const cases: Array<{
+ role: "operator" | "node";
+ authMode: string;
+ authOk: boolean;
+ authMethod: string | undefined;
+ expected: boolean;
+ }> = [
+ {
+ role: "operator",
+ authMode: "trusted-proxy",
+ authOk: true,
+ authMethod: "trusted-proxy",
+ expected: true,
+ },
+ {
+ role: "node",
+ authMode: "trusted-proxy",
+ authOk: true,
+ authMethod: "trusted-proxy",
+ expected: false,
+ },
+ {
+ role: "operator",
+ authMode: "token",
+ authOk: true,
+ authMethod: "token",
+ expected: false,
+ },
+ {
+ role: "operator",
+ authMode: "trusted-proxy",
+ authOk: false,
+ authMethod: "trusted-proxy",
+ expected: false,
+ },
+ ];
+
+ for (const tc of cases) {
+ expect(
+ isTrustedProxyControlUiOperatorAuth({
+ isControlUi: true,
+ role: tc.role,
+ authMode: tc.authMode,
+ authOk: tc.authOk,
+ authMethod: tc.authMethod,
+ }),
+ ).toBe(tc.expected);
+ }
+ });
});
diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts
index 70dbea07505..f2467aedc98 100644
--- a/src/gateway/server/ws-connection/connect-policy.ts
+++ b/src/gateway/server/ws-connection/connect-policy.ts
@@ -43,6 +43,22 @@ export function shouldSkipControlUiPairing(
return policy.allowBypass && sharedAuthOk;
}
+export function isTrustedProxyControlUiOperatorAuth(params: {
+ isControlUi: boolean;
+ role: GatewayRole;
+ authMode: string;
+ authOk: boolean;
+ authMethod: string | undefined;
+}): boolean {
+ return (
+ params.isControlUi &&
+ params.role === "operator" &&
+ params.authMode === "trusted-proxy" &&
+ params.authOk &&
+ params.authMethod === "trusted-proxy"
+ );
+}
+
export type MissingDeviceIdentityDecision =
| { kind: "allow" }
| { kind: "reject-control-ui-insecure-auth" }
diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts
index 191278275ee..261e9f69da2 100644
--- a/src/gateway/server/ws-connection/message-handler.ts
+++ b/src/gateway/server/ws-connection/message-handler.ts
@@ -75,6 +75,7 @@ import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-cont
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
+ isTrustedProxyControlUiOperatorAuth,
resolveControlUiAuthPolicy,
shouldSkipControlUiPairing,
} from "./connect-policy.js";
@@ -83,6 +84,52 @@ import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-
type SubsystemLogger = ReturnType;
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
+const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
+
+type HandshakeBrowserSecurityContext = {
+ hasBrowserOriginHeader: boolean;
+ enforceOriginCheckForAnyClient: boolean;
+ rateLimitClientIp: string | undefined;
+ authRateLimiter?: AuthRateLimiter;
+};
+
+function resolveHandshakeBrowserSecurityContext(params: {
+ requestOrigin?: string;
+ hasProxyHeaders: boolean;
+ clientIp: string | undefined;
+ rateLimiter?: AuthRateLimiter;
+ browserRateLimiter?: AuthRateLimiter;
+}): HandshakeBrowserSecurityContext {
+ const hasBrowserOriginHeader = Boolean(
+ params.requestOrigin && params.requestOrigin.trim() !== "",
+ );
+ return {
+ hasBrowserOriginHeader,
+ enforceOriginCheckForAnyClient: hasBrowserOriginHeader && !params.hasProxyHeaders,
+ rateLimitClientIp:
+ hasBrowserOriginHeader && isLoopbackAddress(params.clientIp)
+ ? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP
+ : params.clientIp,
+ authRateLimiter:
+ hasBrowserOriginHeader && params.browserRateLimiter
+ ? params.browserRateLimiter
+ : params.rateLimiter,
+ };
+}
+
+function shouldAllowSilentLocalPairing(params: {
+ isLocalClient: boolean;
+ hasBrowserOriginHeader: boolean;
+ isControlUi: boolean;
+ isWebchat: boolean;
+ reason: "not-paired" | "role-upgrade" | "scope-upgrade";
+}): boolean {
+ return (
+ params.isLocalClient &&
+ (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) &&
+ (params.reason === "not-paired" || params.reason === "scope-upgrade")
+ );
+}
export function attachGatewayWsMessageHandler(params: {
socket: WebSocket;
@@ -99,6 +146,8 @@ export function attachGatewayWsMessageHandler(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
+ /** Browser-origin fallback limiter (loopback is never exempt). */
+ browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
extraHandlers: GatewayRequestHandlers;
@@ -130,6 +179,7 @@ export function attachGatewayWsMessageHandler(params: {
connectNonce,
resolvedAuth,
rateLimiter,
+ browserRateLimiter,
gatewayMethods,
events,
extraHandlers,
@@ -192,6 +242,19 @@ export function attachGatewayWsMessageHandler(params: {
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
+ const browserSecurity = resolveHandshakeBrowserSecurityContext({
+ requestOrigin,
+ hasProxyHeaders,
+ clientIp,
+ rateLimiter,
+ browserRateLimiter,
+ });
+ const {
+ hasBrowserOriginHeader,
+ enforceOriginCheckForAnyClient,
+ rateLimitClientIp: browserRateLimitClientIp,
+ authRateLimiter,
+ } = browserSecurity;
socket.on("message", async (data) => {
if (isClosed()) {
@@ -329,7 +392,7 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isWebchat = isWebchatConnect(connectParams);
- if (isControlUi || isWebchat) {
+ if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) {
const originCheck = checkBrowserOrigin({
requestHost,
origin: requestOrigin,
@@ -377,8 +440,8 @@ export function attachGatewayWsMessageHandler(params: {
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
- rateLimiter,
- clientIp,
+ rateLimiter: authRateLimiter,
+ clientIp: browserRateLimitClientIp,
});
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
markHandshakeFailure("unauthorized", {
@@ -427,11 +490,13 @@ export function attachGatewayWsMessageHandler(params: {
if (!device) {
clearUnboundScopes();
}
- const trustedProxyAuthOk =
- isControlUi &&
- resolvedAuth.mode === "trusted-proxy" &&
- authOk &&
- authMethod === "trusted-proxy";
+ const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
+ isControlUi,
+ role,
+ authMode: resolvedAuth.mode,
+ authOk,
+ authMethod,
+ });
const decision = evaluateMissingDeviceIdentity({
hasDeviceIdentity: Boolean(device),
role,
@@ -556,8 +621,8 @@ export function attachGatewayWsMessageHandler(params: {
deviceId: device?.id,
role,
scopes,
- rateLimiter,
- clientIp,
+ rateLimiter: authRateLimiter,
+ clientIp: browserRateLimitClientIp,
verifyDeviceToken,
}));
if (!authOk) {
@@ -565,18 +630,18 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
- // Shared token/password auth is already gateway-level trust for operator clients.
- // In that case, don't force device pairing on first connect.
- const skipPairingForOperatorSharedAuth =
- role === "operator" && sharedAuthOk && !isControlUi && !isWebchat;
- const trustedProxyAuthOk =
- isControlUi &&
- resolvedAuth.mode === "trusted-proxy" &&
- authOk &&
- authMethod === "trusted-proxy";
- const skipPairing =
- shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) ||
- skipPairingForOperatorSharedAuth;
+ const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
+ isControlUi,
+ role,
+ authMode: resolvedAuth.mode,
+ authOk,
+ authMethod,
+ });
+ const skipPairing = shouldSkipControlUiPairing(
+ controlUiAuthPolicy,
+ sharedAuthOk,
+ trustedProxyAuthOk,
+ );
if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => {
if (!items || items.length === 0) {
@@ -615,11 +680,18 @@ export function attachGatewayWsMessageHandler(params: {
const requirePairing = async (
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
) => {
+ const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
+ isLocalClient,
+ hasBrowserOriginHeader,
+ isControlUi,
+ isWebchat,
+ reason,
+ });
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientAccessMetadata,
- silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
+ silent: allowSilentLocalPairing,
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
diff --git a/src/infra/abort-signal.test.ts b/src/infra/abort-signal.test.ts
new file mode 100644
index 00000000000..be32e0d881a
--- /dev/null
+++ b/src/infra/abort-signal.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { waitForAbortSignal } from "./abort-signal.js";
+
+describe("waitForAbortSignal", () => {
+ it("resolves immediately when signal is missing", async () => {
+ await expect(waitForAbortSignal(undefined)).resolves.toBeUndefined();
+ });
+
+ it("resolves immediately when signal is already aborted", async () => {
+ const abort = new AbortController();
+ abort.abort();
+ await expect(waitForAbortSignal(abort.signal)).resolves.toBeUndefined();
+ });
+
+ it("waits until abort fires", async () => {
+ const abort = new AbortController();
+ let resolved = false;
+
+ const task = waitForAbortSignal(abort.signal).then(() => {
+ resolved = true;
+ });
+ await Promise.resolve();
+ expect(resolved).toBe(false);
+
+ abort.abort();
+ await task;
+ expect(resolved).toBe(true);
+ });
+});
diff --git a/src/infra/abort-signal.ts b/src/infra/abort-signal.ts
new file mode 100644
index 00000000000..77922784eda
--- /dev/null
+++ b/src/infra/abort-signal.ts
@@ -0,0 +1,12 @@
+export async function waitForAbortSignal(signal?: AbortSignal): Promise {
+ if (!signal || signal.aborted) {
+ return;
+ }
+ await new Promise((resolve) => {
+ const onAbort = () => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+}
diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts
index be4264e22ec..d78f3d137e9 100644
--- a/src/infra/exec-approvals.ts
+++ b/src/infra/exec-approvals.ts
@@ -11,19 +11,22 @@ export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
+export type ExecApprovalRequestPayload = {
+ command: string;
+ commandArgv?: string[];
+ cwd?: string | null;
+ nodeId?: string | null;
+ host?: string | null;
+ security?: string | null;
+ ask?: string | null;
+ agentId?: string | null;
+ resolvedPath?: string | null;
+ sessionKey?: string | null;
+};
+
export type ExecApprovalRequest = {
id: string;
- request: {
- command: string;
- cwd?: string | null;
- nodeId?: string | null;
- host?: string | null;
- security?: string | null;
- ask?: string | null;
- agentId?: string | null;
- resolvedPath?: string | null;
- sessionKey?: string | null;
- };
+ request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
};
diff --git a/src/infra/hardlink-guards.ts b/src/infra/hardlink-guards.ts
new file mode 100644
index 00000000000..ad99729b463
--- /dev/null
+++ b/src/infra/hardlink-guards.ts
@@ -0,0 +1,38 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import { isNotFoundPathError } from "./path-guards.js";
+
+export async function assertNoHardlinkedFinalPath(params: {
+ filePath: string;
+ root: string;
+ boundaryLabel: string;
+ allowFinalHardlinkForUnlink?: boolean;
+}): Promise {
+ if (params.allowFinalHardlinkForUnlink) {
+ return;
+ }
+ let stat: Awaited>;
+ try {
+ stat = await fs.stat(params.filePath);
+ } catch (err) {
+ if (isNotFoundPathError(err)) {
+ return;
+ }
+ throw err;
+ }
+ if (!stat.isFile()) {
+ return;
+ }
+ if (stat.nlink > 1) {
+ throw new Error(
+ `Hardlinked path is not allowed under ${params.boundaryLabel} (${shortPath(params.root)}): ${shortPath(params.filePath)}`,
+ );
+ }
+}
+
+function shortPath(value: string) {
+ if (value.startsWith(os.homedir())) {
+ return `~${value.slice(os.homedir().length)}`;
+ }
+ return value;
+}
diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts
index 0ec2afcafdd..c4f45b5e039 100644
--- a/src/infra/heartbeat-runner.returns-default-unset.test.ts
+++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts
@@ -325,6 +325,30 @@ describe("resolveHeartbeatDeliveryTarget", () => {
lastAccountId: undefined,
},
},
+ {
+ name: "allow direct target by default",
+ cfg: { agents: { defaults: { heartbeat: { target: "last" } } } },
+ entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" },
+ expected: {
+ channel: "telegram",
+ to: "5232990709",
+ accountId: undefined,
+ lastChannel: "telegram",
+ lastAccountId: undefined,
+ },
+ },
+ {
+ name: "block direct target when directPolicy is block",
+ cfg: { agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } } } },
+ entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" },
+ expected: {
+ channel: "none",
+ reason: "dm-blocked",
+ accountId: undefined,
+ lastChannel: "telegram",
+ lastAccountId: undefined,
+ },
+ },
];
for (const testCase of cases) {
expect(
diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts
index a03afba325f..223695c1a53 100644
--- a/src/infra/net/fetch-guard.ssrf.test.ts
+++ b/src/infra/net/fetch-guard.ssrf.test.ts
@@ -18,6 +18,7 @@ describe("fetchWithSsrFGuard hardening", () => {
it("blocks private and legacy loopback literals before fetch", async () => {
const blockedUrls = [
"http://127.0.0.1:8080/internal",
+ "http://[ff02::1]/internal",
"http://0177.0.0.1:8080/internal",
"http://0x7f000001/internal",
];
diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts
index 5826669196d..2698bf3db9e 100644
--- a/src/infra/net/ssrf.test.ts
+++ b/src/infra/net/ssrf.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js";
import { normalizeFingerprint } from "../tls/fingerprint.js";
import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js";
@@ -38,6 +39,7 @@ const privateIpCases = [
"fe80::1%lo0",
"fd00::1",
"fec0::1",
+ ...blockedIpv6MulticastLiterals,
"2001:db8:1234::5efe:127.0.0.1",
"2001:db8:1234:1:200:5efe:7f00:1",
];
diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts
index b84469390c0..8ba29b38e2a 100644
--- a/src/infra/net/ssrf.ts
+++ b/src/infra/net/ssrf.ts
@@ -4,11 +4,11 @@ import { Agent, type Dispatcher } from "undici";
import {
extractEmbeddedIpv4FromIpv6,
isBlockedSpecialUseIpv4Address,
+ isBlockedSpecialUseIpv6Address,
isCanonicalDottedDecimalIPv4,
type Ipv4SpecialUseBlockOptions,
isIpv4Address,
isLegacyIpv4Literal,
- isPrivateOrLoopbackIpAddress,
parseCanonicalIpAddress,
parseLooseIpAddress,
} from "../../shared/net/ip.js";
@@ -120,7 +120,7 @@ export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolea
if (isIpv4Address(strictIp)) {
return isBlockedSpecialUseIpv4Address(strictIp, blockOptions);
}
- if (isPrivateOrLoopbackIpAddress(strictIp.toString())) {
+ if (isBlockedSpecialUseIpv6Address(strictIp)) {
return true;
}
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);
diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts
new file mode 100644
index 00000000000..8d17294d024
--- /dev/null
+++ b/src/infra/outbound/channel-resolution.ts
@@ -0,0 +1,78 @@
+import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
+import { getChannelPlugin } from "../../channels/plugins/index.js";
+import type { ChannelPlugin } from "../../channels/plugins/types.js";
+import type { OpenClawConfig } from "../../config/config.js";
+import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
+import { loadOpenClawPlugins } from "../../plugins/loader.js";
+import { getActivePluginRegistry, getActivePluginRegistryKey } from "../../plugins/runtime.js";
+import {
+ isDeliverableMessageChannel,
+ normalizeMessageChannel,
+ type DeliverableMessageChannel,
+} from "../../utils/message-channel.js";
+
+const bootstrapAttempts = new Set();
+
+export function normalizeDeliverableOutboundChannel(
+ raw?: string | null,
+): DeliverableMessageChannel | undefined {
+ const normalized = normalizeMessageChannel(raw);
+ if (!normalized || !isDeliverableMessageChannel(normalized)) {
+ return undefined;
+ }
+ return normalized;
+}
+
+function maybeBootstrapChannelPlugin(params: {
+ channel: DeliverableMessageChannel;
+ cfg?: OpenClawConfig;
+}): void {
+ const cfg = params.cfg;
+ if (!cfg) {
+ return;
+ }
+
+ const activeRegistry = getActivePluginRegistry();
+ if ((activeRegistry?.channels?.length ?? 0) > 0) {
+ return;
+ }
+
+ const registryKey = getActivePluginRegistryKey() ?? "";
+ const attemptKey = `${registryKey}:${params.channel}`;
+ if (bootstrapAttempts.has(attemptKey)) {
+ return;
+ }
+ bootstrapAttempts.add(attemptKey);
+
+ const autoEnabled = applyPluginAutoEnable({ config: cfg }).config;
+ const defaultAgentId = resolveDefaultAgentId(autoEnabled);
+ const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId);
+ try {
+ loadOpenClawPlugins({
+ config: autoEnabled,
+ workspaceDir,
+ });
+ } catch {
+ // Allow a follow-up resolution attempt if bootstrap failed transiently.
+ bootstrapAttempts.delete(attemptKey);
+ }
+}
+
+export function resolveOutboundChannelPlugin(params: {
+ channel: string;
+ cfg?: OpenClawConfig;
+}): ChannelPlugin | undefined {
+ const normalized = normalizeDeliverableOutboundChannel(params.channel);
+ if (!normalized) {
+ return undefined;
+ }
+
+ const resolve = () => getChannelPlugin(normalized);
+ const current = resolve();
+ if (current) {
+ return current;
+ }
+
+ maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
+ return resolve();
+}
diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts
index 6fdec33ab49..cf3ddabcead 100644
--- a/src/infra/outbound/message-action-runner.test.ts
+++ b/src/infra/outbound/message-action-runner.test.ts
@@ -1021,4 +1021,32 @@ describe("runMessageAction accountId defaults", () => {
expect(ctx.accountId).toBe("ops");
expect(ctx.params.accountId).toBe("ops");
});
+
+ it("falls back to the agent's bound account when accountId is omitted", async () => {
+ await runMessageAction({
+ cfg: {
+ bindings: [{ agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } }],
+ } as OpenClawConfig,
+ action: "send",
+ params: {
+ channel: "discord",
+ target: "channel:123",
+ message: "hi",
+ },
+ agentId: "agent-b",
+ });
+
+ expect(handleAction).toHaveBeenCalled();
+ const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as
+ | {
+ accountId?: string | null;
+ params: Record;
+ }
+ | undefined;
+ if (!ctx) {
+ throw new Error("expected action context");
+ }
+ expect(ctx.accountId).toBe("account-b");
+ expect(ctx.params.accountId).toBe("account-b");
+ });
});
diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts
index 57032e27de8..2693d110306 100644
--- a/src/infra/outbound/message-action-runner.ts
+++ b/src/infra/outbound/message-action-runner.ts
@@ -14,6 +14,8 @@ import type {
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
+import { buildChannelAccountBindings } from "../../routing/bindings.js";
+import { normalizeAgentId } from "../../routing/session-key.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
@@ -753,7 +755,14 @@ export async function runMessageAction(
}
const channel = await resolveChannel(cfg, params);
- const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
+ let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
+ if (!accountId && resolvedAgentId) {
+ const byAgent = buildChannelAccountBindings(cfg).get(channel);
+ const boundAccountIds = byAgent?.get(normalizeAgentId(resolvedAgentId));
+ if (boundAccountIds && boundAccountIds.length > 0) {
+ accountId = boundAccountIds[0];
+ }
+ }
if (accountId) {
params.accountId = accountId;
}
diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts
index 39e83c8ad70..12b9b120f66 100644
--- a/src/infra/outbound/message.channels.test.ts
+++ b/src/infra/outbound/message.channels.test.ts
@@ -194,6 +194,35 @@ describe("gateway url override hardening", () => {
}),
);
});
+
+ it("forwards explicit agentId in gateway send params", async () => {
+ setRegistry(
+ createTestRegistry([
+ {
+ pluginId: "mattermost",
+ source: "test",
+ plugin: {
+ ...createMattermostLikePlugin({ onSendText: () => {} }),
+ outbound: { deliveryMode: "gateway" },
+ },
+ },
+ ]),
+ );
+
+ callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" });
+ await sendMessage({
+ cfg: {},
+ to: "channel:town-square",
+ content: "hi",
+ channel: "mattermost",
+ agentId: "work",
+ });
+
+ const call = callGatewayMock.mock.calls[0]?.[0] as {
+ params?: Record;
+ };
+ expect(call.params?.agentId).toBe("work");
+ });
});
const emptyRegistry = createTestRegistry([]);
diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts
index 3714e7ab5ac..d6fab2e39dc 100644
--- a/src/infra/outbound/message.test.ts
+++ b/src/infra/outbound/message.test.ts
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
resolveOutboundTarget: vi.fn(),
deliverOutboundPayloads: vi.fn(),
+ loadOpenClawPlugins: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
@@ -11,6 +12,19 @@ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
}));
+vi.mock("../../agents/agent-scope.js", () => ({
+ resolveDefaultAgentId: () => "main",
+ resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
+}));
+
+vi.mock("../../config/plugin-auto-enable.js", () => ({
+ applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
+}));
+
+vi.mock("../../plugins/loader.js", () => ({
+ loadOpenClawPlugins: mocks.loadOpenClawPlugins,
+}));
+
vi.mock("./targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
@@ -19,13 +33,17 @@ vi.mock("./deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
+import { setActivePluginRegistry } from "../../plugins/runtime.js";
+import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { sendMessage } from "./message.js";
describe("sendMessage", () => {
beforeEach(() => {
+ setActivePluginRegistry(createTestRegistry([]));
mocks.getChannelPlugin.mockClear();
mocks.resolveOutboundTarget.mockClear();
mocks.deliverOutboundPayloads.mockClear();
+ mocks.loadOpenClawPlugins.mockClear();
mocks.getChannelPlugin.mockReturnValue({
outbound: { deliveryMode: "direct" },
@@ -37,8 +55,8 @@ describe("sendMessage", () => {
it("passes explicit agentId to outbound delivery for scoped media roots", async () => {
await sendMessage({
cfg: {},
- channel: "mattermost",
- to: "channel:town-square",
+ channel: "telegram",
+ to: "123456",
content: "hi",
agentId: "work",
});
@@ -46,9 +64,34 @@ describe("sendMessage", () => {
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
- channel: "mattermost",
- to: "channel:town-square",
+ channel: "telegram",
+ to: "123456",
}),
);
});
+
+ it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => {
+ const telegramPlugin = {
+ outbound: { deliveryMode: "direct" },
+ };
+ mocks.getChannelPlugin
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(telegramPlugin)
+ .mockReturnValue(telegramPlugin);
+
+ await expect(
+ sendMessage({
+ cfg: { channels: { telegram: { botToken: "test-token" } } },
+ channel: "telegram",
+ to: "123456",
+ content: "hi",
+ }),
+ ).resolves.toMatchObject({
+ channel: "telegram",
+ to: "123456",
+ via: "direct",
+ });
+
+ expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts
index 71b36eca6b1..30451b66959 100644
--- a/src/infra/outbound/message.ts
+++ b/src/infra/outbound/message.ts
@@ -1,4 +1,3 @@
-import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
@@ -10,6 +9,10 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../utils/message-channel.js";
+import {
+ normalizeDeliverableOutboundChannel,
+ resolveOutboundChannelPlugin,
+} from "./channel-resolution.js";
import { resolveMessageChannelSelection } from "./channel-selection.js";
import {
deliverOutboundPayloads,
@@ -107,17 +110,18 @@ async function resolveRequiredChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): Promise {
- const channel = params.channel?.trim()
- ? normalizeChannelId(params.channel)
- : (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
- if (!channel) {
- throw new Error(`Unknown channel: ${params.channel}`);
+ if (params.channel?.trim()) {
+ const normalized = normalizeDeliverableOutboundChannel(params.channel);
+ if (!normalized) {
+ throw new Error(`Unknown channel: ${params.channel}`);
+ }
+ return normalized;
}
- return channel;
+ return (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
}
-function resolveRequiredPlugin(channel: string) {
- const plugin = getChannelPlugin(channel);
+function resolveRequiredPlugin(channel: string, cfg: OpenClawConfig) {
+ const plugin = resolveOutboundChannelPlugin({ channel, cfg });
if (!plugin) {
throw new Error(`Unknown channel: ${channel}`);
}
@@ -166,7 +170,7 @@ async function callMessageGateway(params: {
export async function sendMessage(params: MessageSendParams): Promise {
const cfg = params.cfg ?? loadConfig();
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
- const plugin = resolveRequiredPlugin(channel);
+ const plugin = resolveRequiredPlugin(channel, cfg);
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
{
@@ -251,6 +255,7 @@ export async function sendMessage(params: MessageSendParams): Promise ({
+ getChannelPlugin: vi.fn(),
+ loadOpenClawPlugins: vi.fn(),
+}));
+
+vi.mock("../../channels/plugins/index.js", () => ({
+ getChannelPlugin: mocks.getChannelPlugin,
+ normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
+}));
+
+vi.mock("../../agents/agent-scope.js", () => ({
+ resolveDefaultAgentId: () => "main",
+ resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
+}));
+
+vi.mock("../../config/plugin-auto-enable.js", () => ({
+ applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
+}));
+
+vi.mock("../../plugins/loader.js", () => ({
+ loadOpenClawPlugins: mocks.loadOpenClawPlugins,
+}));
+
+import { setActivePluginRegistry } from "../../plugins/runtime.js";
+import { createTestRegistry } from "../../test-utils/channel-plugins.js";
+import { resolveOutboundTarget } from "./targets.js";
+
+describe("resolveOutboundTarget channel resolution", () => {
+ let registrySeq = 0;
+
+ beforeEach(() => {
+ registrySeq += 1;
+ setActivePluginRegistry(createTestRegistry([]), `targets-test-${registrySeq}`);
+ mocks.getChannelPlugin.mockReset();
+ mocks.loadOpenClawPlugins.mockReset();
+ });
+
+ it("recovers telegram plugin resolution so announce delivery does not fail with Unsupported channel: telegram", () => {
+ const telegramPlugin = {
+ id: "telegram",
+ meta: { label: "Telegram" },
+ config: {
+ listAccountIds: () => [],
+ resolveAccount: () => ({}),
+ },
+ };
+ mocks.getChannelPlugin
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(telegramPlugin)
+ .mockReturnValue(telegramPlugin);
+
+ const result = resolveOutboundTarget({
+ channel: "telegram",
+ to: "123456",
+ cfg: { channels: { telegram: { botToken: "test-token" } } },
+ mode: "explicit",
+ });
+
+ expect(result).toEqual({ ok: true, to: "123456" });
+ expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it("retries bootstrap on subsequent resolve when the first bootstrap attempt fails", () => {
+ const telegramPlugin = {
+ id: "telegram",
+ meta: { label: "Telegram" },
+ config: {
+ listAccountIds: () => [],
+ resolveAccount: () => ({}),
+ },
+ };
+ mocks.getChannelPlugin
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(telegramPlugin)
+ .mockReturnValue(telegramPlugin);
+ mocks.loadOpenClawPlugins
+ .mockImplementationOnce(() => {
+ throw new Error("bootstrap failed");
+ })
+ .mockImplementation(() => undefined);
+
+ const first = resolveOutboundTarget({
+ channel: "telegram",
+ to: "123456",
+ cfg: { channels: { telegram: { botToken: "test-token" } } },
+ mode: "explicit",
+ });
+ const second = resolveOutboundTarget({
+ channel: "telegram",
+ to: "123456",
+ cfg: { channels: { telegram: { botToken: "test-token" } } },
+ mode: "explicit",
+ });
+
+ expect(first.ok).toBe(false);
+ expect(second).toEqual({ ok: true, to: "123456" });
+ expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts
index 8f120702de0..cbad502cdde 100644
--- a/src/infra/outbound/targets.test.ts
+++ b/src/infra/outbound/targets.test.ts
@@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508");
});
- it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => {
+ it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -317,12 +317,34 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
+ expect(resolved.channel).toBe("slack");
+ expect(resolved.to).toBe("user:U123");
+ expect(resolved.threadId).toBeUndefined();
+ });
+
+ it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => {
+ const cfg: OpenClawConfig = {};
+ const resolved = resolveHeartbeatDeliveryTarget({
+ cfg,
+ entry: {
+ sessionId: "sess-heartbeat-outbound",
+ updatedAt: 1,
+ lastChannel: "slack",
+ lastTo: "user:U123",
+ lastThreadId: "1739142736.000100",
+ },
+ heartbeat: {
+ target: "last",
+ directPolicy: "block",
+ },
+ });
+
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
expect(resolved.threadId).toBeUndefined();
});
- it("blocks heartbeat delivery to Discord DMs", () => {
+ it("allows heartbeat delivery to Discord DMs by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -337,11 +359,11 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
- expect(resolved.channel).toBe("none");
- expect(resolved.reason).toBe("dm-blocked");
+ expect(resolved.channel).toBe("discord");
+ expect(resolved.to).toBe("user:12345");
});
- it("blocks heartbeat delivery to Telegram direct chats", () => {
+ it("allows heartbeat delivery to Telegram direct chats by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -356,6 +378,26 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
+ expect(resolved.channel).toBe("telegram");
+ expect(resolved.to).toBe("5232990709");
+ });
+
+ it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => {
+ const cfg: OpenClawConfig = {};
+ const resolved = resolveHeartbeatDeliveryTarget({
+ cfg,
+ entry: {
+ sessionId: "sess-heartbeat-telegram-direct",
+ updatedAt: 1,
+ lastChannel: "telegram",
+ lastTo: "5232990709",
+ },
+ heartbeat: {
+ target: "last",
+ directPolicy: "block",
+ },
+ });
+
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
@@ -379,7 +421,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("-1001234567890");
});
- it("blocks heartbeat delivery to WhatsApp direct chats", () => {
+ it("allows heartbeat delivery to WhatsApp direct chats by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -394,8 +436,8 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
- expect(resolved.channel).toBe("none");
- expect(resolved.reason).toBe("dm-blocked");
+ expect(resolved.channel).toBe("whatsapp");
+ expect(resolved.to).toBe("+15551234567");
});
it("keeps heartbeat delivery to WhatsApp groups", () => {
@@ -417,7 +459,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("120363140186826074@g.us");
});
- it("uses session chatType hint when target parser cannot classify", () => {
+ it("uses session chatType hint when target parser cannot classify and allows direct by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -433,6 +475,27 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
+ expect(resolved.channel).toBe("imessage");
+ expect(resolved.to).toBe("chat-guid-unknown-shape");
+ });
+
+ it("blocks session chatType direct hints when directPolicy is block", () => {
+ const cfg: OpenClawConfig = {};
+ const resolved = resolveHeartbeatDeliveryTarget({
+ cfg,
+ entry: {
+ sessionId: "sess-heartbeat-imessage-direct",
+ updatedAt: 1,
+ lastChannel: "imessage",
+ lastTo: "chat-guid-unknown-shape",
+ chatType: "direct",
+ },
+ heartbeat: {
+ target: "last",
+ directPolicy: "block",
+ },
+ });
+
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts
index 41baa558653..89e68e57566 100644
--- a/src/infra/outbound/targets.ts
+++ b/src/infra/outbound/targets.ts
@@ -1,5 +1,4 @@
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
-import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -20,6 +19,10 @@ import {
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
+import {
+ normalizeDeliverableOutboundChannel,
+ resolveOutboundChannelPlugin,
+} from "./channel-resolution.js";
import { missingTargetError } from "./target-errors.js";
export type OutboundChannel = DeliverableMessageChannel | "none";
@@ -181,7 +184,10 @@ export function resolveOutboundTarget(params: {
};
}
- const plugin = getChannelPlugin(params.channel);
+ const plugin = resolveOutboundChannelPlugin({
+ channel: params.channel,
+ cfg: params.cfg,
+ });
if (!plugin) {
return {
ok: false,
@@ -242,7 +248,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
} else if (typeof rawTarget === "string") {
- const normalized = normalizeChannelId(rawTarget);
+ const normalized = normalizeDeliverableOutboundChannel(rawTarget);
if (normalized) {
target = normalized;
}
@@ -269,7 +275,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId;
if (heartbeatAccountId && resolvedTarget.channel) {
- const plugin = getChannelPlugin(resolvedTarget.channel);
+ const plugin = resolveOutboundChannelPlugin({
+ channel: resolvedTarget.channel,
+ cfg,
+ });
const listAccountIds = plugin?.config.listAccountIds;
const accountIds = listAccountIds ? listAccountIds(cfg) : [];
if (accountIds.length > 0) {
@@ -321,7 +330,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
to: resolved.to,
sessionChatType: sessionChatTypeHint,
});
- if (deliveryChatType === "direct") {
+ if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({
reason: "dm-blocked",
accountId: effectiveAccountId,
@@ -331,7 +340,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
let reason: string | undefined;
- const plugin = getChannelPlugin(resolvedTarget.channel);
+ const plugin = resolveOutboundChannelPlugin({
+ channel: resolvedTarget.channel,
+ cfg,
+ });
if (plugin?.config.resolveAllowFrom) {
const explicit = resolveOutboundTarget({
channel: resolvedTarget.channel,
@@ -516,7 +528,10 @@ export function resolveHeartbeatSenderContext(params: {
params.delivery.accountId ??
(provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined);
const allowFromRaw = provider
- ? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({
+ ? (resolveOutboundChannelPlugin({
+ channel: provider,
+ cfg: params.cfg,
+ })?.config.resolveAllowFrom?.({
cfg: params.cfg,
accountId,
}) ?? [])
diff --git a/src/infra/path-alias-guards.ts b/src/infra/path-alias-guards.ts
new file mode 100644
index 00000000000..86d08a3e44a
--- /dev/null
+++ b/src/infra/path-alias-guards.ts
@@ -0,0 +1,89 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js";
+import { isNotFoundPathError, isPathInside } from "./path-guards.js";
+
+export type PathAliasPolicy = {
+ allowFinalSymlinkForUnlink?: boolean;
+ allowFinalHardlinkForUnlink?: boolean;
+};
+
+export const PATH_ALIAS_POLICIES = {
+ strict: Object.freeze({
+ allowFinalSymlinkForUnlink: false,
+ allowFinalHardlinkForUnlink: false,
+ }),
+ unlinkTarget: Object.freeze({
+ allowFinalSymlinkForUnlink: true,
+ allowFinalHardlinkForUnlink: true,
+ }),
+} as const;
+
+export async function assertNoPathAliasEscape(params: {
+ absolutePath: string;
+ rootPath: string;
+ boundaryLabel: string;
+ policy?: PathAliasPolicy;
+}): Promise {
+ const root = path.resolve(params.rootPath);
+ const target = path.resolve(params.absolutePath);
+ if (!isPathInside(root, target)) {
+ throw new Error(
+ `Path escapes ${params.boundaryLabel} (${shortPath(root)}): ${shortPath(params.absolutePath)}`,
+ );
+ }
+ const relative = path.relative(root, target);
+ if (relative) {
+ const rootReal = await tryRealpath(root);
+ const parts = relative.split(path.sep).filter(Boolean);
+ let current = root;
+ for (let idx = 0; idx < parts.length; idx += 1) {
+ current = path.join(current, parts[idx] ?? "");
+ const isLast = idx === parts.length - 1;
+ try {
+ const stat = await fs.lstat(current);
+ if (!stat.isSymbolicLink()) {
+ continue;
+ }
+ if (params.policy?.allowFinalSymlinkForUnlink && isLast) {
+ return;
+ }
+ const symlinkTarget = await tryRealpath(current);
+ if (!isPathInside(rootReal, symlinkTarget)) {
+ throw new Error(
+ `Symlink escapes ${params.boundaryLabel} (${shortPath(rootReal)}): ${shortPath(current)}`,
+ );
+ }
+ current = symlinkTarget;
+ } catch (error) {
+ if (isNotFoundPathError(error)) {
+ break;
+ }
+ throw error;
+ }
+ }
+ }
+
+ await assertNoHardlinkedFinalPath({
+ filePath: target,
+ root,
+ boundaryLabel: params.boundaryLabel,
+ allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink,
+ });
+}
+
+async function tryRealpath(value: string): Promise {
+ try {
+ return await fs.realpath(value);
+ } catch {
+ return path.resolve(value);
+ }
+}
+
+function shortPath(value: string) {
+ if (value.startsWith(os.homedir())) {
+ return `~${value.slice(os.homedir().length)}`;
+ }
+ return value;
+}
diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts
index 7186823d84b..7f7d4fee96c 100644
--- a/src/infra/system-run-command.test.ts
+++ b/src/infra/system-run-command.test.ts
@@ -21,6 +21,10 @@ describe("system run command helpers", () => {
expect(formatExecCommand(["echo", "hi there"])).toBe('echo "hi there"');
});
+ test("formatExecCommand preserves trailing whitespace in argv tokens", () => {
+ expect(formatExecCommand(["runner "])).toBe('"runner "');
+ });
+
test("extractShellCommandFromArgv extracts sh -lc command", () => {
expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe("echo hi");
});
diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts
index b03d715fc72..dc54bf7b561 100644
--- a/src/infra/system-run-command.ts
+++ b/src/infra/system-run-command.ts
@@ -35,15 +35,14 @@ export type ResolvedSystemRunCommand =
export function formatExecCommand(argv: string[]): string {
return argv
.map((arg) => {
- const trimmed = arg.trim();
- if (!trimmed) {
+ if (arg.length === 0) {
return '""';
}
- const needsQuotes = /\s|"/.test(trimmed);
+ const needsQuotes = /\s|"/.test(arg);
if (!needsQuotes) {
- return trimmed;
+ return arg;
}
- return `"${trimmed.replace(/"/g, '\\"')}"`;
+ return `"${arg.replace(/"/g, '\\"')}"`;
})
.join(" ");
}
diff --git a/src/infra/tmp-openclaw-dir.test.ts b/src/infra/tmp-openclaw-dir.test.ts
index 0424e5e0223..f3e3fe36299 100644
--- a/src/infra/tmp-openclaw-dir.test.ts
+++ b/src/infra/tmp-openclaw-dir.test.ts
@@ -8,24 +8,54 @@ function fallbackTmp(uid = 501) {
return path.join("/var/fallback", `openclaw-${uid}`);
}
+function nodeErrorWithCode(code: string) {
+ const err = new Error(code) as Error & { code?: string };
+ err.code = code;
+ return err;
+}
+
+function secureDirStat(uid = 501) {
+ return {
+ isDirectory: () => true,
+ isSymbolicLink: () => false,
+ uid,
+ mode: 0o40700,
+ };
+}
+
function resolveWithMocks(params: {
lstatSync: NonNullable;
+ fallbackLstatSync?: NonNullable;
accessSync?: NonNullable;
uid?: number;
tmpdirPath?: string;
}) {
+ const uid = params.uid ?? 501;
+ const fallbackPath = fallbackTmp(uid);
const accessSync = params.accessSync ?? vi.fn();
+ const wrappedLstatSync = vi.fn((target: string) => {
+ if (target === POSIX_OPENCLAW_TMP_DIR) {
+ return params.lstatSync(target);
+ }
+ if (target === fallbackPath) {
+ if (params.fallbackLstatSync) {
+ return params.fallbackLstatSync(target);
+ }
+ return secureDirStat(uid);
+ }
+ return secureDirStat(uid);
+ }) as NonNullable;
const mkdirSync = vi.fn();
- const getuid = vi.fn(() => params.uid ?? 501);
+ const getuid = vi.fn(() => uid);
const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
- lstatSync: params.lstatSync,
+ lstatSync: wrappedLstatSync,
mkdirSync,
getuid,
tmpdir,
});
- return { resolved, accessSync, lstatSync: params.lstatSync, mkdirSync, tmpdir };
+ return { resolved, accessSync, lstatSync: wrappedLstatSync, mkdirSync, tmpdir };
}
describe("resolvePreferredOpenClawTmpDir", () => {
@@ -45,24 +75,12 @@ describe("resolvePreferredOpenClawTmpDir", () => {
});
it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
- const lstatSyncMock = vi.fn>(() => {
- const err = new Error("missing") as Error & { code?: string };
- err.code = "ENOENT";
- throw err;
- });
-
- // second lstat call (after mkdir) should succeed
- lstatSyncMock.mockImplementationOnce(() => {
- const err = new Error("missing") as Error & { code?: string };
- err.code = "ENOENT";
- throw err;
- });
- lstatSyncMock.mockImplementationOnce(() => ({
- isDirectory: () => true,
- isSymbolicLink: () => false,
- uid: 501,
- mode: 0o40700,
- }));
+ const lstatSyncMock = vi
+ .fn>()
+ .mockImplementationOnce(() => {
+ throw nodeErrorWithCode("ENOENT");
+ })
+ .mockImplementationOnce(() => secureDirStat(501));
const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({
lstatSync: lstatSyncMock,
@@ -84,7 +102,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
- expect(tmpdir).toHaveBeenCalledTimes(1);
+ expect(tmpdir).toHaveBeenCalled();
});
it("falls back to os.tmpdir()/openclaw when /tmp is not writable", () => {
@@ -94,9 +112,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
}
});
const lstatSync = vi.fn(() => {
- const err = new Error("missing") as Error & { code?: string };
- err.code = "ENOENT";
- throw err;
+ throw nodeErrorWithCode("ENOENT");
});
const { resolved, tmpdir } = resolveWithMocks({
accessSync,
@@ -104,7 +120,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
});
expect(resolved).toBe(fallbackTmp());
- expect(tmpdir).toHaveBeenCalledTimes(1);
+ expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is a symlink", () => {
@@ -118,7 +134,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
- expect(tmpdir).toHaveBeenCalledTimes(1);
+ expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is not owned by the current user", () => {
@@ -132,7 +148,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
- expect(tmpdir).toHaveBeenCalledTimes(1);
+ expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is group/other writable", () => {
@@ -145,6 +161,51 @@ describe("resolvePreferredOpenClawTmpDir", () => {
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
- expect(tmpdir).toHaveBeenCalledTimes(1);
+ expect(tmpdir).toHaveBeenCalled();
+ });
+
+ it("throws when fallback path is a symlink", () => {
+ const lstatSync = vi.fn(() => ({
+ isDirectory: () => true,
+ isSymbolicLink: () => true,
+ uid: 501,
+ mode: 0o120777,
+ }));
+ const fallbackLstatSync = vi.fn(() => ({
+ isDirectory: () => true,
+ isSymbolicLink: () => true,
+ uid: 501,
+ mode: 0o120777,
+ }));
+
+ expect(() =>
+ resolveWithMocks({
+ lstatSync,
+ fallbackLstatSync,
+ }),
+ ).toThrow(/Unsafe fallback OpenClaw temp dir/);
+ });
+
+ it("creates fallback directory when missing, then validates ownership and mode", () => {
+ const lstatSync = vi.fn(() => ({
+ isDirectory: () => true,
+ isSymbolicLink: () => true,
+ uid: 501,
+ mode: 0o120777,
+ }));
+ const fallbackLstatSync = vi
+ .fn>()
+ .mockImplementationOnce(() => {
+ throw nodeErrorWithCode("ENOENT");
+ })
+ .mockImplementationOnce(() => secureDirStat(501));
+
+ const { resolved, mkdirSync } = resolveWithMocks({
+ lstatSync,
+ fallbackLstatSync,
+ });
+
+ expect(resolved).toBe(fallbackTmp());
+ expect(mkdirSync).toHaveBeenCalledWith(fallbackTmp(), { recursive: true, mode: 0o700 });
});
});
diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts
index 1e8250b3210..870720b55f8 100644
--- a/src/infra/tmp-openclaw-dir.ts
+++ b/src/infra/tmp-openclaw-dir.ts
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw";
+const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK;
type ResolvePreferredOpenClawTmpDirOptions = {
accessSync?: (path: string, mode?: number) => void;
@@ -66,7 +67,7 @@ export function resolvePreferredOpenClawTmpDir(
return path.join(base, suffix);
};
- const isTrustedPreferredDir = (st: {
+ const isTrustedTmpDir = (st: {
isDirectory(): boolean;
isSymbolicLink(): boolean;
mode?: number;
@@ -75,17 +76,13 @@ export function resolvePreferredOpenClawTmpDir(
return st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st);
};
- const resolvePreferredState = (
- requireWritableAccess: boolean,
- ): "available" | "missing" | "invalid" => {
+ const resolveDirState = (candidatePath: string): "available" | "missing" | "invalid" => {
try {
- const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR);
- if (!isTrustedPreferredDir(preferred)) {
+ const candidate = lstatSync(candidatePath);
+ if (!isTrustedTmpDir(candidate)) {
return "invalid";
}
- if (requireWritableAccess) {
- accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK);
- }
+ accessSync(candidatePath, TMP_DIR_ACCESS_MODE);
return "available";
} catch (err) {
if (isNodeErrorWithCode(err, "ENOENT")) {
@@ -95,23 +92,43 @@ export function resolvePreferredOpenClawTmpDir(
}
};
- const existingPreferredState = resolvePreferredState(true);
+ const ensureTrustedFallbackDir = (): string => {
+ const fallbackPath = fallback();
+ const state = resolveDirState(fallbackPath);
+ if (state === "available") {
+ return fallbackPath;
+ }
+ if (state === "invalid") {
+ throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
+ }
+ try {
+ mkdirSync(fallbackPath, { recursive: true, mode: 0o700 });
+ } catch {
+ throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`);
+ }
+ if (resolveDirState(fallbackPath) !== "available") {
+ throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
+ }
+ return fallbackPath;
+ };
+
+ const existingPreferredState = resolveDirState(POSIX_OPENCLAW_TMP_DIR);
if (existingPreferredState === "available") {
return POSIX_OPENCLAW_TMP_DIR;
}
if (existingPreferredState === "invalid") {
- return fallback();
+ return ensureTrustedFallbackDir();
}
try {
- accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK);
+ accessSync("/tmp", TMP_DIR_ACCESS_MODE);
// Create with a safe default; subsequent callers expect it exists.
mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 });
- if (resolvePreferredState(true) !== "available") {
- return fallback();
+ if (resolveDirState(POSIX_OPENCLAW_TMP_DIR) !== "available") {
+ return ensureTrustedFallbackDir();
}
return POSIX_OPENCLAW_TMP_DIR;
} catch {
- return fallback();
+ return ensureTrustedFallbackDir();
}
}
diff --git a/src/line/monitor.lifecycle.test.ts b/src/line/monitor.lifecycle.test.ts
new file mode 100644
index 00000000000..635d921e7ad
--- /dev/null
+++ b/src/line/monitor.lifecycle.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { OpenClawConfig } from "../config/config.js";
+import type { RuntimeEnv } from "../runtime.js";
+
+const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = vi.hoisted(() => ({
+ createLineBotMock: vi.fn(() => ({
+ account: { accountId: "default" },
+ handleWebhook: vi.fn(),
+ })),
+ registerPluginHttpRouteMock: vi.fn(),
+ unregisterHttpMock: vi.fn(),
+}));
+
+vi.mock("./bot.js", () => ({
+ createLineBot: createLineBotMock,
+}));
+
+vi.mock("../plugins/http-path.js", () => ({
+ normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback,
+}));
+
+vi.mock("../plugins/http-registry.js", () => ({
+ registerPluginHttpRoute: registerPluginHttpRouteMock,
+}));
+
+vi.mock("./webhook-node.js", () => ({
+ createLineNodeWebhookHandler: vi.fn(() => vi.fn()),
+}));
+
+describe("monitorLineProvider lifecycle", () => {
+ beforeEach(() => {
+ createLineBotMock.mockClear();
+ unregisterHttpMock.mockClear();
+ registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
+ });
+
+ it("waits for abort before resolving", async () => {
+ const { monitorLineProvider } = await import("./monitor.js");
+ const abort = new AbortController();
+ let resolved = false;
+
+ const task = monitorLineProvider({
+ channelAccessToken: "token",
+ channelSecret: "secret",
+ config: {} as OpenClawConfig,
+ runtime: {} as RuntimeEnv,
+ abortSignal: abort.signal,
+ }).then((monitor) => {
+ resolved = true;
+ return monitor;
+ });
+
+ await vi.waitFor(() => expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1));
+ expect(resolved).toBe(false);
+
+ abort.abort();
+ await task;
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops immediately when signal is already aborted", async () => {
+ const { monitorLineProvider } = await import("./monitor.js");
+ const abort = new AbortController();
+ abort.abort();
+
+ await monitorLineProvider({
+ channelAccessToken: "token",
+ channelSecret: "secret",
+ config: {} as OpenClawConfig,
+ runtime: {} as RuntimeEnv,
+ abortSignal: abort.signal,
+ });
+
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns immediately without abort signal and stop is idempotent", async () => {
+ const { monitorLineProvider } = await import("./monitor.js");
+
+ const monitor = await monitorLineProvider({
+ channelAccessToken: "token",
+ channelSecret: "secret",
+ config: {} as OpenClawConfig,
+ runtime: {} as RuntimeEnv,
+ });
+
+ expect(unregisterHttpMock).not.toHaveBeenCalled();
+ monitor.stop();
+ monitor.stop();
+ expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/line/monitor.ts b/src/line/monitor.ts
index 07a995c4eed..49fcc518a3f 100644
--- a/src/line/monitor.ts
+++ b/src/line/monitor.ts
@@ -4,6 +4,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import type { OpenClawConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
+import { waitForAbortSignal } from "../infra/abort-signal.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -296,7 +297,12 @@ export async function monitorLineProvider(
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
// Handle abort signal
+ let stopped = false;
const stopHandler = () => {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
unregisterHttp();
recordChannelRuntimeState({
@@ -309,7 +315,12 @@ export async function monitorLineProvider(
});
};
- abortSignal?.addEventListener("abort", stopHandler);
+ if (abortSignal?.aborted) {
+ stopHandler();
+ } else if (abortSignal) {
+ abortSignal.addEventListener("abort", stopHandler, { once: true });
+ await waitForAbortSignal(abortSignal);
+ }
return {
account: bot.account,
diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts
index 2d939c7726e..2682edd2423 100644
--- a/src/node-host/invoke-system-run.test.ts
+++ b/src/node-host/invoke-system-run.test.ts
@@ -49,6 +49,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
preferMacAppExecHost: boolean;
runViaResponse?: ExecHostResponse | null;
command?: string[];
+ cwd?: string;
security?: "full" | "allowlist";
ask?: "off" | "on-miss" | "always";
approved?: boolean;
@@ -70,6 +71,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
client: {} as never,
params: {
command: params.command ?? ["echo", "ok"],
+ cwd: params.cwd,
approved: params.approved ?? false,
sessionKey: "agent:main:main",
},
@@ -214,6 +216,71 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
}),
);
});
+
+ it.runIf(process.platform !== "win32")(
+ "denies approval-based execution when cwd is a symlink",
+ async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-link-"));
+ const safeDir = path.join(tmp, "safe");
+ const linkDir = path.join(tmp, "cwd-link");
+ const script = path.join(safeDir, "run.sh");
+ fs.mkdirSync(safeDir, { recursive: true });
+ fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
+ fs.chmodSync(script, 0o755);
+ fs.symlinkSync(safeDir, linkDir, "dir");
+ try {
+ const { runCommand, sendInvokeResult } = await runSystemInvoke({
+ preferMacAppExecHost: false,
+ command: ["./run.sh"],
+ cwd: linkDir,
+ approved: true,
+ security: "full",
+ ask: "off",
+ });
+ expect(runCommand).not.toHaveBeenCalled();
+ expect(sendInvokeResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({
+ message: expect.stringContaining("canonical cwd"),
+ }),
+ }),
+ );
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+ );
+
+ it("uses canonical executable path for approval-based relative command execution", async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-cwd-real-"));
+ const script = path.join(tmp, "run.sh");
+ fs.writeFileSync(script, "#!/bin/sh\necho SAFE\n");
+ fs.chmodSync(script, 0o755);
+ try {
+ const { runCommand, sendInvokeResult } = await runSystemInvoke({
+ preferMacAppExecHost: false,
+ command: ["./run.sh", "--flag"],
+ cwd: tmp,
+ approved: true,
+ security: "full",
+ ask: "off",
+ });
+ expect(runCommand).toHaveBeenCalledWith(
+ [fs.realpathSync(script), "--flag"],
+ fs.realpathSync(tmp),
+ undefined,
+ undefined,
+ );
+ expect(sendInvokeResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ok: true,
+ }),
+ );
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ });
it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => {
const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
const runCommand = vi.fn(async () => {
@@ -365,6 +432,31 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
);
});
+ it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => {
+ const payloads = ["openclaw status; id", "openclaw status; cat /etc/passwd"];
+ for (const payload of payloads) {
+ const command =
+ process.platform === "win32"
+ ? ["cmd.exe", "/d", "/s", "/c", payload]
+ : ["/bin/sh", "-lc", payload];
+ const { runCommand, sendInvokeResult } = await runSystemInvoke({
+ preferMacAppExecHost: false,
+ security: "allowlist",
+ ask: "on-miss",
+ command,
+ });
+ expect(runCommand, payload).not.toHaveBeenCalled();
+ expect(sendInvokeResult, payload).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ok: false,
+ error: expect.objectContaining({
+ message: "SYSTEM_RUN_DENIED: approval required",
+ }),
+ }),
+ );
+ }
+ });
+
it("denies nested env shell payloads when wrapper depth is exceeded", async () => {
if (process.platform === "win32") {
return;
diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts
index 39e6766f7d5..93edb85e0b7 100644
--- a/src/node-host/invoke-system-run.ts
+++ b/src/node-host/invoke-system-run.ts
@@ -1,4 +1,6 @@
import crypto from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import type { GatewayClient } from "../gateway/client.js";
@@ -18,6 +20,7 @@ import {
} from "../infra/exec-approvals.js";
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
+import { sameFileIdentity } from "../infra/file-identity.js";
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
@@ -110,6 +113,100 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni
}
}
+function isPathLikeExecutableToken(value: string): boolean {
+ if (!value) {
+ return false;
+ }
+ if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) {
+ return true;
+ }
+ if (value.includes("/") || value.includes("\\")) {
+ return true;
+ }
+ if (process.platform === "win32" && /^[a-zA-Z]:[\\/]/.test(value)) {
+ return true;
+ }
+ return false;
+}
+
+function hardenApprovedExecutionPaths(params: {
+ approvedByAsk: boolean;
+ argv: string[];
+ shellCommand: string | null;
+ cwd: string | undefined;
+}): { ok: true; argv: string[]; cwd: string | undefined } | { ok: false; message: string } {
+ if (!params.approvedByAsk) {
+ return { ok: true, argv: params.argv, cwd: params.cwd };
+ }
+
+ let hardenedCwd = params.cwd;
+ if (hardenedCwd) {
+ const requestedCwd = path.resolve(hardenedCwd);
+ let cwdLstat: fs.Stats;
+ let cwdStat: fs.Stats;
+ let cwdReal: string;
+ let cwdRealStat: fs.Stats;
+ try {
+ cwdLstat = fs.lstatSync(requestedCwd);
+ cwdStat = fs.statSync(requestedCwd);
+ cwdReal = fs.realpathSync(requestedCwd);
+ cwdRealStat = fs.statSync(cwdReal);
+ } catch {
+ return {
+ ok: false,
+ message: "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd",
+ };
+ }
+ if (!cwdStat.isDirectory()) {
+ return {
+ ok: false,
+ message: "SYSTEM_RUN_DENIED: approval requires cwd to be a directory",
+ };
+ }
+ if (cwdLstat.isSymbolicLink()) {
+ return {
+ ok: false,
+ message: "SYSTEM_RUN_DENIED: approval requires canonical cwd (no symlink cwd)",
+ };
+ }
+ if (
+ !sameFileIdentity(cwdStat, cwdLstat) ||
+ !sameFileIdentity(cwdStat, cwdRealStat) ||
+ !sameFileIdentity(cwdLstat, cwdRealStat)
+ ) {
+ return {
+ ok: false,
+ message: "SYSTEM_RUN_DENIED: approval cwd identity mismatch",
+ };
+ }
+ hardenedCwd = cwdReal;
+ }
+
+ if (params.shellCommand !== null || params.argv.length === 0) {
+ return { ok: true, argv: params.argv, cwd: hardenedCwd };
+ }
+
+ const argv = [...params.argv];
+ const rawExecutable = argv[0] ?? "";
+ if (!isPathLikeExecutableToken(rawExecutable)) {
+ return { ok: true, argv, cwd: hardenedCwd };
+ }
+
+ const base = hardenedCwd ?? process.cwd();
+ const candidate = path.isAbsolute(rawExecutable)
+ ? rawExecutable
+ : path.resolve(base, rawExecutable);
+ try {
+ argv[0] = fs.realpathSync(candidate);
+ } catch {
+ return {
+ ok: false,
+ message: "SYSTEM_RUN_DENIED: approval requires a stable executable path",
+ };
+ }
+ return { ok: true, argv, cwd: hardenedCwd };
+}
+
export type HandleSystemRunInvokeOptions = {
client: GatewayClient;
params: SystemRunParams;
@@ -422,6 +519,20 @@ async function evaluateSystemRunPolicyPhase(
return null;
}
+ const hardenedPaths = hardenApprovedExecutionPaths({
+ approvedByAsk: policy.approvedByAsk,
+ argv: parsed.argv,
+ shellCommand: parsed.shellCommand,
+ cwd: parsed.cwd,
+ });
+ if (!hardenedPaths.ok) {
+ await sendSystemRunDenied(opts, parsed.execution, {
+ reason: "approval-required",
+ message: hardenedPaths.message,
+ });
+ return null;
+ }
+
const plannedAllowlistArgv = resolvePlannedAllowlistArgv({
security,
shellCommand: parsed.shellCommand,
@@ -437,6 +548,8 @@ async function evaluateSystemRunPolicyPhase(
}
return {
...parsed,
+ argv: hardenedPaths.argv,
+ cwd: hardenedPaths.cwd,
approvals,
security,
policy,
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 337f02a3cc5..828ec089903 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -380,6 +380,7 @@ export { formatDocsLink } from "../terminal/links.js";
export {
resolveDmAllowState,
resolveDmGroupAccessDecision,
+ resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "../security/dm-policy-shared.js";
export type { HookEntry } from "../hooks/types.js";
diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts
index daa60aed73f..a3f81d40870 100644
--- a/src/security/audit-extra.sync.ts
+++ b/src/security/audit-extra.sync.ts
@@ -955,11 +955,11 @@ export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): Secu
severity: "warn",
title: "Some gateway.nodes.denyCommands entries are ineffective",
detail:
- "gateway.nodes.denyCommands uses exact command-name matching only.\n" +
+ "gateway.nodes.denyCommands uses exact node command-name matching only (for example `system.run`), not shell-text filtering inside a command payload.\n" +
detailParts.map((entry) => `- ${entry}`).join("\n"),
remediation:
`Use exact command names (for example: ${examples.join(", ")}). ` +
- "If you need broader restrictions, remove risky commands from allowCommands/default workflows.",
+ "If you need broader restrictions, remove risky command IDs from allowCommands/default workflows and tighten tools.exec policy.",
});
return findings;
diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts
index d65d6a79188..735b7d8728d 100644
--- a/src/security/dm-policy-shared.test.ts
+++ b/src/security/dm-policy-shared.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
resolveDmAllowState,
resolveDmGroupAccessDecision,
+ resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "./dm-policy-shared.js";
@@ -75,6 +76,37 @@ describe("security/dm-policy-shared", () => {
expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]);
});
+ it("resolves access + effective allowlists in one shared call", () => {
+ const resolved = resolveDmGroupAccessWithLists({
+ isGroup: false,
+ dmPolicy: "pairing",
+ groupPolicy: "allowlist",
+ allowFrom: ["owner"],
+ groupAllowFrom: ["group:room"],
+ storeAllowFrom: ["paired-user"],
+ isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
+ });
+ expect(resolved.decision).toBe("allow");
+ expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)");
+ expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
+ expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]);
+ });
+
+ it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {
+ const resolved = resolveDmGroupAccessWithLists({
+ isGroup: false,
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ allowFrom: ["owner"],
+ groupAllowFrom: [],
+ storeAllowFrom: ["paired-user"],
+ isSenderAllowed: () => false,
+ });
+ expect(resolved.decision).toBe("block");
+ expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)");
+ expect(resolved.effectiveAllowFrom).toEqual(["owner"]);
+ });
+
const channels = [
"bluebubbles",
"imessage",
@@ -86,6 +118,70 @@ describe("security/dm-policy-shared", () => {
"zalo",
] as const;
+ it("keeps message/reaction policy parity table across channels", () => {
+ const cases = [
+ {
+ name: "dmPolicy=open",
+ dmPolicy: "open" as const,
+ allowFrom: [] as string[],
+ senderAllowed: false,
+ expectedDecision: "allow" as const,
+ expectedReactionAllowed: true,
+ },
+ {
+ name: "dmPolicy=disabled",
+ dmPolicy: "disabled" as const,
+ allowFrom: [] as string[],
+ senderAllowed: false,
+ expectedDecision: "block" as const,
+ expectedReactionAllowed: false,
+ },
+ {
+ name: "dmPolicy=allowlist unauthorized",
+ dmPolicy: "allowlist" as const,
+ allowFrom: ["owner"],
+ senderAllowed: false,
+ expectedDecision: "block" as const,
+ expectedReactionAllowed: false,
+ },
+ {
+ name: "dmPolicy=allowlist authorized",
+ dmPolicy: "allowlist" as const,
+ allowFrom: ["owner"],
+ senderAllowed: true,
+ expectedDecision: "allow" as const,
+ expectedReactionAllowed: true,
+ },
+ {
+ name: "dmPolicy=pairing unauthorized",
+ dmPolicy: "pairing" as const,
+ allowFrom: [] as string[],
+ senderAllowed: false,
+ expectedDecision: "pairing" as const,
+ expectedReactionAllowed: false,
+ },
+ ];
+
+ for (const channel of channels) {
+ for (const testCase of cases) {
+ const access = resolveDmGroupAccessWithLists({
+ isGroup: false,
+ dmPolicy: testCase.dmPolicy,
+ groupPolicy: "allowlist",
+ allowFrom: testCase.allowFrom,
+ groupAllowFrom: [],
+ storeAllowFrom: [],
+ isSenderAllowed: () => testCase.senderAllowed,
+ });
+ const reactionAllowed = access.decision === "allow";
+ expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);
+ expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe(
+ testCase.expectedReactionAllowed,
+ );
+ }
+ }
+ });
+
for (const channel of channels) {
it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({
diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts
index ee07dfff3c7..a1084ace9ff 100644
--- a/src/security/dm-policy-shared.ts
+++ b/src/security/dm-policy-shared.ts
@@ -77,6 +77,41 @@ export function resolveDmGroupAccessDecision(params: {
return { decision: "block", reason: `dmPolicy=${dmPolicy} (not allowlisted)` };
}
+export function resolveDmGroupAccessWithLists(params: {
+ isGroup: boolean;
+ dmPolicy?: string | null;
+ groupPolicy?: string | null;
+ allowFrom?: Array | null;
+ groupAllowFrom?: Array | null;
+ storeAllowFrom?: Array | null;
+ isSenderAllowed: (allowFrom: string[]) => boolean;
+}): {
+ decision: DmGroupAccessDecision;
+ reason: string;
+ effectiveAllowFrom: string[];
+ effectiveGroupAllowFrom: string[];
+} {
+ const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
+ allowFrom: params.allowFrom,
+ groupAllowFrom: params.groupAllowFrom,
+ storeAllowFrom: params.storeAllowFrom,
+ dmPolicy: params.dmPolicy,
+ });
+ const access = resolveDmGroupAccessDecision({
+ isGroup: params.isGroup,
+ dmPolicy: params.dmPolicy,
+ groupPolicy: params.groupPolicy,
+ effectiveAllowFrom,
+ effectiveGroupAllowFrom,
+ isSenderAllowed: params.isSenderAllowed,
+ });
+ return {
+ ...access,
+ effectiveAllowFrom,
+ effectiveGroupAllowFrom,
+ };
+}
+
export async function resolveDmAllowState(params: {
provider: ChannelId;
allowFrom?: Array | null;
diff --git a/src/shared/net/ip-test-fixtures.ts b/src/shared/net/ip-test-fixtures.ts
new file mode 100644
index 00000000000..d2fa9cd5436
--- /dev/null
+++ b/src/shared/net/ip-test-fixtures.ts
@@ -0,0 +1 @@
+export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const;
diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts
index 73d385832f0..f89fb03f7ef 100644
--- a/src/shared/net/ip.test.ts
+++ b/src/shared/net/ip.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js";
import {
extractEmbeddedIpv4FromIpv6,
isCanonicalDottedDecimalIPv4,
@@ -45,8 +46,11 @@ describe("shared ip helpers", () => {
}
});
- it("treats deprecated site-local IPv6 as private/internal", () => {
+ it("treats blocked IPv6 classes as private/internal", () => {
expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true);
+ for (const literal of blockedIpv6MulticastLiterals) {
+ expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true);
+ }
expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false);
});
});
diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts
index 2342bdedafe..c386c687898 100644
--- a/src/shared/net/ip.ts
+++ b/src/shared/net/ip.ts
@@ -22,11 +22,12 @@ const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([
"carrierGradeNat",
]);
-const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([
+const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([
"unspecified",
"loopback",
"linkLocal",
"uniqueLocal",
+ "multicast",
]);
const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15];
export type Ipv4SpecialUseBlockOptions = {
@@ -227,11 +228,15 @@ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
if (isIpv4Address(normalized)) {
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
}
- if (PRIVATE_OR_LOOPBACK_IPV6_RANGES.has(normalized.range())) {
+ return isBlockedSpecialUseIpv6Address(normalized);
+}
+
+export function isBlockedSpecialUseIpv6Address(address: ipaddr.IPv6): boolean {
+ if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(address.range())) {
return true;
}
// ipaddr.js does not classify deprecated site-local fec0::/10 as private.
- return (normalized.parts[0] & 0xffc0) === 0xfec0;
+ return (address.parts[0] & 0xffc0) === 0xfec0;
}
export function isRfc1918Ipv4Address(raw: string | undefined): boolean {
diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
index 429f9e3896c..a06d17d61d9 100644
--- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
+++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
@@ -378,6 +378,49 @@ describe("monitorSignalProvider tool results", () => {
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
});
+ it.each([
+ {
+ name: "blocks reaction notifications from unauthorized senders when dmPolicy is allowlist",
+ mode: "all" as const,
+ extra: { dmPolicy: "allowlist", allowFrom: ["+15550007777"] } as Record,
+ targetAuthor: "+15550002222",
+ shouldEnqueue: false,
+ },
+ {
+ name: "blocks reaction notifications from unauthorized senders when dmPolicy is pairing",
+ mode: "own" as const,
+ extra: {
+ dmPolicy: "pairing",
+ allowFrom: [],
+ account: "+15550009999",
+ } as Record,
+ targetAuthor: "+15550009999",
+ shouldEnqueue: false,
+ },
+ {
+ name: "allows reaction notifications for allowlisted senders when dmPolicy is allowlist",
+ mode: "all" as const,
+ extra: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } as Record,
+ targetAuthor: "+15550002222",
+ shouldEnqueue: true,
+ },
+ ])("$name", async ({ mode, extra, targetAuthor, shouldEnqueue }) => {
+ setReactionNotificationConfig(mode, extra);
+ await receiveSingleEnvelope({
+ ...makeBaseEnvelope(),
+ reactionMessage: {
+ emoji: "✅",
+ targetAuthor,
+ targetSentTimestamp: 2,
+ },
+ });
+
+ const events = getDirectSignalEventsFor("+15550001111");
+ expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue);
+ expect(sendMock).not.toHaveBeenCalled();
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
+ });
+
it("notifies on own reactions when target includes uuid + phone", async () => {
setReactionNotificationConfig("own", { account: "+15550002222" });
await receiveSingleEnvelope({
diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts
index b095626ab46..ea9fa9f49d6 100644
--- a/src/signal/monitor/event-handler.ts
+++ b/src/signal/monitor/event-handler.ts
@@ -36,6 +36,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
+import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js";
import { normalizeE164 } from "../../utils.js";
import {
formatSignalPairingIdLine,
@@ -45,9 +46,15 @@ import {
resolveSignalPeerId,
resolveSignalRecipient,
resolveSignalSender,
+ type SignalSender,
} from "../identity.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
-import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js";
+import type {
+ SignalEnvelope,
+ SignalEventHandlerDeps,
+ SignalReactionMessage,
+ SignalReceivePayload,
+} from "./event-handler.types.js";
import { renderSignalMentions } from "./mentions.js";
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" });
@@ -317,6 +324,85 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
});
+ function handleReactionOnlyInbound(params: {
+ envelope: SignalEnvelope;
+ sender: SignalSender;
+ senderDisplay: string;
+ reaction: SignalReactionMessage;
+ hasBodyContent: boolean;
+ resolveAccessDecision: (isGroup: boolean) => {
+ decision: "allow" | "block" | "pairing";
+ reason: string;
+ };
+ }): boolean {
+ if (params.hasBodyContent) {
+ return false;
+ }
+ if (params.reaction.isRemove) {
+ return true; // Ignore reaction removals
+ }
+ const emojiLabel = params.reaction.emoji?.trim() || "emoji";
+ const senderName = params.envelope.sourceName ?? params.senderDisplay;
+ logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
+ const groupId = params.reaction.groupInfo?.groupId ?? undefined;
+ const groupName = params.reaction.groupInfo?.groupName ?? undefined;
+ const isGroup = Boolean(groupId);
+ const reactionAccess = params.resolveAccessDecision(isGroup);
+ if (reactionAccess.decision !== "allow") {
+ logVerbose(
+ `Blocked signal reaction sender ${params.senderDisplay} (${reactionAccess.reason})`,
+ );
+ return true;
+ }
+ const targets = deps.resolveSignalReactionTargets(params.reaction);
+ const shouldNotify = deps.shouldEmitSignalReactionNotification({
+ mode: deps.reactionMode,
+ account: deps.account,
+ targets,
+ sender: params.sender,
+ allowlist: deps.reactionAllowlist,
+ });
+ if (!shouldNotify) {
+ return true;
+ }
+
+ const senderPeerId = resolveSignalPeerId(params.sender);
+ const route = resolveAgentRoute({
+ cfg: deps.cfg,
+ channel: "signal",
+ accountId: deps.accountId,
+ peer: {
+ kind: isGroup ? "group" : "direct",
+ id: isGroup ? (groupId ?? "unknown") : senderPeerId,
+ },
+ });
+ const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined;
+ const messageId = params.reaction.targetSentTimestamp
+ ? String(params.reaction.targetSentTimestamp)
+ : "unknown";
+ const text = deps.buildSignalReactionSystemEventText({
+ emojiLabel,
+ actorLabel: senderName,
+ messageId,
+ targetLabel: targets[0]?.display,
+ groupLabel,
+ });
+ const senderId = formatSignalSenderId(params.sender);
+ const contextKey = [
+ "signal",
+ "reaction",
+ "added",
+ messageId,
+ senderId,
+ emojiLabel,
+ groupId ?? "",
+ ]
+ .filter(Boolean)
+ .join(":");
+ enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey });
+ return true;
+ }
+
return async (event: { event?: string; data?: string }) => {
if (event.event !== "receive" || !event.data) {
return;
@@ -366,71 +452,43 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
const hasBodyContent =
Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
+ const senderDisplay = formatSignalSenderDisplay(sender);
+ const storeAllowFrom =
+ deps.dmPolicy === "allowlist"
+ ? []
+ : await readChannelAllowFromStore("signal").catch(() => []);
+ const resolveAccessDecision = (isGroup: boolean) =>
+ resolveDmGroupAccessWithLists({
+ isGroup,
+ dmPolicy: deps.dmPolicy,
+ groupPolicy: deps.groupPolicy,
+ allowFrom: deps.allowFrom,
+ groupAllowFrom: deps.groupAllowFrom,
+ storeAllowFrom,
+ isSenderAllowed: (allowEntries) => isSignalSenderAllowed(sender, allowEntries),
+ });
+ const dmAccess = resolveAccessDecision(false);
+ const effectiveDmAllow = dmAccess.effectiveAllowFrom;
+ const effectiveGroupAllow = dmAccess.effectiveGroupAllowFrom;
+ const dmAllowed = dmAccess.decision === "allow";
- if (reaction && !hasBodyContent) {
- if (reaction.isRemove) {
- return;
- } // Ignore reaction removals
- const emojiLabel = reaction.emoji?.trim() || "emoji";
- const senderDisplay = formatSignalSenderDisplay(sender);
- const senderName = envelope.sourceName ?? senderDisplay;
- logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
- const targets = deps.resolveSignalReactionTargets(reaction);
- const shouldNotify = deps.shouldEmitSignalReactionNotification({
- mode: deps.reactionMode,
- account: deps.account,
- targets,
+ if (
+ reaction &&
+ handleReactionOnlyInbound({
+ envelope,
sender,
- allowlist: deps.reactionAllowlist,
- });
- if (!shouldNotify) {
- return;
- }
-
- const groupId = reaction.groupInfo?.groupId ?? undefined;
- const groupName = reaction.groupInfo?.groupName ?? undefined;
- const isGroup = Boolean(groupId);
- const senderPeerId = resolveSignalPeerId(sender);
- const route = resolveAgentRoute({
- cfg: deps.cfg,
- channel: "signal",
- accountId: deps.accountId,
- peer: {
- kind: isGroup ? "group" : "direct",
- id: isGroup ? (groupId ?? "unknown") : senderPeerId,
- },
- });
- const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined;
- const messageId = reaction.targetSentTimestamp
- ? String(reaction.targetSentTimestamp)
- : "unknown";
- const text = deps.buildSignalReactionSystemEventText({
- emojiLabel,
- actorLabel: senderName,
- messageId,
- targetLabel: targets[0]?.display,
- groupLabel,
- });
- const senderId = formatSignalSenderId(sender);
- const contextKey = [
- "signal",
- "reaction",
- "added",
- messageId,
- senderId,
- emojiLabel,
- groupId ?? "",
- ]
- .filter(Boolean)
- .join(":");
- enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey });
+ senderDisplay,
+ reaction,
+ hasBodyContent,
+ resolveAccessDecision,
+ })
+ ) {
return;
}
if (!dataMessage) {
return;
}
- const senderDisplay = formatSignalSenderDisplay(sender);
const senderRecipient = resolveSignalRecipient(sender);
const senderPeerId = resolveSignalPeerId(sender);
const senderAllowId = formatSignalSenderId(sender);
@@ -441,20 +499,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
const isGroup = Boolean(groupId);
- const storeAllowFrom =
- deps.dmPolicy === "allowlist"
- ? []
- : await readChannelAllowFromStore("signal").catch(() => []);
- const effectiveDmAllow = [...deps.allowFrom, ...storeAllowFrom];
- const effectiveGroupAllow = [...deps.groupAllowFrom, ...storeAllowFrom];
- const dmAllowed =
- deps.dmPolicy === "open" ? true : isSignalSenderAllowed(sender, effectiveDmAllow);
if (!isGroup) {
- if (deps.dmPolicy === "disabled") {
+ if (dmAccess.decision === "block") {
+ if (deps.dmPolicy !== "disabled") {
+ logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`);
+ }
return;
}
- if (!dmAllowed) {
+ if (dmAccess.decision === "pairing") {
if (deps.dmPolicy === "pairing") {
const senderId = senderAllowId;
const { code, created } = await upsertChannelPairingRequest({
@@ -483,23 +536,20 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
logVerbose(`signal pairing reply failed for ${senderId}: ${String(err)}`);
}
}
- } else {
- logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`);
}
return;
}
}
- if (isGroup && deps.groupPolicy === "disabled") {
- logVerbose("Blocked signal group message (groupPolicy: disabled)");
- return;
- }
- if (isGroup && deps.groupPolicy === "allowlist") {
- if (effectiveGroupAllow.length === 0) {
- logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
- return;
- }
- if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) {
- logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
+ if (isGroup) {
+ const groupAccess = resolveAccessDecision(true);
+ if (groupAccess.decision !== "allow") {
+ if (groupAccess.reason === "groupPolicy=disabled") {
+ logVerbose("Blocked signal group message (groupPolicy: disabled)");
+ } else if (groupAccess.reason === "groupPolicy=allowlist (empty allowlist)") {
+ logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
+ } else {
+ logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
+ }
return;
}
}
diff --git a/src/slack/modal-metadata.test.ts b/src/slack/modal-metadata.test.ts
index d209c70587c..a7a7ce8224b 100644
--- a/src/slack/modal-metadata.test.ts
+++ b/src/slack/modal-metadata.test.ts
@@ -18,6 +18,7 @@ describe("parseSlackModalPrivateMetadata", () => {
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
+ userId: "U123",
ignored: "x",
}),
),
@@ -25,6 +26,7 @@ describe("parseSlackModalPrivateMetadata", () => {
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
+ userId: "U123",
});
});
});
@@ -37,11 +39,13 @@ describe("encodeSlackModalPrivateMetadata", () => {
sessionKey: "agent:main:slack:channel:C1",
channelId: "",
channelType: "im",
+ userId: "U123",
}),
),
).toEqual({
sessionKey: "agent:main:slack:channel:C1",
channelType: "im",
+ userId: "U123",
});
});
diff --git a/src/slack/modal-metadata.ts b/src/slack/modal-metadata.ts
index 491fb5d38f3..963024487a9 100644
--- a/src/slack/modal-metadata.ts
+++ b/src/slack/modal-metadata.ts
@@ -2,6 +2,7 @@ export type SlackModalPrivateMetadata = {
sessionKey?: string;
channelId?: string;
channelType?: string;
+ userId?: string;
};
const SLACK_PRIVATE_METADATA_MAX = 3000;
@@ -20,6 +21,7 @@ export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateM
sessionKey: normalizeString(parsed.sessionKey),
channelId: normalizeString(parsed.channelId),
channelType: normalizeString(parsed.channelType),
+ userId: normalizeString(parsed.userId),
};
} catch {
return {};
@@ -31,6 +33,7 @@ export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
...(input.channelId ? { channelId: input.channelId } : {}),
...(input.channelType ? { channelType: input.channelType } : {}),
+ ...(input.userId ? { userId: input.userId } : {}),
};
const encoded = JSON.stringify(payload);
if (encoded.length > SLACK_PRIVATE_METADATA_MAX) {
diff --git a/src/slack/monitor/auth.ts b/src/slack/monitor/auth.ts
index d8fa5e5b4e5..cb43241f899 100644
--- a/src/slack/monitor/auth.ts
+++ b/src/slack/monitor/auth.ts
@@ -1,6 +1,12 @@
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
-import { allowListMatches, normalizeAllowList, normalizeAllowListLower } from "./allow-list.js";
-import type { SlackMonitorContext } from "./context.js";
+import {
+ allowListMatches,
+ normalizeAllowList,
+ normalizeAllowListLower,
+ resolveSlackUserAllowed,
+} from "./allow-list.js";
+import { resolveSlackChannelConfig } from "./channel-config.js";
+import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) {
const storeAllowFrom =
@@ -27,3 +33,137 @@ export function isSlackSenderAllowListed(params: {
})
);
}
+
+export type SlackSystemEventAuthResult = {
+ allowed: boolean;
+ reason?:
+ | "missing-sender"
+ | "sender-mismatch"
+ | "channel-not-allowed"
+ | "dm-disabled"
+ | "sender-not-allowlisted"
+ | "sender-not-channel-allowed";
+ channelType?: "im" | "mpim" | "channel" | "group";
+ channelName?: string;
+};
+
+export async function authorizeSlackSystemEventSender(params: {
+ ctx: SlackMonitorContext;
+ senderId?: string;
+ channelId?: string;
+ channelType?: string | null;
+ expectedSenderId?: string;
+}): Promise {
+ const senderId = params.senderId?.trim();
+ if (!senderId) {
+ return { allowed: false, reason: "missing-sender" };
+ }
+
+ const expectedSenderId = params.expectedSenderId?.trim();
+ if (expectedSenderId && expectedSenderId !== senderId) {
+ return { allowed: false, reason: "sender-mismatch" };
+ }
+
+ const channelId = params.channelId?.trim();
+ let channelType = normalizeSlackChannelType(params.channelType, channelId);
+ let channelName: string | undefined;
+ if (channelId) {
+ const info: {
+ name?: string;
+ type?: "im" | "mpim" | "channel" | "group";
+ } = await params.ctx.resolveChannelName(channelId).catch(() => ({}));
+ channelName = info.name;
+ channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId);
+ if (
+ !params.ctx.isChannelAllowed({
+ channelId,
+ channelName,
+ channelType,
+ })
+ ) {
+ return {
+ allowed: false,
+ reason: "channel-not-allowed",
+ channelType,
+ channelName,
+ };
+ }
+ }
+
+ const senderInfo: { name?: string } = await params.ctx
+ .resolveUserName(senderId)
+ .catch(() => ({}));
+ const senderName = senderInfo.name;
+
+ const resolveAllowFromLower = async () =>
+ (await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower;
+
+ if (channelType === "im") {
+ if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") {
+ return { allowed: false, reason: "dm-disabled", channelType, channelName };
+ }
+ if (params.ctx.dmPolicy !== "open") {
+ const allowFromLower = await resolveAllowFromLower();
+ const senderAllowListed = isSlackSenderAllowListed({
+ allowListLower: allowFromLower,
+ senderId,
+ senderName,
+ allowNameMatching: params.ctx.allowNameMatching,
+ });
+ if (!senderAllowListed) {
+ return {
+ allowed: false,
+ reason: "sender-not-allowlisted",
+ channelType,
+ channelName,
+ };
+ }
+ }
+ } else if (!channelId) {
+ // No channel context. Apply allowFrom if configured so we fail closed
+ // for privileged interactive events when owner allowlist is present.
+ const allowFromLower = await resolveAllowFromLower();
+ if (allowFromLower.length > 0) {
+ const senderAllowListed = isSlackSenderAllowListed({
+ allowListLower: allowFromLower,
+ senderId,
+ senderName,
+ allowNameMatching: params.ctx.allowNameMatching,
+ });
+ if (!senderAllowListed) {
+ return { allowed: false, reason: "sender-not-allowlisted" };
+ }
+ }
+ } else {
+ const channelConfig = resolveSlackChannelConfig({
+ channelId,
+ channelName,
+ channels: params.ctx.channelsConfig,
+ defaultRequireMention: params.ctx.defaultRequireMention,
+ });
+ const channelUsersAllowlistConfigured =
+ Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
+ if (channelUsersAllowlistConfigured) {
+ const channelUserAllowed = resolveSlackUserAllowed({
+ allowList: channelConfig?.users,
+ userId: senderId,
+ userName: senderName,
+ allowNameMatching: params.ctx.allowNameMatching,
+ });
+ if (!channelUserAllowed) {
+ return {
+ allowed: false,
+ reason: "sender-not-channel-allowed",
+ channelType,
+ channelName,
+ };
+ }
+ }
+ }
+
+ return {
+ allowed: true,
+ channelType,
+ channelName,
+ };
+}
diff --git a/src/slack/monitor/channel-config.ts b/src/slack/monitor/channel-config.ts
index 15ba7c3b146..b594a34d43b 100644
--- a/src/slack/monitor/channel-config.ts
+++ b/src/slack/monitor/channel-config.ts
@@ -96,8 +96,16 @@ export function resolveSlackChannelConfig(params: {
const keys = Object.keys(entries);
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
const directName = channelName ? channelName.trim() : "";
+ // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345) but
+ // operators commonly write them in lowercase in their config. Add both
+ // case variants so the lookup is case-insensitive without requiring a full
+ // entry-scan. buildChannelKeyCandidates deduplicates identical keys.
+ const channelIdLower = channelId.toLowerCase();
+ const channelIdUpper = channelId.toUpperCase();
const candidates = buildChannelKeyCandidates(
channelId,
+ channelIdLower !== channelId ? channelIdLower : undefined,
+ channelIdUpper !== channelId ? channelIdUpper : undefined,
channelName ? `#${directName}` : undefined,
directName,
normalizedName,
diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts
index 7710239cc71..cfd53506358 100644
--- a/src/slack/monitor/events/interactions.test.ts
+++ b/src/slack/monitor/events/interactions.test.ts
@@ -30,6 +30,7 @@ type RegisteredViewHandler = (args: {
view?: {
id?: string;
callback_id?: string;
+ private_metadata?: string;
root_view_id?: string;
previous_view_id?: string;
external_id?: string;
@@ -58,7 +59,23 @@ type RegisteredViewClosedHandler = (args: {
};
}) => Promise;
-function createContext() {
+function createContext(overrides?: {
+ dmEnabled?: boolean;
+ dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
+ allowFrom?: string[];
+ allowNameMatching?: boolean;
+ channelsConfig?: Record;
+ isChannelAllowed?: (params: {
+ channelId?: string;
+ channelName?: string;
+ channelType?: "im" | "mpim" | "channel" | "group";
+ }) => boolean;
+ resolveUserName?: (userId: string) => Promise<{ name?: string }>;
+ resolveChannelName?: (channelId: string) => Promise<{
+ name?: string;
+ type?: "im" | "mpim" | "channel" | "group";
+ }>;
+}) {
let handler: RegisteredHandler | null = null;
let viewHandler: RegisteredViewHandler | null = null;
let viewClosedHandler: RegisteredViewClosedHandler | null = null;
@@ -80,9 +97,40 @@ function createContext() {
};
const runtimeLog = vi.fn();
const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1");
+ const isChannelAllowed = vi
+ .fn<
+ (params: {
+ channelId?: string;
+ channelName?: string;
+ channelType?: "im" | "mpim" | "channel" | "group";
+ }) => boolean
+ >()
+ .mockImplementation((params) => overrides?.isChannelAllowed?.(params) ?? true);
+ const resolveUserName = vi
+ .fn<(userId: string) => Promise<{ name?: string }>>()
+ .mockImplementation((userId) => overrides?.resolveUserName?.(userId) ?? Promise.resolve({}));
+ const resolveChannelName = vi
+ .fn<
+ (channelId: string) => Promise<{
+ name?: string;
+ type?: "im" | "mpim" | "channel" | "group";
+ }>
+ >()
+ .mockImplementation(
+ (channelId) => overrides?.resolveChannelName?.(channelId) ?? Promise.resolve({}),
+ );
const ctx = {
app,
runtime: { log: runtimeLog },
+ dmEnabled: overrides?.dmEnabled ?? true,
+ dmPolicy: overrides?.dmPolicy ?? ("open" as const),
+ allowFrom: overrides?.allowFrom ?? [],
+ allowNameMatching: overrides?.allowNameMatching ?? false,
+ channelsConfig: overrides?.channelsConfig ?? {},
+ defaultRequireMention: true,
+ isChannelAllowed,
+ resolveUserName,
+ resolveChannelName,
resolveSlackSystemEventSessionKey: resolveSessionKey,
};
return {
@@ -90,6 +138,9 @@ function createContext() {
app,
runtimeLog,
resolveSessionKey,
+ isChannelAllowed,
+ resolveUserName,
+ resolveChannelName,
getHandler: () => handler,
getViewHandler: () => viewHandler,
getViewClosedHandler: () => viewClosedHandler,
@@ -168,7 +219,7 @@ describe("registerSlackInteractionEvents", () => {
});
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "C1",
- channelType: undefined,
+ channelType: "channel",
});
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
});
@@ -228,6 +279,85 @@ describe("registerSlackInteractionEvents", () => {
);
});
+ it("blocks block actions from users outside configured channel users allowlist", async () => {
+ enqueueSystemEventMock.mockClear();
+ const { ctx, app, getHandler } = createContext({
+ channelsConfig: {
+ C1: { users: ["U_ALLOWED"] },
+ },
+ });
+ registerSlackInteractionEvents({ ctx: ctx as never });
+ const handler = getHandler();
+ expect(handler).toBeTruthy();
+
+ const ack = vi.fn().mockResolvedValue(undefined);
+ const respond = vi.fn().mockResolvedValue(undefined);
+ await handler!({
+ ack,
+ respond,
+ body: {
+ user: { id: "U_DENIED" },
+ channel: { id: "C1" },
+ message: {
+ ts: "201.202",
+ blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
+ },
+ },
+ action: {
+ type: "button",
+ action_id: "openclaw:verify",
+ block_id: "verify_block",
+ },
+ });
+
+ expect(ack).toHaveBeenCalled();
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ expect(app.client.chat.update).not.toHaveBeenCalled();
+ expect(respond).toHaveBeenCalledWith({
+ text: "You are not authorized to use this control.",
+ response_type: "ephemeral",
+ });
+ });
+
+ it("blocks DM block actions when sender is not in allowFrom", async () => {
+ enqueueSystemEventMock.mockClear();
+ const { ctx, app, getHandler } = createContext({
+ dmPolicy: "allowlist",
+ allowFrom: ["U_OWNER"],
+ });
+ registerSlackInteractionEvents({ ctx: ctx as never });
+ const handler = getHandler();
+ expect(handler).toBeTruthy();
+
+ const ack = vi.fn().mockResolvedValue(undefined);
+ const respond = vi.fn().mockResolvedValue(undefined);
+ await handler!({
+ ack,
+ respond,
+ body: {
+ user: { id: "U_ATTACKER" },
+ channel: { id: "D222" },
+ message: {
+ ts: "301.302",
+ blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
+ },
+ },
+ action: {
+ type: "button",
+ action_id: "openclaw:verify",
+ block_id: "verify_block",
+ },
+ });
+
+ expect(ack).toHaveBeenCalled();
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ expect(app.client.chat.update).not.toHaveBeenCalled();
+ expect(respond).toHaveBeenCalledWith({
+ text: "You are not authorized to use this control.",
+ response_type: "ephemeral",
+ });
+ });
+
it("ignores malformed action payloads after ack and logs warning", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler, runtimeLog } = createContext();
@@ -338,7 +468,7 @@ describe("registerSlackInteractionEvents", () => {
expect(ack).toHaveBeenCalled();
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "C222",
- channelType: undefined,
+ channelType: "channel",
});
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
@@ -697,7 +827,11 @@ describe("registerSlackInteractionEvents", () => {
previous_view_id: "VPREV",
external_id: "deploy-ext-1",
hash: "view-hash-1",
- private_metadata: JSON.stringify({ channelId: "D123", channelType: "im" }),
+ private_metadata: JSON.stringify({
+ channelId: "D123",
+ channelType: "im",
+ userId: "U777",
+ }),
state: {
values: {
env_block: {
@@ -771,6 +905,59 @@ describe("registerSlackInteractionEvents", () => {
);
});
+ it("blocks modal events when private metadata userId does not match submitter", async () => {
+ enqueueSystemEventMock.mockClear();
+ const { ctx, getViewHandler } = createContext();
+ registerSlackInteractionEvents({ ctx: ctx as never });
+ const viewHandler = getViewHandler();
+ expect(viewHandler).toBeTruthy();
+
+ const ack = vi.fn().mockResolvedValue(undefined);
+ await viewHandler!({
+ ack,
+ body: {
+ user: { id: "U222" },
+ view: {
+ callback_id: "openclaw:deploy_form",
+ private_metadata: JSON.stringify({
+ channelId: "D123",
+ channelType: "im",
+ userId: "U111",
+ }),
+ },
+ },
+ } as never);
+
+ expect(ack).toHaveBeenCalled();
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
+ it("blocks modal events when private metadata is missing userId", async () => {
+ enqueueSystemEventMock.mockClear();
+ const { ctx, getViewHandler } = createContext();
+ registerSlackInteractionEvents({ ctx: ctx as never });
+ const viewHandler = getViewHandler();
+ expect(viewHandler).toBeTruthy();
+
+ const ack = vi.fn().mockResolvedValue(undefined);
+ await viewHandler!({
+ ack,
+ body: {
+ user: { id: "U222" },
+ view: {
+ callback_id: "openclaw:deploy_form",
+ private_metadata: JSON.stringify({
+ channelId: "D123",
+ channelType: "im",
+ }),
+ },
+ },
+ } as never);
+
+ expect(ack).toHaveBeenCalled();
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
it("captures modal input labels and picker values across block types", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, getViewHandler } = createContext();
@@ -786,6 +973,7 @@ describe("registerSlackInteractionEvents", () => {
view: {
id: "V400",
callback_id: "openclaw:routing_form",
+ private_metadata: JSON.stringify({ userId: "U444" }),
state: {
values: {
env_block: {
@@ -1001,6 +1189,7 @@ describe("registerSlackInteractionEvents", () => {
view: {
id: "V555",
callback_id: "openclaw:long_richtext",
+ private_metadata: JSON.stringify({ userId: "U555" }),
state: {
values: {
richtext_block: {
@@ -1054,7 +1243,10 @@ describe("registerSlackInteractionEvents", () => {
previous_view_id: "VPREV900",
external_id: "deploy-ext-900",
hash: "view-hash-900",
- private_metadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
+ private_metadata: JSON.stringify({
+ sessionKey: "agent:main:slack:channel:C99",
+ userId: "U900",
+ }),
state: {
values: {
env_block: {
@@ -1101,7 +1293,10 @@ describe("registerSlackInteractionEvents", () => {
viewId: "V900",
userId: "U900",
isCleared: true,
- privateMetadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
+ privateMetadata: JSON.stringify({
+ sessionKey: "agent:main:slack:channel:C99",
+ userId: "U900",
+ }),
rootViewId: "VROOT900",
previousViewId: "VPREV900",
externalId: "deploy-ext-900",
@@ -1131,6 +1326,7 @@ describe("registerSlackInteractionEvents", () => {
view: {
id: "V901",
callback_id: "openclaw:deploy_form",
+ private_metadata: JSON.stringify({ userId: "U901" }),
},
},
});
diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts
index cbc4fc9f36e..40a06ad9f2e 100644
--- a/src/slack/monitor/events/interactions.ts
+++ b/src/slack/monitor/events/interactions.ts
@@ -2,6 +2,7 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js";
+import { authorizeSlackSystemEventSender } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
@@ -78,6 +79,7 @@ type SlackModalBody = {
type SlackModalEventBase = {
callbackId: string;
userId: string;
+ expectedUserId?: string;
viewId?: string;
sessionRouting: ReturnType;
payload: {
@@ -366,11 +368,15 @@ function summarizeViewState(values: unknown): ModalInputSummary[] {
function resolveModalSessionRouting(params: {
ctx: SlackMonitorContext;
- privateMetadata: unknown;
+ metadata: ReturnType;
}): { sessionKey: string; channelId?: string; channelType?: string } {
- const metadata = parseSlackModalPrivateMetadata(params.privateMetadata);
+ const metadata = params.metadata;
if (metadata.sessionKey) {
- return { sessionKey: metadata.sessionKey };
+ return {
+ sessionKey: metadata.sessionKey,
+ channelId: metadata.channelId,
+ channelType: metadata.channelType,
+ };
}
if (metadata.channelId) {
return {
@@ -416,17 +422,19 @@ function resolveSlackModalEventBase(params: {
ctx: SlackMonitorContext;
body: SlackModalBody;
}): SlackModalEventBase {
+ const metadata = parseSlackModalPrivateMetadata(params.body.view?.private_metadata);
const callbackId = params.body.view?.callback_id ?? "unknown";
const userId = params.body.user?.id ?? "unknown";
const viewId = params.body.view?.id;
const inputs = summarizeViewState(params.body.view?.state?.values);
const sessionRouting = resolveModalSessionRouting({
ctx: params.ctx,
- privateMetadata: params.body.view?.private_metadata,
+ metadata,
});
return {
callbackId,
userId,
+ expectedUserId: metadata.userId,
viewId,
sessionRouting,
payload: {
@@ -449,16 +457,17 @@ function resolveSlackModalEventBase(params: {
};
}
-function emitSlackModalLifecycleEvent(params: {
+async function emitSlackModalLifecycleEvent(params: {
ctx: SlackMonitorContext;
body: SlackModalBody;
interactionType: SlackModalInteractionKind;
contextPrefix: "slack:interaction:view" | "slack:interaction:view-closed";
-}): void {
- const { callbackId, userId, viewId, sessionRouting, payload } = resolveSlackModalEventBase({
- ctx: params.ctx,
- body: params.body,
- });
+}): Promise {
+ const { callbackId, userId, expectedUserId, viewId, sessionRouting, payload } =
+ resolveSlackModalEventBase({
+ ctx: params.ctx,
+ body: params.body,
+ });
const isViewClosed = params.interactionType === "view_closed";
const isCleared = params.body.is_cleared === true;
const eventPayload = isViewClosed
@@ -482,6 +491,27 @@ function emitSlackModalLifecycleEvent(params: {
);
}
+ if (!expectedUserId) {
+ params.ctx.runtime.log?.(
+ `slack:interaction drop modal callback=${callbackId} user=${userId} reason=missing-expected-user`,
+ );
+ return;
+ }
+
+ const auth = await authorizeSlackSystemEventSender({
+ ctx: params.ctx,
+ senderId: userId,
+ channelId: sessionRouting.channelId,
+ channelType: sessionRouting.channelType,
+ expectedSenderId: expectedUserId,
+ });
+ if (!auth.allowed) {
+ params.ctx.runtime.log?.(
+ `slack:interaction drop modal callback=${callbackId} user=${userId} reason=${auth.reason ?? "unauthorized"}`,
+ );
+ return;
+ }
+
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
sessionKey: sessionRouting.sessionKey,
contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"),
@@ -497,7 +527,7 @@ function registerModalLifecycleHandler(params: {
}) {
params.register(params.matcher, async ({ ack, body }: SlackModalEventHandlerArgs) => {
await ack();
- emitSlackModalLifecycleEvent({
+ await emitSlackModalLifecycleEvent({
ctx: params.ctx,
body: body as SlackModalBody,
interactionType: params.interactionType,
@@ -557,6 +587,27 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
const threadTs = typedBody.container?.thread_ts;
+ const auth = await authorizeSlackSystemEventSender({
+ ctx,
+ senderId: userId,
+ channelId,
+ });
+ if (!auth.allowed) {
+ ctx.runtime.log?.(
+ `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
+ );
+ if (respond) {
+ try {
+ await respond({
+ text: "You are not authorized to use this control.",
+ response_type: "ephemeral",
+ });
+ } catch {
+ // Best-effort feedback only.
+ }
+ }
+ return;
+ }
const actionSummary = summarizeAction(typedAction);
const eventPayload: InteractionSummary = {
interactionType: "block_action",
@@ -581,7 +632,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
// Pass undefined (not "unknown") to allow proper main session fallback
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId: channelId,
- channelType: undefined,
+ channelType: auth.channelType,
});
// Build context key - only include defined values to avoid "unknown" noise
diff --git a/src/slack/monitor/events/pins.test.ts b/src/slack/monitor/events/pins.test.ts
new file mode 100644
index 00000000000..00c2528bbdb
--- /dev/null
+++ b/src/slack/monitor/events/pins.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it, vi } from "vitest";
+import { registerSlackPinEvents } from "./pins.js";
+import {
+ createSlackSystemEventTestHarness,
+ type SlackSystemEventTestOverrides,
+} from "./system-event-test-harness.js";
+
+const enqueueSystemEventMock = vi.fn();
+const readAllowFromStoreMock = vi.fn();
+
+vi.mock("../../../infra/system-events.js", () => ({
+ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
+}));
+
+vi.mock("../../../pairing/pairing-store.js", () => ({
+ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
+}));
+
+type SlackPinHandler = (args: { event: Record; body: unknown }) => Promise;
+
+function createPinContext(overrides?: SlackSystemEventTestOverrides) {
+ const harness = createSlackSystemEventTestHarness(overrides);
+ registerSlackPinEvents({ ctx: harness.ctx });
+ return {
+ getAddedHandler: () => harness.getHandler("pin_added") as SlackPinHandler | null,
+ getRemovedHandler: () => harness.getHandler("pin_removed") as SlackPinHandler | null,
+ };
+}
+
+function makePinEvent(overrides?: { user?: string; channel?: string }) {
+ return {
+ type: "pin_added",
+ user: overrides?.user ?? "U1",
+ channel_id: overrides?.channel ?? "D1",
+ event_ts: "123.456",
+ item: {
+ type: "message",
+ message: {
+ ts: "123.456",
+ },
+ },
+ };
+}
+
+describe("registerSlackPinEvents", () => {
+ it("enqueues DM pin system events when dmPolicy is open", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createPinContext({ dmPolicy: "open" });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makePinEvent(),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks DM pin system events when dmPolicy is disabled", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createPinContext({ dmPolicy: "disabled" });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makePinEvent(),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
+ it("blocks DM pin system events for unauthorized senders in allowlist mode", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createPinContext({
+ dmPolicy: "allowlist",
+ allowFrom: ["U2"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makePinEvent({ user: "U1" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
+ it("allows DM pin system events for authorized senders in allowlist mode", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createPinContext({
+ dmPolicy: "allowlist",
+ allowFrom: ["U1"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makePinEvent({ user: "U1" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks channel pin events for users outside channel users allowlist", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createPinContext({
+ dmPolicy: "open",
+ channelType: "channel",
+ channelUsers: ["U_OWNER"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/slack/monitor/events/pins.ts b/src/slack/monitor/events/pins.ts
index 2613bc35e24..9a63aa4a972 100644
--- a/src/slack/monitor/events/pins.ts
+++ b/src/slack/monitor/events/pins.ts
@@ -1,9 +1,9 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
-import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackPinEvent } from "../types.js";
+import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js";
async function handleSlackPinEvent(params: {
ctx: SlackMonitorContext;
@@ -22,32 +22,26 @@ async function handleSlackPinEvent(params: {
const payload = event as SlackPinEvent;
const channelId = payload.channel_id;
- const channelInfo = channelId ? await ctx.resolveChannelName(channelId) : {};
- if (
- !ctx.isChannelAllowed({
- channelId,
- channelName: channelInfo?.name,
- channelType: channelInfo?.type,
- })
- ) {
+ const ingressContext = await authorizeAndResolveSlackSystemEventContext({
+ ctx,
+ senderId: payload.user,
+ channelId,
+ eventKind: "pin",
+ });
+ if (!ingressContext) {
return;
}
- const label = resolveSlackChannelLabel({
- channelId,
- channelName: channelInfo?.name,
- });
const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {};
const userLabel = userInfo?.name ?? payload.user ?? "someone";
const itemType = payload.item?.type ?? "item";
const messageId = payload.item?.message?.ts ?? payload.event_ts;
- const sessionKey = ctx.resolveSlackSystemEventSessionKey({
- channelId,
- channelType: channelInfo?.type ?? undefined,
- });
- enqueueSystemEvent(`Slack: ${userLabel} ${action} a ${itemType} in ${label}.`, {
- sessionKey,
- contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
- });
+ enqueueSystemEvent(
+ `Slack: ${userLabel} ${action} a ${itemType} in ${ingressContext.channelLabel}.`,
+ {
+ sessionKey: ingressContext.sessionKey,
+ contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
+ },
+ );
} catch (err) {
ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${String(err)}`));
}
diff --git a/src/slack/monitor/events/reactions.test.ts b/src/slack/monitor/events/reactions.test.ts
new file mode 100644
index 00000000000..e95a1ec5a8c
--- /dev/null
+++ b/src/slack/monitor/events/reactions.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it, vi } from "vitest";
+import { registerSlackReactionEvents } from "./reactions.js";
+import {
+ createSlackSystemEventTestHarness,
+ type SlackSystemEventTestOverrides,
+} from "./system-event-test-harness.js";
+
+const enqueueSystemEventMock = vi.fn();
+const readAllowFromStoreMock = vi.fn();
+
+vi.mock("../../../infra/system-events.js", () => ({
+ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
+}));
+
+vi.mock("../../../pairing/pairing-store.js", () => ({
+ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
+}));
+
+type SlackReactionHandler = (args: {
+ event: Record;
+ body: unknown;
+}) => Promise;
+
+function createReactionContext(overrides?: SlackSystemEventTestOverrides) {
+ const harness = createSlackSystemEventTestHarness(overrides);
+ registerSlackReactionEvents({ ctx: harness.ctx });
+ return {
+ getAddedHandler: () => harness.getHandler("reaction_added") as SlackReactionHandler | null,
+ getRemovedHandler: () => harness.getHandler("reaction_removed") as SlackReactionHandler | null,
+ };
+}
+
+function makeReactionEvent(overrides?: { user?: string; channel?: string }) {
+ return {
+ type: "reaction_added",
+ user: overrides?.user ?? "U1",
+ reaction: "thumbsup",
+ item: {
+ type: "message",
+ channel: overrides?.channel ?? "D1",
+ ts: "123.456",
+ },
+ item_user: "UBOT",
+ };
+}
+
+describe("registerSlackReactionEvents", () => {
+ it("enqueues DM reaction system events when dmPolicy is open", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createReactionContext({ dmPolicy: "open" });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makeReactionEvent(),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks DM reaction system events when dmPolicy is disabled", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createReactionContext({ dmPolicy: "disabled" });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makeReactionEvent(),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
+ it("blocks DM reaction system events for unauthorized senders in allowlist mode", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createReactionContext({
+ dmPolicy: "allowlist",
+ allowFrom: ["U2"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makeReactionEvent({ user: "U1" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+
+ it("allows DM reaction system events for authorized senders in allowlist mode", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createReactionContext({
+ dmPolicy: "allowlist",
+ allowFrom: ["U1"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makeReactionEvent({ user: "U1" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("enqueues channel reaction events regardless of dmPolicy", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getRemovedHandler } = createReactionContext({
+ dmPolicy: "disabled",
+ channelType: "channel",
+ });
+ const removedHandler = getRemovedHandler();
+ expect(removedHandler).toBeTruthy();
+
+ await removedHandler!({
+ event: {
+ ...makeReactionEvent({ channel: "C1" }),
+ type: "reaction_removed",
+ },
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks channel reaction events for users outside channel users allowlist", async () => {
+ enqueueSystemEventMock.mockClear();
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ const { getAddedHandler } = createReactionContext({
+ dmPolicy: "open",
+ channelType: "channel",
+ channelUsers: ["U_OWNER"],
+ });
+ const addedHandler = getAddedHandler();
+ expect(addedHandler).toBeTruthy();
+
+ await addedHandler!({
+ event: makeReactionEvent({ channel: "C1", user: "U_ATTACKER" }),
+ body: {},
+ });
+
+ expect(enqueueSystemEventMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/slack/monitor/events/reactions.ts b/src/slack/monitor/events/reactions.ts
index b437352d6ca..07dcf0f8be3 100644
--- a/src/slack/monitor/events/reactions.ts
+++ b/src/slack/monitor/events/reactions.ts
@@ -1,9 +1,9 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
-import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackReactionEvent } from "../types.js";
+import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js";
export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
@@ -15,35 +15,30 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }
return;
}
- const channelInfo = item.channel ? await ctx.resolveChannelName(item.channel) : {};
- const channelType = channelInfo?.type;
- if (
- !ctx.isChannelAllowed({
- channelId: item.channel,
- channelName: channelInfo?.name,
- channelType,
- })
- ) {
+ const ingressContext = await authorizeAndResolveSlackSystemEventContext({
+ ctx,
+ senderId: event.user,
+ channelId: item.channel,
+ eventKind: "reaction",
+ });
+ if (!ingressContext) {
return;
}
- const channelLabel = resolveSlackChannelLabel({
- channelId: item.channel,
- channelName: channelInfo?.name,
- });
- const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined;
+ const actorInfoPromise: Promise<{ name?: string } | undefined> = event.user
+ ? ctx.resolveUserName(event.user)
+ : Promise.resolve(undefined);
+ const authorInfoPromise: Promise<{ name?: string } | undefined> = event.item_user
+ ? ctx.resolveUserName(event.item_user)
+ : Promise.resolve(undefined);
+ const [actorInfo, authorInfo] = await Promise.all([actorInfoPromise, authorInfoPromise]);
const actorLabel = actorInfo?.name ?? event.user;
const emojiLabel = event.reaction ?? "emoji";
- const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined;
const authorLabel = authorInfo?.name ?? event.item_user;
- const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`;
+ const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${ingressContext.channelLabel} msg ${item.ts}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
- const sessionKey = ctx.resolveSlackSystemEventSessionKey({
- channelId: item.channel,
- channelType,
- });
enqueueSystemEvent(text, {
- sessionKey,
+ sessionKey: ingressContext.sessionKey,
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
});
} catch (err) {
diff --git a/src/slack/monitor/events/system-event-context.ts b/src/slack/monitor/events/system-event-context.ts
new file mode 100644
index 00000000000..5df48dfd167
--- /dev/null
+++ b/src/slack/monitor/events/system-event-context.ts
@@ -0,0 +1,44 @@
+import { logVerbose } from "../../../globals.js";
+import { authorizeSlackSystemEventSender } from "../auth.js";
+import { resolveSlackChannelLabel } from "../channel-config.js";
+import type { SlackMonitorContext } from "../context.js";
+
+export type SlackAuthorizedSystemEventContext = {
+ channelLabel: string;
+ sessionKey: string;
+};
+
+export async function authorizeAndResolveSlackSystemEventContext(params: {
+ ctx: SlackMonitorContext;
+ senderId?: string;
+ channelId?: string;
+ channelType?: string | null;
+ eventKind: string;
+}): Promise {
+ const { ctx, senderId, channelId, channelType, eventKind } = params;
+ const auth = await authorizeSlackSystemEventSender({
+ ctx,
+ senderId,
+ channelId,
+ channelType,
+ });
+ if (!auth.allowed) {
+ logVerbose(
+ `slack: drop ${eventKind} sender ${senderId ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
+ );
+ return undefined;
+ }
+
+ const channelLabel = resolveSlackChannelLabel({
+ channelId,
+ channelName: auth.channelName,
+ });
+ const sessionKey = ctx.resolveSlackSystemEventSessionKey({
+ channelId,
+ channelType: auth.channelType,
+ });
+ return {
+ channelLabel,
+ sessionKey,
+ };
+}
diff --git a/src/slack/monitor/events/system-event-test-harness.ts b/src/slack/monitor/events/system-event-test-harness.ts
new file mode 100644
index 00000000000..73a50d0444c
--- /dev/null
+++ b/src/slack/monitor/events/system-event-test-harness.ts
@@ -0,0 +1,56 @@
+import type { SlackMonitorContext } from "../context.js";
+
+export type SlackSystemEventHandler = (args: {
+ event: Record;
+ body: unknown;
+}) => Promise;
+
+export type SlackSystemEventTestOverrides = {
+ dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
+ allowFrom?: string[];
+ channelType?: "im" | "channel";
+ channelUsers?: string[];
+};
+
+export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTestOverrides) {
+ const handlers: Record = {};
+ const channelType = overrides?.channelType ?? "im";
+ const app = {
+ event: (name: string, handler: SlackSystemEventHandler) => {
+ handlers[name] = handler;
+ },
+ };
+ const ctx = {
+ app,
+ runtime: { error: () => {} },
+ dmEnabled: true,
+ dmPolicy: overrides?.dmPolicy ?? "open",
+ defaultRequireMention: true,
+ channelsConfig: overrides?.channelUsers
+ ? {
+ C1: {
+ users: overrides.channelUsers,
+ allow: true,
+ },
+ }
+ : undefined,
+ groupPolicy: "open",
+ allowFrom: overrides?.allowFrom ?? [],
+ allowNameMatching: false,
+ shouldDropMismatchedSlackEvent: () => false,
+ isChannelAllowed: () => true,
+ resolveChannelName: async () => ({
+ name: channelType === "im" ? "direct" : "general",
+ type: channelType,
+ }),
+ resolveUserName: async () => ({ name: "alice" }),
+ resolveSlackSystemEventSessionKey: () => "agent:main:main",
+ } as unknown as SlackMonitorContext;
+
+ return {
+ ctx,
+ getHandler(name: string): SlackSystemEventHandler | null {
+ return handlers[name] ?? null;
+ },
+ };
+}
diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts
index 3262873718d..3da7f08164e 100644
--- a/src/slack/monitor/monitor.test.ts
+++ b/src/slack/monitor/monitor.test.ts
@@ -60,6 +60,27 @@ describe("resolveSlackChannelConfig", () => {
matchSource: "direct",
});
});
+
+ it("matches channel config key stored in lowercase when Slack delivers uppercase channel ID", () => {
+ // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345).
+ // Users commonly copy them in lowercase from docs or older CLI output.
+ const res = resolveSlackChannelConfig({
+ channelId: "C0ABC12345",
+ channels: { c0abc12345: { allow: true, requireMention: false } },
+ defaultRequireMention: true,
+ });
+ expect(res).toMatchObject({ allowed: true, requireMention: false });
+ });
+
+ it("matches channel config key stored in uppercase when user types lowercase channel ID", () => {
+ // Defensive: also handle the inverse direction.
+ const res = resolveSlackChannelConfig({
+ channelId: "c0abc12345",
+ channels: { C0ABC12345: { allow: true, requireMention: false } },
+ defaultRequireMention: true,
+ });
+ expect(res).toMatchObject({ allowed: true, requireMention: false });
+ });
});
const baseParams = () => ({
diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts
index e4d42cd889e..ad28c32883d 100644
--- a/src/telegram/bot-handlers.ts
+++ b/src/telegram/bot-handlers.ts
@@ -17,6 +17,7 @@ import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js
import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
+import type { DmPolicy } from "../config/types.base.js";
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import { danger, logVerbose, warn } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -507,6 +508,131 @@ export const registerTelegramHandlers = ({
return false;
};
+ type TelegramGroupAllowContext = Awaited>;
+ type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
+ type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string };
+ type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy };
+
+ const TELEGRAM_EVENT_AUTH_RULES: Record<
+ TelegramEventAuthorizationMode,
+ {
+ enforceDirectAuthorization: boolean;
+ enforceGroupAllowlistAuthorization: boolean;
+ deniedDmReason: string;
+ deniedGroupReason: string;
+ }
+ > = {
+ reaction: {
+ enforceDirectAuthorization: true,
+ enforceGroupAllowlistAuthorization: false,
+ deniedDmReason: "reaction unauthorized by dm policy/allowlist",
+ deniedGroupReason: "reaction unauthorized by group allowlist",
+ },
+ "callback-scope": {
+ enforceDirectAuthorization: false,
+ enforceGroupAllowlistAuthorization: false,
+ deniedDmReason: "callback unauthorized by inlineButtonsScope",
+ deniedGroupReason: "callback unauthorized by inlineButtonsScope",
+ },
+ "callback-allowlist": {
+ enforceDirectAuthorization: true,
+ enforceGroupAllowlistAuthorization: true,
+ deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist",
+ deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist",
+ },
+ };
+
+ const resolveTelegramEventAuthorizationContext = async (params: {
+ chatId: number;
+ isForum: boolean;
+ messageThreadId?: number;
+ groupAllowContext?: TelegramGroupAllowContext;
+ }): Promise => {
+ const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
+ const groupAllowContext =
+ params.groupAllowContext ??
+ (await resolveTelegramGroupAllowFromContext({
+ chatId: params.chatId,
+ accountId,
+ isForum: params.isForum,
+ messageThreadId: params.messageThreadId,
+ groupAllowFrom,
+ resolveTelegramGroupConfig,
+ }));
+ return { dmPolicy, ...groupAllowContext };
+ };
+
+ const authorizeTelegramEventSender = (params: {
+ chatId: number;
+ chatTitle?: string;
+ isGroup: boolean;
+ senderId: string;
+ senderUsername: string;
+ mode: TelegramEventAuthorizationMode;
+ context: TelegramEventAuthorizationContext;
+ }): TelegramEventAuthorizationResult => {
+ const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params;
+ const {
+ dmPolicy,
+ resolvedThreadId,
+ storeAllowFrom,
+ groupConfig,
+ topicConfig,
+ effectiveGroupAllow,
+ hasGroupAllowOverride,
+ } = context;
+ const authRules = TELEGRAM_EVENT_AUTH_RULES[mode];
+ const {
+ enforceDirectAuthorization,
+ enforceGroupAllowlistAuthorization,
+ deniedDmReason,
+ deniedGroupReason,
+ } = authRules;
+ if (
+ shouldSkipGroupMessage({
+ isGroup,
+ chatId,
+ chatTitle,
+ resolvedThreadId,
+ senderId,
+ senderUsername,
+ effectiveGroupAllow,
+ hasGroupAllowOverride,
+ groupConfig,
+ topicConfig,
+ })
+ ) {
+ return { allowed: false, reason: "group-policy" };
+ }
+
+ if (!isGroup && enforceDirectAuthorization) {
+ if (dmPolicy === "disabled") {
+ logVerbose(
+ `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`,
+ );
+ return { allowed: false, reason: "direct-disabled" };
+ }
+ if (dmPolicy !== "open") {
+ const effectiveDmAllow = normalizeAllowFromWithStore({
+ allowFrom,
+ storeAllowFrom,
+ dmPolicy,
+ });
+ if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) {
+ logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`);
+ return { allowed: false, reason: "direct-unauthorized" };
+ }
+ }
+ }
+ if (isGroup && enforceGroupAllowlistAuthorization) {
+ if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) {
+ logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`);
+ return { allowed: false, reason: "group-unauthorized" };
+ }
+ }
+ return { allowed: true };
+ };
+
// Handle emoji reactions to messages.
bot.on("message_reaction", async (ctx) => {
try {
@@ -521,6 +647,10 @@ export const registerTelegramHandlers = ({
const chatId = reaction.chat.id;
const messageId = reaction.message_id;
const user = reaction.user;
+ const senderId = user?.id != null ? String(user.id) : "";
+ const senderUsername = user?.username ?? "";
+ const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
+ const isForum = reaction.chat.is_forum === true;
// Resolve reaction notification mode (default: "own").
const reactionMode = telegramCfg.reactionNotifications ?? "own";
@@ -533,6 +663,22 @@ export const registerTelegramHandlers = ({
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
return;
}
+ const eventAuthContext = await resolveTelegramEventAuthorizationContext({
+ chatId,
+ isForum,
+ });
+ const senderAuthorization = authorizeTelegramEventSender({
+ chatId,
+ chatTitle: reaction.chat.title,
+ isGroup,
+ senderId,
+ senderUsername,
+ mode: "reaction",
+ context: eventAuthContext,
+ });
+ if (!senderAuthorization.allowed) {
+ return;
+ }
// Detect added reactions.
const oldEmojis = new Set(
@@ -552,12 +698,12 @@ export const registerTelegramHandlers = ({
const senderName = user
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
: undefined;
- const senderUsername = user?.username ? `@${user.username}` : undefined;
+ const senderUsernameLabel = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
- if (senderName && senderUsername) {
- senderLabel = `${senderName} (${senderUsername})`;
- } else if (!senderName && senderUsername) {
- senderLabel = senderUsername;
+ if (senderName && senderUsernameLabel) {
+ senderLabel = `${senderName} (${senderUsernameLabel})`;
+ } else if (!senderName && senderUsernameLabel) {
+ senderLabel = senderUsernameLabel;
}
if (!senderLabel && user?.id) {
senderLabel = `id:${user.id}`;
@@ -567,8 +713,6 @@ export const registerTelegramHandlers = ({
// Reactions target a specific message_id; the Telegram Bot API does not include
// message_thread_id on MessageReactionUpdated, so we route to the chat-level
// session (forum topic routing is not available for reactions).
- const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
- const isForum = reaction.chat.is_forum === true;
const resolvedThreadId = isForum
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
: undefined;
@@ -855,67 +999,29 @@ export const registerTelegramHandlers = ({
const messageThreadId = callbackMessage.message_thread_id;
const isForum = callbackMessage.chat.is_forum === true;
- const groupAllowContext = await resolveTelegramGroupAllowFromContext({
+ const eventAuthContext = await resolveTelegramEventAuthorizationContext({
chatId,
- accountId,
- dmPolicy: telegramCfg.dmPolicy ?? "pairing",
isForum,
messageThreadId,
- groupAllowFrom,
- resolveTelegramGroupConfig,
- });
- const {
- resolvedThreadId,
- storeAllowFrom,
- groupConfig,
- topicConfig,
- effectiveGroupAllow,
- hasGroupAllowOverride,
- } = groupAllowContext;
- const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
- const effectiveDmAllow = normalizeAllowFromWithStore({
- allowFrom: telegramCfg.allowFrom,
- storeAllowFrom,
- dmPolicy,
});
+ const { resolvedThreadId, storeAllowFrom } = eventAuthContext;
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
- if (
- shouldSkipGroupMessage({
- isGroup,
- chatId,
- chatTitle: callbackMessage.chat.title,
- resolvedThreadId,
- senderId,
- senderUsername,
- effectiveGroupAllow,
- hasGroupAllowOverride,
- groupConfig,
- topicConfig,
- })
- ) {
+ const authorizationMode: TelegramEventAuthorizationMode =
+ inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope";
+ const senderAuthorization = authorizeTelegramEventSender({
+ chatId,
+ chatTitle: callbackMessage.chat.title,
+ isGroup,
+ senderId,
+ senderUsername,
+ mode: authorizationMode,
+ context: eventAuthContext,
+ });
+ if (!senderAuthorization.allowed) {
return;
}
- if (inlineButtonsScope === "allowlist") {
- if (!isGroup) {
- if (dmPolicy === "disabled") {
- return;
- }
- if (dmPolicy !== "open") {
- const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername);
- if (!allowed) {
- return;
- }
- }
- } else {
- const allowed = isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername);
- if (!allowed) {
- return;
- }
- }
- }
-
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];
@@ -1151,25 +1257,20 @@ export const registerTelegramHandlers = ({
if (shouldSkipUpdate(event.ctxForDedupe)) {
return;
}
- const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
-
- const groupAllowContext = await resolveTelegramGroupAllowFromContext({
+ const eventAuthContext = await resolveTelegramEventAuthorizationContext({
chatId: event.chatId,
- accountId,
- dmPolicy,
isForum: event.isForum,
messageThreadId: event.messageThreadId,
- groupAllowFrom,
- resolveTelegramGroupConfig,
});
const {
+ dmPolicy,
resolvedThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
effectiveGroupAllow,
hasGroupAllowOverride,
- } = groupAllowContext;
+ } = eventAuthContext;
const effectiveDmAllow = normalizeAllowFromWithStore({
allowFrom,
storeAllowFrom,
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index 3ea805c944d..c3a2cfcdcb1 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -36,7 +36,12 @@ import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
-import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
+import {
+ firstDefined,
+ isSenderAllowed,
+ normalizeAllowFrom,
+ normalizeAllowFromWithStore,
+} from "./bot-access.js";
import {
buildGroupLabel,
buildSenderLabel,
@@ -189,11 +194,8 @@ export const buildTelegramMessageContext = async ({
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy });
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
- const effectiveGroupAllow = normalizeAllowFromWithStore({
- allowFrom: groupAllowOverride ?? groupAllowFrom,
- storeAllowFrom,
- dmPolicy,
- });
+ // Group sender checks are explicit and must not inherit DM pairing-store entries.
+ const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts
index 75a8fb6b9af..7e82adafec2 100644
--- a/src/telegram/bot-message-dispatch.test.ts
+++ b/src/telegram/bot-message-dispatch.test.ts
@@ -691,6 +691,52 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
+ it.each(["partial", "block"] as const)(
+ "keeps finalized text preview when the next assistant message is media-only (%s mode)",
+ async (streamMode) => {
+ let answerMessageId: number | undefined = 1001;
+ const answerDraftStream = {
+ update: vi.fn(),
+ flush: vi.fn().mockResolvedValue(undefined),
+ messageId: vi.fn().mockImplementation(() => answerMessageId),
+ clear: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn().mockResolvedValue(undefined),
+ forceNewMessage: vi.fn().mockImplementation(() => {
+ answerMessageId = undefined;
+ }),
+ };
+ const reasoningDraftStream = createDraftStream();
+ createTelegramDraftStream
+ .mockImplementationOnce(() => answerDraftStream)
+ .mockImplementationOnce(() => reasoningDraftStream);
+ dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
+ async ({ dispatcherOptions, replyOptions }) => {
+ await replyOptions?.onPartialReply?.({ text: "First message preview" });
+ await dispatcherOptions.deliver({ text: "First message final" }, { kind: "final" });
+ await replyOptions?.onAssistantMessageStart?.();
+ await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/voice.ogg" }, { kind: "final" });
+ return { queuedFinal: true };
+ },
+ );
+ deliverReplies.mockResolvedValue({ delivered: true });
+ editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
+ const bot = createBot();
+
+ await dispatchWithContext({ context: createContext(), streamMode, bot });
+
+ expect(editMessageTelegram).toHaveBeenCalledWith(
+ 123,
+ 1001,
+ "First message final",
+ expect.any(Object),
+ );
+ const deleteMessageCalls = (
+ bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } }
+ ).deleteMessage.mock.calls;
+ expect(deleteMessageCalls).not.toContainEqual([123, 1001]);
+ },
+ );
+
it("maps finals correctly when archived preview id arrives during final flush", async () => {
let answerMessageId: number | undefined;
let answerDraftParams:
diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts
index f45b79fb9ab..5b000a8dcd0 100644
--- a/src/telegram/bot-message-dispatch.ts
+++ b/src/telegram/bot-message-dispatch.ts
@@ -567,7 +567,10 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.resetForNextStep();
if (answerLane.hasStreamedMessage) {
const previewMessageId = answerLane.stream?.messageId();
- if (typeof previewMessageId === "number") {
+ // Only archive previews that still need a matching final text update.
+ // Once a preview has already been finalized, archiving it here causes
+ // cleanup to delete a user-visible final message on later media-only turns.
+ if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) {
archivedAnswerPreviews.push({
messageId: previewMessageId,
textSnapshot: answerLane.lastPartialText,
@@ -576,6 +579,8 @@ export const dispatchTelegramMessage = async ({
answerLane.stream?.forceNewMessage();
}
resetDraftLaneState(answerLane);
+ // New assistant message boundary: this lane now tracks a fresh preview lifecycle.
+ finalizedPreviewByLane.answer = false;
}
: undefined,
onReasoningEnd: reasoningLane.stream
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index 88316cbeb82..f963aa269cc 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -170,7 +170,6 @@ async function resolveTelegramCommandAuth(params: {
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
- dmPolicy: telegramCfg.dmPolicy ?? "pairing",
isForum,
messageThreadId,
groupAllowFrom,
diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts
index 942a1c6c2b3..4be6b0dcbf3 100644
--- a/src/telegram/bot.create-telegram-bot.test.ts
+++ b/src/telegram/bot.create-telegram-bot.test.ts
@@ -1416,6 +1416,30 @@ describe("createTelegramBot", () => {
expect(replySpy.mock.calls.length, testCase.name).toBe(0);
}
});
+ it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => {
+ resetHarnessSpies();
+ loadConfig.mockReturnValue({
+ channels: {
+ telegram: {
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["222222222"],
+ groups: { "*": { requireMention: false } },
+ },
+ },
+ });
+ readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);
+
+ await dispatchMessage({
+ message: {
+ chat: { id: -100123456789, type: "group", title: "Test Group" },
+ from: { id: 123456789, username: "testuser" },
+ text: "hello",
+ date: 1736380800,
+ },
+ });
+
+ expect(replySpy).not.toHaveBeenCalled();
+ });
it("allows control commands with TG-prefixed groupAllowFrom entries", async () => {
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index 03380dbbf62..e7e326d0e36 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -832,6 +832,95 @@ describe("createTelegramBot", () => {
);
});
+ it.each([
+ {
+ name: "blocks reaction when dmPolicy is disabled",
+ updateId: 510,
+ channelConfig: { dmPolicy: "disabled", reactionNotifications: "all" },
+ reaction: {
+ chat: { id: 1234, type: "private" },
+ message_id: 42,
+ user: { id: 9, first_name: "Ada" },
+ date: 1736380800,
+ old_reaction: [],
+ new_reaction: [{ type: "emoji", emoji: "👍" }],
+ },
+ expectedEnqueueCalls: 0,
+ },
+ {
+ name: "blocks reaction in allowlist mode for unauthorized direct sender",
+ updateId: 511,
+ channelConfig: {
+ dmPolicy: "allowlist",
+ allowFrom: ["12345"],
+ reactionNotifications: "all",
+ },
+ reaction: {
+ chat: { id: 1234, type: "private" },
+ message_id: 42,
+ user: { id: 9, first_name: "Ada" },
+ date: 1736380800,
+ old_reaction: [],
+ new_reaction: [{ type: "emoji", emoji: "👍" }],
+ },
+ expectedEnqueueCalls: 0,
+ },
+ {
+ name: "allows reaction in allowlist mode for authorized direct sender",
+ updateId: 512,
+ channelConfig: { dmPolicy: "allowlist", allowFrom: ["9"], reactionNotifications: "all" },
+ reaction: {
+ chat: { id: 1234, type: "private" },
+ message_id: 42,
+ user: { id: 9, first_name: "Ada" },
+ date: 1736380800,
+ old_reaction: [],
+ new_reaction: [{ type: "emoji", emoji: "👍" }],
+ },
+ expectedEnqueueCalls: 1,
+ },
+ {
+ name: "blocks reaction in group allowlist mode for unauthorized sender",
+ updateId: 513,
+ channelConfig: {
+ dmPolicy: "open",
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["12345"],
+ reactionNotifications: "all",
+ },
+ reaction: {
+ chat: { id: 9999, type: "supergroup" },
+ message_id: 77,
+ user: { id: 9, first_name: "Ada" },
+ date: 1736380800,
+ old_reaction: [],
+ new_reaction: [{ type: "emoji", emoji: "🔥" }],
+ },
+ expectedEnqueueCalls: 0,
+ },
+ ])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => {
+ onSpy.mockClear();
+ enqueueSystemEventSpy.mockClear();
+
+ loadConfig.mockReturnValue({
+ channels: {
+ telegram: channelConfig,
+ },
+ });
+
+ createTelegramBot({ token: "tok" });
+ const handler = getOnHandler("message_reaction") as (
+ ctx: Record,
+ ) => Promise;
+
+ await handler({
+ update: { update_id: updateId },
+ messageReaction: reaction,
+ });
+
+ expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls);
+ });
+
it("skips reaction when reactionNotifications is off", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts
index 493ad010082..ebfe36fbac0 100644
--- a/src/telegram/bot/helpers.ts
+++ b/src/telegram/bot/helpers.ts
@@ -3,11 +3,7 @@ import { formatLocationText, type NormalizedLocation } from "../../channels/loca
import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js";
import type { TelegramGroupConfig, TelegramTopicConfig } from "../../config/types.js";
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
-import {
- firstDefined,
- normalizeAllowFromWithStore,
- type NormalizedAllowFrom,
-} from "../bot-access.js";
+import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
import type { TelegramStreamMode } from "./types.js";
const TELEGRAM_GENERAL_TOPIC_ID = 1;
@@ -20,7 +16,6 @@ export type TelegramThreadSpec = {
export async function resolveTelegramGroupAllowFromContext(params: {
chatId: string | number;
accountId?: string;
- dmPolicy?: string;
isForum?: boolean;
messageThreadId?: number | null;
groupAllowFrom?: Array;
@@ -51,11 +46,9 @@ export async function resolveTelegramGroupAllowFromContext(params: {
resolvedThreadId,
);
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
- const effectiveGroupAllow = normalizeAllowFromWithStore({
- allowFrom: groupAllowOverride ?? params.groupAllowFrom,
- storeAllowFrom,
- dmPolicy: params.dmPolicy,
- });
+ // Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only).
+ // DM pairing store entries are not a group authorization source.
+ const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? params.groupAllowFrom);
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
return {
resolvedThreadId,
diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts
index 49fbcc13155..5c0df3de6ef 100644
--- a/src/telegram/monitor.test.ts
+++ b/src/telegram/monitor.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { monitorTelegramProvider } from "./monitor.js";
type MockCtx = {
@@ -67,6 +67,36 @@ const { startTelegramWebhookSpy } = vi.hoisted(() => ({
startTelegramWebhookSpy: vi.fn(async () => ({ server: { close: vi.fn() }, stop: vi.fn() })),
}));
+type RunnerStub = {
+ task: () => Promise;
+ stop: ReturnType void | Promise>>;
+ isRunning: () => boolean;
+};
+
+const makeRunnerStub = (overrides: Partial = {}): RunnerStub => ({
+ task: overrides.task ?? (() => Promise.resolve()),
+ stop: overrides.stop ?? vi.fn<() => void | Promise>(),
+ isRunning: overrides.isRunning ?? (() => false),
+});
+
+async function monitorWithAutoAbort(
+ opts: Omit[0], "abortSignal"> = {},
+) {
+ const abort = new AbortController();
+ runSpy.mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ }),
+ );
+ await monitorTelegramProvider({
+ token: "tok",
+ ...opts,
+ abortSignal: abort.signal,
+ });
+}
+
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -130,26 +160,37 @@ vi.mock("../auto-reply/reply.js", () => ({
}));
describe("monitorTelegramProvider (grammY)", () => {
+ let consoleErrorSpy: { mockRestore: () => void } | undefined;
+
beforeEach(() => {
loadConfig.mockReturnValue({
agents: { defaults: { maxConcurrent: 2 } },
channels: { telegram: {} },
});
initSpy.mockClear();
- runSpy.mockClear();
+ runSpy.mockReset().mockImplementation(() =>
+ makeRunnerStub({
+ task: () => Promise.reject(new Error("runSpy called without explicit test stub")),
+ }),
+ );
computeBackoff.mockClear();
sleepWithAbort.mockClear();
startTelegramWebhookSpy.mockClear();
registerUnhandledRejectionHandlerMock.mockClear();
resetUnhandledRejection();
createTelegramBotErrors.length = 0;
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy?.mockRestore();
});
it("processes a DM and sends reply", async () => {
Object.values(api).forEach((fn) => {
fn?.mockReset?.();
});
- await monitorTelegramProvider({ token: "tok" });
+ await monitorWithAutoAbort();
expect(handlers.message).toBeDefined();
await handlers.message?.({
message: {
@@ -172,7 +213,7 @@ describe("monitorTelegramProvider (grammY)", () => {
channels: { telegram: {} },
});
- await monitorTelegramProvider({ token: "tok" });
+ await monitorWithAutoAbort();
expect(runSpy).toHaveBeenCalledWith(
expect.anything(),
@@ -180,7 +221,7 @@ describe("monitorTelegramProvider (grammY)", () => {
sink: { concurrency: 3 },
runner: expect.objectContaining({
silent: true,
- maxRetryTime: 5 * 60 * 1000,
+ maxRetryTime: 60 * 60 * 1000,
retryInterval: "exponential",
}),
}),
@@ -191,7 +232,7 @@ describe("monitorTelegramProvider (grammY)", () => {
Object.values(api).forEach((fn) => {
fn?.mockReset?.();
});
- await monitorTelegramProvider({ token: "tok" });
+ await monitorWithAutoAbort();
await handlers.message?.({
message: {
message_id: 2,
@@ -205,24 +246,27 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("retries on recoverable undici fetch errors", async () => {
+ const abort = new AbortController();
const networkError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
}),
});
runSpy
- .mockImplementationOnce(() => ({
- task: () => Promise.reject(networkError),
- stop: vi.fn(),
- isRunning: (): boolean => false,
- }))
- .mockImplementationOnce(() => ({
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: (): boolean => false,
- }));
+ .mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: () => Promise.reject(networkError),
+ }),
+ )
+ .mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ }),
+ );
- await monitorTelegramProvider({ token: "tok" });
+ await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(computeBackoff).toHaveBeenCalled();
expect(sleepWithAbort).toHaveBeenCalled();
@@ -230,6 +274,7 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("deletes webhook before starting polling", async () => {
+ const abort = new AbortController();
const order: string[] = [];
api.deleteWebhook.mockReset();
api.deleteWebhook.mockImplementationOnce(async () => {
@@ -238,20 +283,21 @@ describe("monitorTelegramProvider (grammY)", () => {
});
runSpy.mockImplementationOnce(() => {
order.push("run");
- return {
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: () => false,
- };
+ return makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ });
});
- await monitorTelegramProvider({ token: "tok" });
+ await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(api.deleteWebhook).toHaveBeenCalledWith({ drop_pending_updates: false });
expect(order).toEqual(["deleteWebhook", "run"]);
});
it("retries recoverable deleteWebhook failures before polling", async () => {
+ const abort = new AbortController();
const cleanupError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
@@ -259,13 +305,15 @@ describe("monitorTelegramProvider (grammY)", () => {
});
api.deleteWebhook.mockReset();
api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true);
- runSpy.mockImplementationOnce(() => ({
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: () => false,
- }));
+ runSpy.mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ }),
+ );
- await monitorTelegramProvider({ token: "tok" });
+ await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(api.deleteWebhook).toHaveBeenCalledTimes(2);
expect(computeBackoff).toHaveBeenCalled();
@@ -274,6 +322,7 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("retries setup-time recoverable errors before starting polling", async () => {
+ const abort = new AbortController();
const setupError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
@@ -281,13 +330,15 @@ describe("monitorTelegramProvider (grammY)", () => {
});
createTelegramBotErrors.push(setupError);
- runSpy.mockImplementationOnce(() => ({
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: () => false,
- }));
+ runSpy.mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ }),
+ );
- await monitorTelegramProvider({ token: "tok" });
+ await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(computeBackoff).toHaveBeenCalled();
expect(sleepWithAbort).toHaveBeenCalled();
@@ -295,6 +346,7 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("awaits runner.stop before retrying after recoverable polling error", async () => {
+ const abort = new AbortController();
const recoverableError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect timeout"), {
code: "UND_ERR_CONNECT_TIMEOUT",
@@ -307,21 +359,22 @@ describe("monitorTelegramProvider (grammY)", () => {
});
runSpy
- .mockImplementationOnce(() => ({
- task: () => Promise.reject(recoverableError),
- stop: firstStop,
- isRunning: () => false,
- }))
+ .mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: () => Promise.reject(recoverableError),
+ stop: firstStop,
+ }),
+ )
.mockImplementationOnce(() => {
expect(firstStopped).toBe(true);
- return {
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: () => false,
- };
+ return makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ });
});
- await monitorTelegramProvider({ token: "tok" });
+ await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
expect(firstStop).toHaveBeenCalled();
expect(computeBackoff).toHaveBeenCalled();
@@ -330,16 +383,17 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("surfaces non-recoverable errors", async () => {
- runSpy.mockImplementationOnce(() => ({
- task: () => Promise.reject(new Error("bad token")),
- stop: vi.fn(),
- isRunning: (): boolean => false,
- }));
+ runSpy.mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: () => Promise.reject(new Error("bad token")),
+ }),
+ );
await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token");
});
it("force-restarts polling when unhandled network rejection stalls runner", async () => {
+ const abort = new AbortController();
let running = true;
let releaseTask: (() => void) | undefined;
const stop = vi.fn(async () => {
@@ -348,21 +402,25 @@ describe("monitorTelegramProvider (grammY)", () => {
});
runSpy
- .mockImplementationOnce(() => ({
- task: () =>
- new Promise((resolve) => {
- releaseTask = resolve;
- }),
- stop,
- isRunning: () => running,
- }))
- .mockImplementationOnce(() => ({
- task: () => Promise.resolve(),
- stop: vi.fn(),
- isRunning: () => false,
- }));
+ .mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: () =>
+ new Promise((resolve) => {
+ releaseTask = resolve;
+ }),
+ stop,
+ isRunning: () => running,
+ }),
+ )
+ .mockImplementationOnce(() =>
+ makeRunnerStub({
+ task: async () => {
+ abort.abort();
+ },
+ }),
+ );
- const monitor = monitorTelegramProvider({ token: "tok" });
+ const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
expect(emitUnhandledRejection(new TypeError("fetch failed"))).toBe(true);
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index 8637f488dd6..06410b74ed1 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -2,6 +2,7 @@ import { type RunOptions, run } from "@grammyjs/runner";
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
+import { waitForAbortSignal } from "../infra/abort-signal.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { formatErrorMessage } from "../infra/errors.js";
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
@@ -45,8 +46,9 @@ export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions;
+
const isGetUpdatesConflict = (err: unknown) => {
if (!err || typeof err !== "object") {
return false;
@@ -169,16 +173,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
});
- const abortSignal = opts.abortSignal;
- if (abortSignal && !abortSignal.aborted) {
- await new Promise((resolve) => {
- const onAbort = () => {
- abortSignal.removeEventListener("abort", onAbort);
- resolve();
- };
- abortSignal.addEventListener("abort", onAbort, { once: true });
- });
- }
+ await waitForAbortSignal(opts.abortSignal);
return;
}
@@ -186,21 +181,11 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
let restartAttempts = 0;
let webhookCleared = false;
const runnerOptions = createTelegramRunnerOptions(cfg);
- const waitBeforeRetryOnRecoverableSetupError = async (
- err: unknown,
- logPrefix: string,
- ): Promise => {
- if (opts.abortSignal?.aborted) {
- return false;
- }
- if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) {
- throw err;
- }
+ const waitBeforeRestart = async (buildLine: (delay: string) => string): Promise => {
restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
- (opts.runtime?.error ?? console.error)(
- `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${formatDurationPrecise(delayMs)}.`,
- );
+ const delay = formatDurationPrecise(delayMs);
+ log(buildLine(delay));
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch (sleepErr) {
@@ -212,10 +197,24 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
return true;
};
- while (!opts.abortSignal?.aborted) {
- let bot;
+ const waitBeforeRetryOnRecoverableSetupError = async (
+ err: unknown,
+ logPrefix: string,
+ ): Promise => {
+ if (opts.abortSignal?.aborted) {
+ return false;
+ }
+ if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) {
+ throw err;
+ }
+ return waitBeforeRestart(
+ (delay) => `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${delay}.`,
+ );
+ };
+
+ const createPollingBot = async (): Promise => {
try {
- bot = createTelegramBot({
+ return createTelegramBot({
token,
runtime: opts.runtime,
proxyFetch,
@@ -232,31 +231,34 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
"Telegram setup network error",
);
if (!shouldRetry) {
- return;
+ return undefined;
}
- continue;
+ return undefined;
}
+ };
- if (!webhookCleared) {
- try {
- await withTelegramApiErrorLogging({
- operation: "deleteWebhook",
- runtime: opts.runtime,
- fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }),
- });
- webhookCleared = true;
- } catch (err) {
- const shouldRetry = await waitBeforeRetryOnRecoverableSetupError(
- err,
- "Telegram webhook cleanup failed",
- );
- if (!shouldRetry) {
- return;
- }
- continue;
- }
+ const ensureWebhookCleanup = async (bot: TelegramBot): Promise<"ready" | "retry" | "exit"> => {
+ if (webhookCleared) {
+ return "ready";
}
+ try {
+ await withTelegramApiErrorLogging({
+ operation: "deleteWebhook",
+ runtime: opts.runtime,
+ fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }),
+ });
+ webhookCleared = true;
+ return "ready";
+ } catch (err) {
+ const shouldRetry = await waitBeforeRetryOnRecoverableSetupError(
+ err,
+ "Telegram webhook cleanup failed",
+ );
+ return shouldRetry ? "retry" : "exit";
+ }
+ };
+ const runPollingCycle = async (bot: TelegramBot): Promise<"continue" | "exit"> => {
const runner = run(bot, runnerOptions);
activeRunner = runner;
let stopPromise: Promise | undefined;
@@ -277,17 +279,17 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
try {
// runner.task() returns a promise that resolves when the runner stops
await runner.task();
- if (!forceRestarted) {
- return;
+ if (opts.abortSignal?.aborted) {
+ return "exit";
}
+ const reason = forceRestarted
+ ? "unhandled network error"
+ : "runner stopped (maxRetryTime exceeded or graceful stop)";
forceRestarted = false;
- restartAttempts += 1;
- const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
- log(
- `Telegram polling runner restarted after unhandled network error; retrying in ${formatDurationPrecise(delayMs)}.`,
+ const shouldRestart = await waitBeforeRestart(
+ (delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`,
);
- await sleepWithAbort(delayMs, opts.abortSignal);
- continue;
+ return shouldRestart ? "continue" : "exit";
} catch (err) {
forceRestarted = false;
if (opts.abortSignal?.aborted) {
@@ -298,25 +300,36 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
if (!isConflict && !isRecoverable) {
throw err;
}
- restartAttempts += 1;
- const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
const reason = isConflict ? "getUpdates conflict" : "network error";
const errMsg = formatErrorMessage(err);
- (opts.runtime?.error ?? console.error)(
- `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`,
+ const shouldRestart = await waitBeforeRestart(
+ (delay) => `Telegram ${reason}: ${errMsg}; retrying in ${delay}.`,
);
- try {
- await sleepWithAbort(delayMs, opts.abortSignal);
- } catch (sleepErr) {
- if (opts.abortSignal?.aborted) {
- return;
- }
- throw sleepErr;
- }
+ return shouldRestart ? "continue" : "exit";
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
await stopRunner();
}
+ };
+
+ while (!opts.abortSignal?.aborted) {
+ const bot = await createPollingBot();
+ if (!bot) {
+ continue;
+ }
+
+ const cleanupState = await ensureWebhookCleanup(bot);
+ if (cleanupState === "retry") {
+ continue;
+ }
+ if (cleanupState === "exit") {
+ return;
+ }
+
+ const state = await runPollingCycle(bot);
+ if (state === "exit") {
+ return;
+ }
}
} finally {
unregisterHandler();
diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts
index 37d881d843c..b589fdcf52b 100644
--- a/src/telegram/send.test.ts
+++ b/src/telegram/send.test.ts
@@ -196,6 +196,10 @@ describe("sendMessageTelegram", () => {
for (const testCase of cases) {
botCtorSpy.mockClear();
loadConfig.mockReturnValue(testCase.cfg);
+ botApi.sendMessage.mockResolvedValue({
+ message_id: 1,
+ chat: { id: "123" },
+ });
await sendMessageTelegram("123", "hi", testCase.opts);
expect(botCtorSpy, testCase.name).toHaveBeenCalledWith(
"tok",
@@ -325,6 +329,40 @@ describe("sendMessageTelegram", () => {
}
});
+ it("fails when Telegram text send returns no message_id", async () => {
+ const sendMessage = vi.fn().mockResolvedValue({
+ chat: { id: "123" },
+ });
+ const api = { sendMessage } as unknown as {
+ sendMessage: typeof sendMessage;
+ };
+
+ await expect(
+ sendMessageTelegram("123", "hi", {
+ token: "tok",
+ api,
+ }),
+ ).rejects.toThrow(/returned no message_id/i);
+ });
+
+ it("fails when Telegram media send returns no message_id", async () => {
+ mockLoadedMedia({ contentType: "image/png", fileName: "photo.png" });
+ const sendPhoto = vi.fn().mockResolvedValue({
+ chat: { id: "123" },
+ });
+ const api = { sendPhoto } as unknown as {
+ sendPhoto: typeof sendPhoto;
+ };
+
+ await expect(
+ sendMessageTelegram("123", "caption", {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/photo.png",
+ }),
+ ).rejects.toThrow(/returned no message_id/i);
+ });
+
it("uses native fetch for BAN compatibility when api is omitted", async () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
@@ -1242,6 +1280,23 @@ describe("sendStickerTelegram", () => {
expect(sendSticker).toHaveBeenNthCalledWith(2, chatId, "fileId123", undefined);
expect(res.messageId).toBe("109");
});
+
+ it("fails when sticker send returns no message_id", async () => {
+ const chatId = "123";
+ const sendSticker = vi.fn().mockResolvedValue({
+ chat: { id: chatId },
+ });
+ const api = { sendSticker } as unknown as {
+ sendSticker: typeof sendSticker;
+ };
+
+ await expect(
+ sendStickerTelegram(chatId, "fileId123", {
+ token: "tok",
+ api,
+ }),
+ ).rejects.toThrow(/returned no message_id/i);
+ });
});
describe("shared send behaviors", () => {
@@ -1504,6 +1559,20 @@ describe("sendPollTelegram", () => {
expect(api.sendPoll).not.toHaveBeenCalled();
});
+
+ it("fails when poll send returns no message_id", async () => {
+ const api = {
+ sendPoll: vi.fn(async () => ({ chat: { id: 555 }, poll: { id: "p1" } })),
+ };
+
+ await expect(
+ sendPollTelegram(
+ "123",
+ { question: "Q", options: ["A", "B"] },
+ { token: "t", api: api as unknown as Bot["api"] },
+ ),
+ ).rejects.toThrow(/returned no message_id/i);
+ });
});
describe("createForumTopicTelegram", () => {
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index 85327df22b5..ceaa9113e32 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -86,6 +86,16 @@ type TelegramReactionOpts = {
retry?: RetryConfig;
};
+function resolveTelegramMessageIdOrThrow(
+ result: TelegramMessageLike | null | undefined,
+ context: string,
+): number {
+ if (typeof result?.message_id === "number" && Number.isFinite(result.message_id)) {
+ return Math.trunc(result.message_id);
+ }
+ throw new Error(`Telegram ${context} returned no message_id`);
+}
+
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i;
const MESSAGE_NOT_MODIFIED_RE =
@@ -685,11 +695,9 @@ export async function sendMessageTelegram(
})();
const result = await sendMedia(mediaSender.label, mediaSender.sender);
- const mediaMessageId = String(result?.message_id ?? "unknown");
+ const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send");
const resolvedChatId = String(result?.chat?.id ?? chatId);
- if (result?.message_id) {
- recordSentMessage(chatId, result.message_id);
- }
+ recordSentMessage(chatId, mediaMessageId);
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
@@ -708,13 +716,15 @@ export async function sendMessageTelegram(
: undefined;
const textRes = await sendTelegramText(followUpText, textParams);
// Return the text message ID as the "main" message (it's the actual content).
+ const textMessageId = resolveTelegramMessageIdOrThrow(textRes, "text follow-up send");
+ recordSentMessage(chatId, textMessageId);
return {
- messageId: String(textRes?.message_id ?? mediaMessageId),
+ messageId: String(textMessageId),
chatId: resolvedChatId,
};
}
- return { messageId: mediaMessageId, chatId: resolvedChatId };
+ return { messageId: String(mediaMessageId), chatId: resolvedChatId };
}
if (!text || !text.trim()) {
@@ -728,16 +738,14 @@ export async function sendMessageTelegram(
}
: undefined;
const res = await sendTelegramText(text, textParams, opts.plainText);
- const messageId = String(res?.message_id ?? "unknown");
- if (res?.message_id) {
- recordSentMessage(chatId, res.message_id);
- }
+ const messageId = resolveTelegramMessageIdOrThrow(res, "text send");
+ recordSentMessage(chatId, messageId);
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
- return { messageId, chatId: String(res?.chat?.id ?? chatId) };
+ return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) };
}
export async function reactMessageTelegram(
@@ -1013,18 +1021,16 @@ export async function sendStickerTelegram(
requestWithChatNotFound(() => api.sendSticker(chatId, fileId.trim(), effectiveParams), label),
);
- const messageId = String(result?.message_id ?? "unknown");
+ const messageId = resolveTelegramMessageIdOrThrow(result, "sticker send");
const resolvedChatId = String(result?.chat?.id ?? chatId);
- if (result?.message_id) {
- recordSentMessage(chatId, result.message_id);
- }
+ recordSentMessage(chatId, messageId);
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
- return { messageId, chatId: resolvedChatId };
+ return { messageId: String(messageId), chatId: resolvedChatId };
}
type TelegramPollOpts = {
@@ -1121,12 +1127,10 @@ export async function sendPollTelegram(
),
);
- const messageId = String(result?.message_id ?? "unknown");
+ const messageId = resolveTelegramMessageIdOrThrow(result, "poll send");
const resolvedChatId = String(result?.chat?.id ?? chatId);
const pollId = result?.poll?.id;
- if (result?.message_id) {
- recordSentMessage(chatId, result.message_id);
- }
+ recordSentMessage(chatId, messageId);
recordChannelActivity({
channel: "telegram",
@@ -1134,7 +1138,7 @@ export async function sendPollTelegram(
direction: "outbound",
});
- return { messageId, chatId: resolvedChatId, pollId };
+ return { messageId: String(messageId), chatId: resolvedChatId, pollId };
}
// ---------------------------------------------------------------------------
diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts
index 2c943a4be6f..0117c55823a 100644
--- a/src/telegram/webhook.test.ts
+++ b/src/telegram/webhook.test.ts
@@ -1,24 +1,26 @@
+import { createHash } from "node:crypto";
+import { once } from "node:events";
+import { request } from "node:http";
+import { setTimeout as sleep } from "node:timers/promises";
import { describe, expect, it, vi } from "vitest";
import { startTelegramWebhook } from "./webhook.js";
-const handlerSpy = vi.hoisted(() =>
- vi.fn(
- (_req: unknown, res: { writeHead: (status: number) => void; end: (body?: string) => void }) => {
- res.writeHead(200);
- res.end("ok");
- },
- ),
-);
+const handlerSpy = vi.hoisted(() => vi.fn((..._args: unknown[]): unknown => undefined));
const setWebhookSpy = vi.hoisted(() => vi.fn());
+const deleteWebhookSpy = vi.hoisted(() => vi.fn(async () => true));
+const initSpy = vi.hoisted(() => vi.fn(async () => undefined));
const stopSpy = vi.hoisted(() => vi.fn());
const webhookCallbackSpy = vi.hoisted(() => vi.fn(() => handlerSpy));
const createTelegramBotSpy = vi.hoisted(() =>
vi.fn(() => ({
- api: { setWebhook: setWebhookSpy },
+ init: initSpy,
+ api: { setWebhook: setWebhookSpy, deleteWebhook: deleteWebhookSpy },
stop: stopSpy,
})),
);
+const WEBHOOK_POST_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 8_000;
+
vi.mock("grammy", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -31,8 +33,178 @@ vi.mock("./bot.js", () => ({
createTelegramBot: createTelegramBotSpy,
}));
+async function fetchWithTimeout(
+ input: string,
+ init: Omit,
+ timeoutMs: number,
+): Promise {
+ const abort = new AbortController();
+ const timer = setTimeout(() => {
+ abort.abort();
+ }, timeoutMs);
+ try {
+ return await fetch(input, { ...init, signal: abort.signal });
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function postWebhookJson(params: {
+ url: string;
+ payload: string;
+ secret?: string;
+ timeoutMs?: number;
+}): Promise {
+ return await fetchWithTimeout(
+ params.url,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(params.secret ? { "x-telegram-bot-api-secret-token": params.secret } : {}),
+ },
+ body: params.payload,
+ },
+ params.timeoutMs ?? 5_000,
+ );
+}
+
+function createDeterministicRng(seed: number): () => number {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1_664_525 + 1_013_904_223) >>> 0;
+ return state / 4_294_967_296;
+ };
+}
+
+async function postWebhookPayloadWithChunkPlan(params: {
+ port: number;
+ path: string;
+ payload: string;
+ secret: string;
+ mode: "single" | "random-chunked";
+ timeoutMs?: number;
+}): Promise<{ statusCode: number; body: string }> {
+ const payloadBuffer = Buffer.from(params.payload, "utf-8");
+ return await new Promise((resolve, reject) => {
+ let bytesQueued = 0;
+ let chunksQueued = 0;
+ let phase: "writing" | "awaiting-response" = "writing";
+ let settled = false;
+ const finishResolve = (value: { statusCode: number; body: string }) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timeout);
+ resolve(value);
+ };
+ const finishReject = (error: unknown) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timeout);
+ reject(error);
+ };
+
+ const req = request(
+ {
+ hostname: "127.0.0.1",
+ port: params.port,
+ path: params.path,
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "content-length": String(payloadBuffer.length),
+ "x-telegram-bot-api-secret-token": params.secret,
+ },
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ res.on("end", () => {
+ finishResolve({
+ statusCode: res.statusCode ?? 0,
+ body: Buffer.concat(chunks).toString("utf-8"),
+ });
+ });
+ },
+ );
+
+ const timeout = setTimeout(() => {
+ finishReject(
+ new Error(
+ `webhook post timed out after ${params.timeoutMs ?? 15_000}ms (phase=${phase}, bytesQueued=${bytesQueued}, chunksQueued=${chunksQueued}, totalBytes=${payloadBuffer.length})`,
+ ),
+ );
+ req.destroy();
+ }, params.timeoutMs ?? 15_000);
+
+ req.on("error", (error) => {
+ finishReject(error);
+ });
+
+ const writeAll = async () => {
+ if (params.mode === "single") {
+ req.end(payloadBuffer);
+ return;
+ }
+
+ const rng = createDeterministicRng(26156);
+ let offset = 0;
+ while (offset < payloadBuffer.length) {
+ const remaining = payloadBuffer.length - offset;
+ const nextSize = Math.max(1, Math.min(remaining, 1 + Math.floor(rng() * 8_192)));
+ const chunk = payloadBuffer.subarray(offset, offset + nextSize);
+ const canContinue = req.write(chunk);
+ offset += nextSize;
+ bytesQueued = offset;
+ chunksQueued += 1;
+ if (chunksQueued % 10 === 0) {
+ await sleep(1 + Math.floor(rng() * 3));
+ }
+ if (!canContinue) {
+ // Windows CI occasionally stalls on waiting for drain indefinitely.
+ // Bound the wait, then continue queuing this small (~1MB) payload.
+ await Promise.race([once(req, "drain"), sleep(25)]);
+ }
+ }
+ phase = "awaiting-response";
+ req.end();
+ };
+
+ void writeAll().catch((error) => {
+ finishReject(error);
+ });
+ });
+}
+
+function createNearLimitTelegramPayload(): { payload: string; sizeBytes: number } {
+ const maxBytes = 1_024 * 1_024;
+ const targetBytes = maxBytes - 4_096;
+ const shell = { update_id: 77_777, message: { text: "" } };
+ const shellSize = Buffer.byteLength(JSON.stringify(shell), "utf-8");
+ const textLength = Math.max(1, targetBytes - shellSize);
+ const pattern = "the quick brown fox jumps over the lazy dog ";
+ const repeats = Math.ceil(textLength / pattern.length);
+ const text = pattern.repeat(repeats).slice(0, textLength);
+ const payload = JSON.stringify({
+ update_id: 77_777,
+ message: { text },
+ });
+ return { payload, sizeBytes: Buffer.byteLength(payload, "utf-8") };
+}
+
+function sha256(text: string): string {
+ return createHash("sha256").update(text).digest("hex");
+}
+
describe("startTelegramWebhook", () => {
it("starts server, registers webhook, and serves health", async () => {
+ initSpy.mockClear();
createTelegramBotSpy.mockClear();
webhookCallbackSpy.mockClear();
const abort = new AbortController();
@@ -59,6 +231,7 @@ describe("startTelegramWebhook", () => {
const health = await fetch(`${url}/healthz`);
expect(health.status).toBe(200);
+ expect(initSpy).toHaveBeenCalledTimes(1);
expect(setWebhookSpy).toHaveBeenCalled();
expect(webhookCallbackSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -66,7 +239,7 @@ describe("startTelegramWebhook", () => {
setWebhook: expect.any(Function),
}),
}),
- "http",
+ "callback",
{
secretToken: "secret",
onTimeout: "return",
@@ -101,7 +274,13 @@ describe("startTelegramWebhook", () => {
if (!addr || typeof addr === "string") {
throw new Error("no addr");
}
- await fetch(`http://127.0.0.1:${addr.port}/hook`, { method: "POST" });
+ const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
+ const response = await postWebhookJson({
+ url: `http://127.0.0.1:${addr.port}/hook`,
+ payload,
+ secret: "secret",
+ });
+ expect(response.status).toBe(200);
expect(handlerSpy).toHaveBeenCalled();
abort.abort();
});
@@ -113,4 +292,371 @@ describe("startTelegramWebhook", () => {
}),
).rejects.toThrow(/requires a non-empty secret token/i);
});
+
+ it("registers webhook using the bound listening port when port is 0", async () => {
+ setWebhookSpy.mockClear();
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret: "secret",
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+ try {
+ const addr = server.address();
+ if (!addr || typeof addr === "string") {
+ throw new Error("no addr");
+ }
+ expect(addr.port).toBeGreaterThan(0);
+ expect(setWebhookSpy).toHaveBeenCalledTimes(1);
+ expect(setWebhookSpy).toHaveBeenCalledWith(
+ `http://127.0.0.1:${addr.port}/hook`,
+ expect.objectContaining({
+ secret_token: "secret",
+ }),
+ );
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("keeps webhook payload readable when callback delays body read", async () => {
+ handlerSpy.mockImplementationOnce(async (...args: unknown[]) => {
+ const [update, reply] = args as [unknown, (json: string) => Promise];
+ await sleep(50);
+ await reply(JSON.stringify(update));
+ });
+
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret: "secret",
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+ try {
+ const addr = server.address();
+ if (!addr || typeof addr === "string") {
+ throw new Error("no addr");
+ }
+
+ const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
+ const res = await postWebhookJson({
+ url: `http://127.0.0.1:${addr.port}/hook`,
+ payload,
+ secret: "secret",
+ });
+ expect(res.status).toBe(200);
+ const responseBody = await res.text();
+ expect(JSON.parse(responseBody)).toEqual(JSON.parse(payload));
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("keeps webhook payload readable across multiple delayed reads", async () => {
+ const seenPayloads: string[] = [];
+ const delayedHandler = async (...args: unknown[]) => {
+ const [update, reply] = args as [unknown, (json: string) => Promise];
+ await sleep(50);
+ seenPayloads.push(JSON.stringify(update));
+ await reply("ok");
+ };
+ handlerSpy.mockImplementationOnce(delayedHandler).mockImplementationOnce(delayedHandler);
+
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret: "secret",
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+ try {
+ const addr = server.address();
+ if (!addr || typeof addr === "string") {
+ throw new Error("no addr");
+ }
+
+ const payloads = [
+ JSON.stringify({ update_id: 1, message: { text: "first" } }),
+ JSON.stringify({ update_id: 2, message: { text: "second" } }),
+ ];
+
+ for (const payload of payloads) {
+ const res = await postWebhookJson({
+ url: `http://127.0.0.1:${addr.port}/hook`,
+ payload,
+ secret: "secret",
+ });
+ expect(res.status).toBe(200);
+ }
+
+ expect(seenPayloads.map((x) => JSON.parse(x))).toEqual(payloads.map((x) => JSON.parse(x)));
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("processes a second request after first-request delayed-init data loss", async () => {
+ const seenUpdates: unknown[] = [];
+ webhookCallbackSpy.mockImplementationOnce(
+ () =>
+ vi.fn(
+ (
+ update: unknown,
+ reply: (json: string) => Promise,
+ _secretHeader: string | undefined,
+ _unauthorized: () => Promise,
+ ) => {
+ seenUpdates.push(update);
+ void (async () => {
+ await sleep(50);
+ await reply("ok");
+ })();
+ },
+ ) as unknown as typeof handlerSpy,
+ );
+
+ const secret = "secret";
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret,
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+
+ try {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("no addr");
+ }
+
+ const firstPayload = JSON.stringify({ update_id: 100, message: { text: "first" } });
+ const secondPayload = JSON.stringify({ update_id: 101, message: { text: "second" } });
+ const firstResponse = await postWebhookPayloadWithChunkPlan({
+ port: address.port,
+ path: "/hook",
+ payload: firstPayload,
+ secret,
+ mode: "single",
+ timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
+ });
+ const secondResponse = await postWebhookPayloadWithChunkPlan({
+ port: address.port,
+ path: "/hook",
+ payload: secondPayload,
+ secret,
+ mode: "single",
+ timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
+ });
+
+ expect(firstResponse.statusCode).toBe(200);
+ expect(secondResponse.statusCode).toBe(200);
+ expect(seenUpdates).toEqual([JSON.parse(firstPayload), JSON.parse(secondPayload)]);
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("handles near-limit payload with random chunk writes and event-loop yields", async () => {
+ const seenUpdates: Array<{ update_id: number; message: { text: string } }> = [];
+ webhookCallbackSpy.mockImplementationOnce(
+ () =>
+ vi.fn(
+ (
+ update: unknown,
+ reply: (json: string) => Promise,
+ _secretHeader: string | undefined,
+ _unauthorized: () => Promise,
+ ) => {
+ seenUpdates.push(update as { update_id: number; message: { text: string } });
+ void reply("ok");
+ },
+ ) as unknown as typeof handlerSpy,
+ );
+
+ const { payload, sizeBytes } = createNearLimitTelegramPayload();
+ expect(sizeBytes).toBeLessThan(1_024 * 1_024);
+ expect(sizeBytes).toBeGreaterThan(256 * 1_024);
+ const expected = JSON.parse(payload) as { update_id: number; message: { text: string } };
+
+ const secret = "secret";
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret,
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+
+ try {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("no addr");
+ }
+
+ const response = await postWebhookPayloadWithChunkPlan({
+ port: address.port,
+ path: "/hook",
+ payload,
+ secret,
+ mode: "random-chunked",
+ timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(seenUpdates).toHaveLength(1);
+ expect(seenUpdates[0]?.update_id).toBe(expected.update_id);
+ expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length);
+ expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text));
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("handles near-limit payload written in a single request write", async () => {
+ const seenUpdates: Array<{ update_id: number; message: { text: string } }> = [];
+ webhookCallbackSpy.mockImplementationOnce(
+ () =>
+ vi.fn(
+ (
+ update: unknown,
+ reply: (json: string) => Promise,
+ _secretHeader: string | undefined,
+ _unauthorized: () => Promise,
+ ) => {
+ seenUpdates.push(update as { update_id: number; message: { text: string } });
+ void reply("ok");
+ },
+ ) as unknown as typeof handlerSpy,
+ );
+
+ const { payload, sizeBytes } = createNearLimitTelegramPayload();
+ expect(sizeBytes).toBeLessThan(1_024 * 1_024);
+ expect(sizeBytes).toBeGreaterThan(256 * 1_024);
+ const expected = JSON.parse(payload) as { update_id: number; message: { text: string } };
+
+ const secret = "secret";
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret,
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+
+ try {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("no addr");
+ }
+
+ const response = await postWebhookPayloadWithChunkPlan({
+ port: address.port,
+ path: "/hook",
+ payload,
+ secret,
+ mode: "single",
+ timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(seenUpdates).toHaveLength(1);
+ expect(seenUpdates[0]?.update_id).toBe(expected.update_id);
+ expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length);
+ expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text));
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("rejects payloads larger than 1MB before invoking webhook handler", async () => {
+ handlerSpy.mockClear();
+ const abort = new AbortController();
+ const { server } = await startTelegramWebhook({
+ token: "tok",
+ secret: "secret",
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+
+ try {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("no addr");
+ }
+
+ const responseOrError = await new Promise<
+ | { kind: "response"; statusCode: number; body: string }
+ | { kind: "error"; code: string | undefined }
+ >((resolve) => {
+ const req = request(
+ {
+ hostname: "127.0.0.1",
+ port: address.port,
+ path: "/hook",
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "content-length": String(1_024 * 1_024 + 2_048),
+ "x-telegram-bot-api-secret-token": "secret",
+ },
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ res.on("end", () => {
+ resolve({
+ kind: "response",
+ statusCode: res.statusCode ?? 0,
+ body: Buffer.concat(chunks).toString("utf-8"),
+ });
+ });
+ },
+ );
+ req.on("error", (error: NodeJS.ErrnoException) => {
+ resolve({ kind: "error", code: error.code });
+ });
+ req.end("{}");
+ });
+
+ if (responseOrError.kind === "response") {
+ expect(responseOrError.statusCode).toBe(413);
+ expect(responseOrError.body).toBe("Payload too large");
+ } else {
+ expect(responseOrError.code).toBeOneOf(["ECONNRESET", "EPIPE"]);
+ }
+ expect(handlerSpy).not.toHaveBeenCalled();
+ } finally {
+ abort.abort();
+ }
+ });
+
+ it("de-registers webhook when shutting down", async () => {
+ deleteWebhookSpy.mockClear();
+ const abort = new AbortController();
+ await startTelegramWebhook({
+ token: "tok",
+ secret: "secret",
+ port: 0,
+ abortSignal: abort.signal,
+ path: "/hook",
+ });
+
+ abort.abort();
+ await sleep(25);
+
+ expect(deleteWebhookSpy).toHaveBeenCalledTimes(1);
+ expect(deleteWebhookSpy).toHaveBeenCalledWith({ drop_pending_updates: false });
+ });
});
diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts
index 9eb3c73d7f4..0fd887f956c 100644
--- a/src/telegram/webhook.ts
+++ b/src/telegram/webhook.ts
@@ -3,7 +3,7 @@ import { webhookCallback } from "grammy";
import type { OpenClawConfig } from "../config/config.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { formatErrorMessage } from "../infra/errors.js";
-import { installRequestBodyLimitGuard } from "../infra/http-body.js";
+import { readJsonBodyWithLimit } from "../infra/http-body.js";
import {
logWebhookError,
logWebhookProcessed,
@@ -21,6 +21,59 @@ const TELEGRAM_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const TELEGRAM_WEBHOOK_CALLBACK_TIMEOUT_MS = 10_000;
+async function listenHttpServer(params: {
+ server: ReturnType;
+ port: number;
+ host: string;
+}) {
+ await new Promise((resolve, reject) => {
+ const onError = (err: Error) => {
+ params.server.off("error", onError);
+ reject(err);
+ };
+ params.server.once("error", onError);
+ params.server.listen(params.port, params.host, () => {
+ params.server.off("error", onError);
+ resolve();
+ });
+ });
+}
+
+function resolveWebhookPublicUrl(params: {
+ configuredPublicUrl?: string;
+ server: ReturnType;
+ path: string;
+ host: string;
+ port: number;
+}) {
+ if (params.configuredPublicUrl) {
+ return params.configuredPublicUrl;
+ }
+ const address = params.server.address();
+ if (address && typeof address !== "string") {
+ const resolvedHost =
+ params.host === "0.0.0.0" || address.address === "0.0.0.0" || address.address === "::"
+ ? "localhost"
+ : address.address;
+ return `http://${resolvedHost}:${address.port}${params.path}`;
+ }
+ const fallbackHost = params.host === "0.0.0.0" ? "localhost" : params.host;
+ return `http://${fallbackHost}:${params.port}${params.path}`;
+}
+
+async function initializeTelegramWebhookBot(params: {
+ bot: ReturnType;
+ runtime: RuntimeEnv;
+ abortSignal?: AbortSignal;
+}) {
+ const initSignal = params.abortSignal as Parameters<(typeof params.bot)["init"]>[0];
+ await withTelegramApiErrorLogging({
+ operation: "getMe",
+ runtime: params.runtime,
+ fn: () => params.bot.init(initSignal),
+ });
+}
+
export async function startTelegramWebhook(opts: {
token: string;
accountId?: string;
@@ -55,7 +108,12 @@ export async function startTelegramWebhook(opts: {
config: opts.config,
accountId: opts.accountId,
});
- const handler = webhookCallback(bot, "http", {
+ await initializeTelegramWebhookBot({
+ bot,
+ runtime,
+ abortSignal: opts.abortSignal,
+ });
+ const handler = webhookCallback(bot, "callback", {
secretToken: secret,
onTimeout: "return",
timeoutMilliseconds: TELEGRAM_WEBHOOK_CALLBACK_TIMEOUT_MS,
@@ -66,6 +124,14 @@ export async function startTelegramWebhook(opts: {
}
const server = createServer((req, res) => {
+ const respondText = (statusCode: number, text = "") => {
+ if (res.headersSent || res.writableEnded) {
+ return;
+ }
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
+ res.end(text);
+ };
+
if (req.url === healthPath) {
res.writeHead(200);
res.end("ok");
@@ -80,69 +146,125 @@ export async function startTelegramWebhook(opts: {
if (diagnosticsEnabled) {
logWebhookReceived({ channel: "telegram", updateType: "telegram-post" });
}
- const guard = installRequestBodyLimitGuard(req, res, {
- maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES,
- timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS,
- responseFormat: "text",
- });
- if (guard.isTripped()) {
- return;
- }
- const handled = handler(req, res);
- if (handled && typeof handled.catch === "function") {
- void handled
- .then(() => {
- if (diagnosticsEnabled) {
- logWebhookProcessed({
- channel: "telegram",
- updateType: "telegram-post",
- durationMs: Date.now() - startTime,
- });
- }
- })
- .catch((err) => {
- if (guard.isTripped()) {
- return;
- }
- const errMsg = formatErrorMessage(err);
- if (diagnosticsEnabled) {
- logWebhookError({
- channel: "telegram",
- updateType: "telegram-post",
- error: errMsg,
- });
- }
- runtime.log?.(`webhook handler failed: ${errMsg}`);
- if (!res.headersSent) {
- res.writeHead(500);
- }
- res.end();
- })
- .finally(() => {
- guard.dispose();
+ void (async () => {
+ const body = await readJsonBodyWithLimit(req, {
+ maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES,
+ timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS,
+ emptyObjectOnEmpty: false,
+ });
+ if (!body.ok) {
+ if (body.code === "PAYLOAD_TOO_LARGE") {
+ respondText(413, body.error);
+ return;
+ }
+ if (body.code === "REQUEST_BODY_TIMEOUT") {
+ respondText(408, body.error);
+ return;
+ }
+ if (body.code === "CONNECTION_CLOSED") {
+ respondText(400, body.error);
+ return;
+ }
+ respondText(400, body.error);
+ return;
+ }
+
+ let replied = false;
+ const reply = async (json: string) => {
+ if (replied) {
+ return;
+ }
+ replied = true;
+ if (res.headersSent || res.writableEnded) {
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
+ res.end(json);
+ };
+ const unauthorized = async () => {
+ if (replied) {
+ return;
+ }
+ replied = true;
+ respondText(401, "unauthorized");
+ };
+ const secretHeaderRaw = req.headers["x-telegram-bot-api-secret-token"];
+ const secretHeader = Array.isArray(secretHeaderRaw) ? secretHeaderRaw[0] : secretHeaderRaw;
+
+ await handler(body.value, reply, secretHeader, unauthorized);
+ if (!replied) {
+ respondText(200);
+ }
+
+ if (diagnosticsEnabled) {
+ logWebhookProcessed({
+ channel: "telegram",
+ updateType: "telegram-post",
+ durationMs: Date.now() - startTime,
});
- return;
+ }
+ })().catch((err) => {
+ const errMsg = formatErrorMessage(err);
+ if (diagnosticsEnabled) {
+ logWebhookError({
+ channel: "telegram",
+ updateType: "telegram-post",
+ error: errMsg,
+ });
+ }
+ runtime.log?.(`webhook handler failed: ${errMsg}`);
+ respondText(500);
+ });
+ });
+
+ await listenHttpServer({
+ server,
+ port,
+ host,
+ });
+
+ const publicUrl = resolveWebhookPublicUrl({
+ configuredPublicUrl: opts.publicUrl,
+ server,
+ path,
+ host,
+ port,
+ });
+
+ try {
+ await withTelegramApiErrorLogging({
+ operation: "setWebhook",
+ runtime,
+ fn: () =>
+ bot.api.setWebhook(publicUrl, {
+ secret_token: secret,
+ allowed_updates: resolveTelegramAllowedUpdates(),
+ }),
+ });
+ } catch (err) {
+ server.close();
+ void bot.stop();
+ if (diagnosticsEnabled) {
+ stopDiagnosticHeartbeat();
}
- guard.dispose();
- });
+ throw err;
+ }
- const publicUrl =
- opts.publicUrl ?? `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
-
- await withTelegramApiErrorLogging({
- operation: "setWebhook",
- runtime,
- fn: () =>
- bot.api.setWebhook(publicUrl, {
- secret_token: secret,
- allowed_updates: resolveTelegramAllowedUpdates(),
- }),
- });
-
- await new Promise((resolve) => server.listen(port, host, resolve));
runtime.log?.(`webhook listening on ${publicUrl}`);
+ let shutDown = false;
const shutdown = () => {
+ if (shutDown) {
+ return;
+ }
+ shutDown = true;
+ void withTelegramApiErrorLogging({
+ operation: "deleteWebhook",
+ runtime,
+ fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }),
+ }).catch(() => {
+ // withTelegramApiErrorLogging has already emitted the failure.
+ });
server.close();
void bot.stop();
if (diagnosticsEnabled) {
diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts
index df826b62ccf..301375fbb59 100644
--- a/src/wizard/onboarding.ts
+++ b/src/wizard/onboarding.ts
@@ -31,15 +31,21 @@ async function requireRiskAcknowledgement(params: {
"Security warning — please read.",
"",
"OpenClaw is a hobby project and still in beta. Expect sharp edges.",
+ "By default, OpenClaw is a personal agent: one trusted operator boundary.",
"This bot can read files and run actions if tools are enabled.",
"A bad prompt can trick it into doing unsafe things.",
"",
- "If you’re not comfortable with basic security and access control, don’t run OpenClaw.",
+ "OpenClaw is not a hostile multi-tenant boundary by default.",
+ "If multiple users can message one tool-enabled agent, they share that delegated tool authority.",
+ "",
+ "If you’re not comfortable with security hardening and access control, don’t run OpenClaw.",
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
"",
"Recommended baseline:",
"- Pairing/allowlists + mention gating.",
+ "- Multi-user/shared inbox: split trust boundaries (separate gateway/credentials, ideally separate OS users/hosts).",
"- Sandbox + least-privilege tools.",
+ "- Shared inboxes: isolate DM sessions (`session.dmScope: per-channel-peer`) and keep tool access minimal.",
"- Keep secrets out of the agent’s reachable filesystem.",
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
"",
@@ -53,7 +59,8 @@ async function requireRiskAcknowledgement(params: {
);
const ok = await params.prompter.confirm({
- message: "I understand this is powerful and inherently risky. Continue?",
+ message:
+ "I understand this is personal-by-default and shared/multi-user use requires lock-down. Continue?",
initialValue: false,
});
if (!ok) {
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index 4a5c4cdfa46..25fa6742b4a 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -452,6 +452,24 @@
grid-template-columns: 1fr;
}
+ /* Mobile: stack compose row vertically */
+ .chat-compose__row {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ /* Mobile: stack action buttons vertically */
+ .chat-compose__actions {
+ flex-direction: column;
+ width: 100%;
+ gap: 8px;
+ }
+
+ /* Mobile: full-width buttons */
+ .chat-compose .chat-compose__actions .btn {
+ width: 100%;
+ }
+
.chat-controls {
flex-wrap: wrap;
gap: 8px;