diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abb5b50a5ce8..f0266c721748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -317,6 +317,32 @@ jobs: - name: Check docs run: pnpm check:docs + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python tooling + run: | + python -m pip install --upgrade pip + python -m pip install pytest ruff pyyaml + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + secrets: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: @@ -325,15 +351,20 @@ jobs: with: submodules: false + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install detect-secrets + - name: Install pre-commit run: | python -m pip install --upgrade pip - python -m pip install detect-secrets==1.5.0 + python -m pip install pre-commit detect-secrets==1.5.0 - name: Detect secrets run: | @@ -342,6 +373,30 @@ jobs: exit 1 fi + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + checks-windows: needs: [docs-scope, changed-scope, build-artifacts, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') diff --git a/.gitignore b/.gitignore index 745310ebf395..7d31353a0477 100644 --- a/.gitignore +++ b/.gitignore @@ -98,9 +98,19 @@ package-lock.json .agents/* !.agents/maintainers.md .agent/ +skills-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json .ant-colony/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..9190f88b6e08 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e946d18c1127..30b6363a34da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,8 @@ repos: - id: check-added-large-files args: [--maxkb=500] - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' # Secret detection (same as CI) - repo: https://github.com/Yelp/detect-secrets @@ -45,7 +47,6 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' - # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 @@ -69,9 +70,34 @@ repos: args: [--persona=regular, --min-severity=medium, --min-confidence=medium] exclude: "^(vendor/|Swabble/)" + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + # Project checks (same commands as CI) - repo: local hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + # oxlint --type-aware src test - id: oxlint name: oxlint diff --git a/AGENTS.md b/AGENTS.md index 0b3cf42b4dd6..00ae79a05514 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ - Repo: https://github.com/openclaw/openclaw - GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". +- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. +- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). +- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. ## Project Structure & Module Organization @@ -83,6 +86,7 @@ - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. - beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. - dev: moving head on `main` (no tag; git checkout main). ## Testing Guidelines @@ -91,6 +95,7 @@ - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. - Do not set test workers above 16; tried already. +- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Full kit + what’s covered: `docs/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1dbe524fed..7c992a66b926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,139 @@ Docs: https://docs.openclaw.ai ## Unreleased -## 2026.2.22 (Unreleased) +### Breaking + +- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) + +### Changes + +- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. + +### Fixes + +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. +- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. +- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. +- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. +- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. +- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. +- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. +- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) +- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. +- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) +- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) +- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) +- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) +- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) +- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) +- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) + +## 2026.2.23 + +### Changes + +- Providers/Kilo Gateway: add first-class `kilocode` provider support (auth, onboarding, implicit provider detection, model defaults, transcript/cache-ttl handling, and docs), with default model `kilocode/anthropic/claude-opus-4.6`. (#20212) Thanks @jrf0110 and @markijbema. +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. +- Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. +- Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. +- Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. + +### Breaking + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + +### Fixes + +- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. +- Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) +- Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. +- Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. +- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. +- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. +- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. +- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Auto-reply/Inbound metadata: move dynamic inbound `flags` (reply/forward/thread/history) from system metadata to user-context conversation info, preventing turn-by-turn prompt-cache invalidation from flag toggles. (#21785) Thanks @aidiffuser. +- Auto-reply/Sessions: remove auth-key labels from `/new` and `/reset` confirmation messages so session reset notices never expose API key prefixes or env-key labels in chat output. (#24384, #24409) Thanks @Clawborn. +- Slack/Group policy: move Slack account `groupPolicy` defaulting to provider-level schema defaults so multi-account configs inherit top-level `channels.slack.groupPolicy` instead of silently overriding inheritance with per-account `allowlist`. (#17579) Thanks @ZetiMente. +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. +- Providers/DashScope: mark DashScope-compatible `openai-completions` endpoints as `supportsDeveloperRole=false` so OpenClaw sends `system` instead of unsupported `developer` role on Qwen/DashScope APIs. (#19130) Thanks @Putzhuawa and @vincentkoc. +- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope. +- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. +- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. +- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. +- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) +- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. +- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. +- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. + +## 2026.2.22 ### Changes +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. - CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. @@ -27,6 +156,7 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. - **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. - **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. @@ -34,8 +164,14 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) @@ -79,6 +215,8 @@ Docs: https://docs.openclaw.ai - Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. - Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. - Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. - Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. - Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). - Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) @@ -95,6 +233,7 @@ Docs: https://docs.openclaw.ai - Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. - Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) - Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) @@ -196,6 +335,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. - Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. +- Agents/Auth profiles: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. - Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. - Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. - Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2beaeeba290e..10d4f2907045 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 000000000000..1887eca27d95 --- /dev/null +++ b/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green diff --git a/README.md b/README.md index 72f362418d72..7387372192f9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). @@ -503,78 +502,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

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

diff --git a/SECURITY.md b/SECURITY.md index 1a26e7541c06..378eceaff914 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives: - **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) - **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. +For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it. For full reporting instructions see our [Trust page](https://trust.openclaw.ai). @@ -30,6 +30,40 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai). Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). +- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +These are frequently reported but are typically closed with no code change: + +- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). +- Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. +- Missing HSTS findings on default local/loopback deployments. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + ## Security & Trust **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. @@ -43,13 +77,34 @@ The best way to help the project right now is by sending PRs. When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. +## Operator Trust Model (Important) + +OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. + +- Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. +- If one operator can view data from another operator on the same gateway, that is expected in this trust model. +- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. +- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway. +- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user. +- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default. +- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`. +- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host. +- Implicit exec calls (no explicit host in the tool call) follow the same behavior. +- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. + ## Out of Scope - Public Internet Exposure - Using OpenClaw in ways that the docs recommend not to -- Deployments where mutually untrusted/adversarial operators share one gateway host and config -- Prompt injection attacks +- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) +- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) - Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) +- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) +- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. +- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact +- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass. ## Deployment Assumptions @@ -59,6 +114,33 @@ OpenClaw security guidance assumes: - Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. - Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. +- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user). + +## One-User Trust Model (Personal Assistant) + +OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." + +- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. +- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. +- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. +- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. +- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk. + +## Agent and Model Assumptions + +- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. +- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals. +- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. + +## Gateway and Node trust concept + +OpenClaw separates routing from execution, but both remain inside the same operator trust boundary: + +- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway. +- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node. +- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary. +- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary. ## Workspace Memory Trust Boundary diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b91b1e215376..52e1014e7ba7 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602210 - versionName = "2026.2.21" + versionCode = 202602230 + versionName = "2026.2.23" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0656afbf2d7e..aedea62a5e1d 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 000000000000..0624e976b515 --- /dev/null +++ b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c3b469e70928..c34fccb5052d 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260220 + 20260223 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5bd98e6f4923..fc5e6097b18a 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -3,6 +3,7 @@ import OpenClawKit import OpenClawProtocol import Observation import os +import Security import SwiftUI import UIKit import UserNotifications @@ -37,9 +38,22 @@ private final class NotificationInvokeLatch: @unchecked Sendable { cont?.resume(returning: response) } } + +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + @MainActor @Observable final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") @@ -74,6 +88,8 @@ final class NodeAppModel { var gatewayAgents: [AgentSummary] = [] var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -485,21 +501,14 @@ final class NodeAppModel { } } - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static var apnsEnvironment: String { #if DEBUG "sandbox" @@ -508,17 +517,6 @@ final class NodeAppModel { #endif } - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - private func refreshBrandingFromGateway() async { do { let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) @@ -699,117 +697,6 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - self.deepLinkLogger.info( - "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" - ) - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - self.recordShareEvent("Rejected: message too large (\(message.count) chars).") - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - self.recordShareEvent("Failed: gateway not connected.") - self.deepLinkLogger.error("agent deep link rejected: gateway not connected") - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - self.recordShareEvent("Sent to gateway (\(message.count) chars).") - self.deepLinkLogger.info("agent deep link forwarded to gateway") - self.openChatRequestID &+= 1 - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - self.recordShareEvent("Failed: \(error.localizedDescription)") - self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command @@ -2560,6 +2447,229 @@ extension NodeAppModel { } } +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + extension NodeAppModel { func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { await self.handleWatchQuickReply(event) @@ -2607,5 +2717,13 @@ extension NodeAppModel { func _test_queuedWatchReplyCount() -> Int { self.queuedWatchReplies.count } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } } #endif diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index da893d3c9439..dd0f389ed4d4 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -88,6 +88,7 @@ struct RootCanvas: View { } } .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 7fc8d8270443..a3420e273216 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 3d015afae84b..24bc4ba06391 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,8 +29,35 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + @MainActor -private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { var currentStatus = WatchMessagingStatus( supported: true, paired: true, @@ -327,6 +354,58 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index cc5dbf6cdda1..4e309b031a67 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 2d6b7baa7b87..1b5f28dfc433 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 613322f3e8ed..1028876e5101 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,8 +92,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -146,8 +146,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -176,8 +176,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -200,8 +200,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -228,5 +228,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index e7ca1ad54878..3a425368d090 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 202602210 + 202602230 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c39..4e766514defc 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2582,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2595,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2607,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2621,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c39..4e766514defc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2582,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2595,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2607,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2621,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js index 183e35f9c4ab..fe32d2c06165 100644 --- a/assets/chrome-extension/background-utils.js +++ b/assets/chrome-extension/background-utils.js @@ -11,14 +11,32 @@ export function reconnectDelayMs( return backoff + Math.max(0, jitterMs) * random(); } -export function buildRelayWsUrl(port, gatewayToken) { +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`openclaw-extension-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { const token = String(gatewayToken || "").trim(); if (!token) { throw new Error( "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", ); } - return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`; + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; } export function isRetryableReconnectError(err) { diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 5de9027bfcd6..60f50d6551e5 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -30,6 +30,10 @@ const pending = new Map() /** @type {Set} */ const tabOperationLocks = new Set() +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + // Reconnect state for exponential backoff. let reconnectAttempt = 0 let reconnectTimer = null @@ -128,7 +132,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = buildRelayWsUrl(port, gatewayToken) + const wsUrl = await buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -190,6 +194,8 @@ function onRelayClosed(reason) { p.reject(new Error(`Relay disconnected (${reason})`)) } + reattachPending.clear() + for (const [tabId, tab] of tabs.entries()) { if (tab.state === 'connected') { setBadge(tabId, 'connecting') @@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() { tabOperationLocks.add(tabId) try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') @@ -632,50 +648,109 @@ function onDebuggerEvent(source, method, params) { } } -// Navigation/reload fires target_closed but the tab is still alive — Chrome -// just swaps the renderer process. Suppress the detach event to the relay and -// seamlessly re-attach after a short grace period. -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - if (reason === 'target_closed') { - const oldState = tabs.get(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) + // User explicitly cancelled or DevTools replaced the connection — respect their intent + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } - setTimeout(async () => { - try { - // If user manually detached during the grace period, bail out. - if (!tabs.has(tabId)) return - const tab = await chrome.tabs.get(tabId) - if (tab && relayWs?.readyState === WebSocket.OPEN) { - console.log(`Re-attaching tab ${tabId} after navigation`) - if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) - tabs.delete(tabId) - await attachTab(tabId, { skipAttachedEvent: false }) - } else { - // Tab gone or relay down — full cleanup. - void detachTab(tabId, reason) - } - } catch (err) { - console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) - void detachTab(tabId, reason) - } - }, 500) + // Check if tab still exists — distinguishes navigation from tab close + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + // Tab is gone (closed) — normal cleanup + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) return } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } // Tab lifecycle listeners — clean up stale entries. chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) if (!tabs.has(tabId)) return const tab = tabs.get(tabId) if (tab?.sessionId) tabBySession.delete(tab.sessionId) @@ -798,3 +873,27 @@ async function whenReady(fn) { await initPromise return fn() } + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 000000000000..53e2cd550147 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index e4252ccae4c2..aa6fcc4901fd 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,12 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -function relayHeaders(token) { - const t = String(token || '').trim() - if (!t) return {} - return { 'x-openclaw-relay-token': t } -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -33,27 +30,21 @@ async function checkRelayReachable(port, token) { setStatus('error', 'Gateway token required. Save your gateway token to connect.') return } - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { - method: 'GET', - headers: relayHeaders(trimmedToken), - signal: ctrl.signal, + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, }) - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } finally { - clearTimeout(t) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index aae5f58fdf21..8d1401926079 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -349,7 +349,8 @@ Notes: ## Storage & history - Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). -- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned). +- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned by size and line count). +- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable). - Override store path: `cron.store` in config. ## Configuration @@ -362,10 +363,21 @@ Notes: maxConcurrentRuns: 1, // default 1 webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode + sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` +Run-log pruning behavior: + +- `cron.runLog.maxBytes`: max run-log file size before pruning. +- `cron.runLog.keepLines`: when pruning, keep only the newest N lines. +- Both apply to `cron/runs/.jsonl` files. + Webhook behavior: - Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. @@ -380,6 +392,85 @@ Disable cron entirely: - `cron.enabled: false` (config) - `OPENCLAW_SKIP_CRON=1` (env) +## Maintenance + +Cron has two built-in maintenance paths: isolated run-session retention and run-log pruning. + +### Defaults + +- `cron.sessionRetention`: `24h` (set `false` to disable run-session pruning) +- `cron.runLog.maxBytes`: `2_000_000` bytes +- `cron.runLog.keepLines`: `2000` + +### How it works + +- Isolated runs create session entries (`...:cron::run:`) and transcript files. +- The reaper removes expired run-session entries older than `cron.sessionRetention`. +- For removed run sessions no longer referenced by the session store, OpenClaw archives transcript files and purges old deleted archives on the same retention window. +- After each run append, `cron/runs/.jsonl` is size-checked: + - if file size exceeds `runLog.maxBytes`, it is trimmed to the newest `runLog.keepLines` lines. + +### Performance caveat for high volume schedulers + +High-frequency cron setups can generate large run-session and run-log footprints. Maintenance is built in, but loose limits can still create avoidable IO and cleanup work. + +What to watch: + +- long `cron.sessionRetention` windows with many isolated runs +- high `cron.runLog.keepLines` combined with large `runLog.maxBytes` +- many noisy recurring jobs writing to the same `cron/runs/.jsonl` + +What to do: + +- keep `cron.sessionRetention` as short as your debugging/audit needs allow +- keep run logs bounded with moderate `runLog.maxBytes` and `runLog.keepLines` +- move noisy background jobs to isolated mode with delivery rules that avoid unnecessary chatter +- review growth periodically with `openclaw cron runs` and adjust retention before logs become large + +### Customize examples + +Keep run sessions for a week and allow bigger run logs: + +```json5 +{ + cron: { + sessionRetention: "7d", + runLog: { + maxBytes: "10mb", + keepLines: 5000, + }, + }, +} +``` + +Disable isolated run-session pruning but keep run-log pruning: + +```json5 +{ + cron: { + sessionRetention: false, + runLog: { + maxBytes: "5mb", + keepLines: 3000, + }, + }, +} +``` + +Tune for high-volume cron usage (example): + +```json5 +{ + cron: { + sessionRetention: "12h", + runLog: { + maxBytes: "3mb", + keepLines: 1500, + }, + }, +} +``` + ## CLI quickstart One-shot reminder (UTC ISO, auto-delete after success): diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 334c6d78ee53..108ef34d4efe 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -397,7 +397,8 @@ Example: `allowlist` behavior: - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - - optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - if a guild has `channels` configured, non-listed channels are denied - if a guild has no `channels` block, all channels in that allowlisted guild are allowed @@ -768,7 +769,7 @@ Default slash command settings: Notes: - allowlists can use `pk:` - - member display names are matched by name/slug + - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true` - lookups use original message ID and are time-window constrained - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 818a8288f5df..13729257fe7c 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path: Use these identifiers for delivery and allowlists: -- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal). +- Direct messages: `users/` (recommended). +- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`. - Deprecated: `users/` is treated as a user id, not an email allowlist. - Spaces: `spaces/`. @@ -171,7 +172,7 @@ Use these identifiers for delivery and allowlists: botUser: "users/1234567890", // optional; helps mention detection dm: { policy: "pairing", - allowFrom: ["users/1234567890", "name@example.com"], + allowFrom: ["users/1234567890"], }, groupPolicy: "allowlist", groups: { @@ -194,6 +195,7 @@ Notes: - Service account credentials can also be passed inline with `serviceAccount` (JSON string). - Default webhook path is `/googlechat` if `webhookPath` isn’t set. +- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). diff --git a/docs/channels/irc.md b/docs/channels/irc.md index 7496f574c4e2..00403b6f92d7 100644 --- a/docs/channels/irc.md +++ b/docs/channels/irc.md @@ -57,7 +57,8 @@ Config keys: - Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` - `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) -Allowlist entries can use nick or `nick!user@host` forms. +Allowlist entries should use stable sender identities (`nick!user@host`). +Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`. ### Common gotcha: `allowFrom` is for DMs, not channels diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 350fa8429c4d..702f72cc01f5 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -101,7 +101,8 @@ Notes: ## Channels (groups) - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). -- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). - Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index d8b9f0af865b..9c4a583e1b54 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -87,7 +87,9 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. +- `channels.msteams.allowFrom` should use stable AAD object IDs. +- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`. +- The wizard can resolve names to IDs via Microsoft Graph when credentials allow. **Group access** @@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) -- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index beb79a511fcc..869df30ad999 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -171,6 +171,7 @@ For actions/directory reads, user token can be preferred when configured. For wr - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - unresolved entries are kept as configured + - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -513,6 +514,7 @@ Primary reference: High-signal Slack fields: - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` + - compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed) - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 9535509016d2..1b1981395e46 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -49,6 +49,13 @@ openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/ openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 ``` +Permission model (client debug mode): + +- Auto-approval is allowlist-based and only applies to trusted core tool IDs. +- `read` auto-approval is scoped to the current working directory (`--cwd` when set). +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval. +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source). + ## How to use this Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 3e56db9717a5..9c129518e213 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -23,6 +23,11 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after- Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. +Note: retention/pruning is controlled in config: + +- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 7dc1f6fc1b8e..dff899d7cd28 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -27,6 +27,7 @@ Notes: - Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts. - `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal. +- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. ## macOS: `launchctl` env overrides diff --git a/docs/cli/security.md b/docs/cli/security.md index e8b76c8e3e78..9b1cce7db792 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -32,8 +32,9 @@ It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxie It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`. It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. -It warns when Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs. +It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). +Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. ## JSON output diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 0709bc1f0df3..4ed5ace54ee3 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -11,6 +11,94 @@ List stored conversation sessions. ```bash openclaw sessions +openclaw sessions --agent work +openclaw sessions --all-agents openclaw sessions --active 120 openclaw sessions --json ``` + +Scope selection: + +- default: configured default agent store +- `--agent `: one configured agent store +- `--all-agents`: aggregate all configured agent stores +- `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) + +JSON examples: + +`openclaw sessions --all-agents --json`: + +```json +{ + "path": null, + "stores": [ + { "agentId": "main", "path": "/home/user/.openclaw/agents/main/sessions/sessions.json" }, + { "agentId": "work", "path": "/home/user/.openclaw/agents/work/sessions/sessions.json" } + ], + "allAgents": true, + "count": 2, + "activeMinutes": null, + "sessions": [ + { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, + { "agentId": "work", "key": "agent:work:main", "model": "claude-opus-4-5" } + ] +} +``` + +## Cleanup maintenance + +Run maintenance now (instead of waiting for the next write cycle): + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --agent work --dry-run +openclaw sessions cleanup --all-agents --dry-run +openclaw sessions cleanup --enforce +openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123" +openclaw sessions cleanup --json +``` + +`openclaw sessions cleanup` uses `session.maintenance` settings from config: + +- Scope note: `openclaw sessions cleanup` maintains session stores/transcripts only. It does not prune cron run logs (`cron/runs/.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance). + +- `--dry-run`: preview how many entries would be pruned/capped without writing. + - In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed. +- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`. +- `--active-key `: protect a specific active key from disk-budget eviction. +- `--agent `: run cleanup for one configured agent store. +- `--all-agents`: run cleanup for all configured agent stores. +- `--store `: run against a specific `sessions.json` file. +- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store. + +`openclaw sessions cleanup --all-agents --dry-run --json`: + +```json +{ + "allAgents": true, + "mode": "warn", + "dryRun": true, + "stores": [ + { + "agentId": "main", + "storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json", + "beforeCount": 120, + "afterCount": 80, + "pruned": 40, + "capped": 0 + }, + { + "agentId": "work", + "storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json", + "beforeCount": 18, + "afterCount": 18, + "pruned": 0, + "capped": 0 + } + ] +} +``` + +Related: + +- Session config: [Configuration reference](/gateway/configuration-reference#session) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 16cfd599093b..22c544161b31 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -126,10 +126,23 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6` - CLI: `openclaw onboard --auth-choice ai-gateway-api-key` +### Kilo Gateway + +- Provider: `kilocode` +- Auth: `KILOCODE_API_KEY` +- Example model: `kilocode/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --kilocode-api-key ` +- Base URL: `https://api.kilo.ai/api/gateway/` +- Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5. + +See [/providers/kilocode](/providers/kilocode) for setup details. + ### Other built-in providers - OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) - Example model: `openrouter/anthropic/claude-sonnet-4-5` +- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) +- Example model: `kilocode/anthropic/claude-opus-4.6` - xAI: `xai` (`XAI_API_KEY`) - Mistral: `mistral` (`MISTRAL_API_KEY`) - Example model: `mistral/mistral-large-latest` diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md index 0fcb2b78d0a8..ba9f39f37f13 100644 --- a/docs/concepts/session-pruning.md +++ b/docs/concepts/session-pruning.md @@ -15,13 +15,13 @@ Session pruning trims **old tool results** from the in-memory context right befo - When `mode: "cache-ttl"` is enabled and the last Anthropic call for the session is older than `ttl`. - Only affects the messages sent to the model for that request. - Only active for Anthropic API calls (and OpenRouter Anthropic models). -- For best results, match `ttl` to your model `cacheControlTtl`. +- For best results, match `ttl` to your model `cacheRetention` policy (`short` = 5m, `long` = 1h). - After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. ## Smart defaults (Anthropic) - **OAuth or setup-token** profiles: enable `cache-ttl` pruning and set heartbeat to `1h`. -- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheControlTtl` to `1h` on Anthropic models. +- **API key** profiles: enable `cache-ttl` pruning, set heartbeat to `30m`, and default `cacheRetention: "short"` on Anthropic models. - If you set any of these values explicitly, OpenClaw does **not** override them. ## What this improves (cost + cache behavior) @@ -91,9 +91,7 @@ Default (off): ```json5 { - agent: { - contextPruning: { mode: "off" }, - }, + agents: { defaults: { contextPruning: { mode: "off" } } }, } ``` @@ -101,9 +99,7 @@ Enable TTL-aware pruning: ```json5 { - agent: { - contextPruning: { mode: "cache-ttl", ttl: "5m" }, - }, + agents: { defaults: { contextPruning: { mode: "cache-ttl", ttl: "5m" } } }, } ``` @@ -111,10 +107,12 @@ Restrict pruning to specific tools: ```json5 { - agent: { - contextPruning: { - mode: "cache-ttl", - tools: { allow: ["exec", "read"], deny: ["*image*"] }, + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + tools: { allow: ["exec", "read"], deny: ["*image*"] }, + }, }, }, } diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe557..bbd58d599ced 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -152,7 +152,7 @@ Parameters: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, aborts the sub-agent run after N seconds) - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 3d1503ab80e0..6c9010d2c11e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -71,6 +71,109 @@ All session state is **owned by the gateway** (the “master” OpenClaw). UI cl - Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from. - OpenClaw does **not** read legacy Pi/Tau session folders. +## Maintenance + +OpenClaw applies session-store maintenance to keep `sessions.json` and transcript artifacts bounded over time. + +### Defaults + +- `session.maintenance.mode`: `warn` +- `session.maintenance.pruneAfter`: `30d` +- `session.maintenance.maxEntries`: `500` +- `session.maintenance.rotateBytes`: `10mb` +- `session.maintenance.resetArchiveRetention`: defaults to `pruneAfter` (`30d`) +- `session.maintenance.maxDiskBytes`: unset (disabled) +- `session.maintenance.highWaterBytes`: defaults to `80%` of `maxDiskBytes` when budgeting is enabled + +### How it works + +Maintenance runs during session-store writes, and you can trigger it on demand with `openclaw sessions cleanup`. + +- `mode: "warn"`: reports what would be evicted but does not mutate entries/transcripts. +- `mode: "enforce"`: applies cleanup in this order: + 1. prune stale entries older than `pruneAfter` + 2. cap entry count to `maxEntries` (oldest first) + 3. archive transcript files for removed entries that are no longer referenced + 4. purge old `*.deleted.` and `*.reset.` archives by retention policy + 5. rotate `sessions.json` when it exceeds `rotateBytes` + 6. if `maxDiskBytes` is set, enforce disk budget toward `highWaterBytes` (oldest artifacts first, then oldest sessions) + +### Performance caveat for large stores + +Large session stores are common in high-volume setups. Maintenance work is write-path work, so very large stores can increase write latency. + +What increases cost most: + +- very high `session.maintenance.maxEntries` values +- long `pruneAfter` windows that keep stale entries around +- many transcript/archive artifacts in `~/.openclaw/agents//sessions/` +- enabling disk budgets (`maxDiskBytes`) without reasonable pruning/cap limits + +What to do: + +- use `mode: "enforce"` in production so growth is bounded automatically +- set both time and count limits (`pruneAfter` + `maxEntries`), not just one +- set `maxDiskBytes` + `highWaterBytes` for hard upper bounds in large deployments +- keep `highWaterBytes` meaningfully below `maxDiskBytes` (default is 80%) +- run `openclaw sessions cleanup --dry-run --json` after config changes to verify projected impact before enforcing +- for frequent active sessions, pass `--active-key` when running manual cleanup + +### Customize examples + +Use a conservative enforce policy: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "45d", + maxEntries: 800, + rotateBytes: "20mb", + resetArchiveRetention: "14d", + }, + }, +} +``` + +Enable a hard disk budget for the sessions directory: + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + maxDiskBytes: "1gb", + highWaterBytes: "800mb", + }, + }, +} +``` + +Tune for larger installs (example): + +```json5 +{ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "14d", + maxEntries: 2000, + rotateBytes: "25mb", + maxDiskBytes: "2gb", + highWaterBytes: "1.6gb", + }, + }, +} +``` + +Preview or force maintenance from CLI: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + ## Session pruning OpenClaw trims **old tool results** from the in-memory context right before LLM calls by default. @@ -180,7 +283,7 @@ Runtime override (owner only): - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). -- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). +- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - JSONL transcripts can be opened directly to review full turns. diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md new file mode 100644 index 000000000000..596a77f13858 --- /dev/null +++ b/docs/design/kilo-gateway-integration.md @@ -0,0 +1,534 @@ +# Kilo Gateway Provider Integration Design + +## Overview + +This document outlines the design for integrating "Kilo Gateway" as a first-class provider in OpenClaw, modeled after the existing OpenRouter implementation. Kilo Gateway uses an OpenAI-compatible completions API with a different base URL. + +## Design Decisions + +### 1. Provider Naming + +**Recommendation: `kilocode`** + +Rationale: + +- Matches the user config example provided (`kilocode` provider key) +- Consistent with existing provider naming patterns (e.g., `openrouter`, `opencode`, `moonshot`) +- Short and memorable +- Avoids confusion with generic "kilo" or "gateway" terms + +Alternative considered: `kilo-gateway` - rejected because hyphenated names are less common in the codebase and `kilocode` is more concise. + +### 2. Default Model Reference + +**Recommendation: `kilocode/anthropic/claude-opus-4.6`** + +Rationale: + +- Based on user config example +- Claude Opus 4.5 is a capable default model +- Explicit model selection avoids reliance on auto-routing + +### 3. Base URL Configuration + +**Recommendation: Hardcoded default with config override** + +- **Default Base URL:** `https://api.kilo.ai/api/gateway/` +- **Configurable:** Yes, via `models.providers.kilocode.baseUrl` + +This matches the pattern used by other providers like Moonshot, Venice, and Synthetic. + +### 4. Model Scanning + +**Recommendation: No dedicated model scanning endpoint initially** + +Rationale: + +- Kilo Gateway proxies to OpenRouter, so models are dynamic +- Users can manually configure models in their config +- If Kilo Gateway exposes a `/models` endpoint in the future, scanning can be added + +### 5. Special Handling + +**Recommendation: Inherit OpenRouter behavior for Anthropic models** + +Since Kilo Gateway proxies to OpenRouter, the same special handling should apply: + +- Cache TTL eligibility for `anthropic/*` models +- Extra params (cacheControlTtl) for `anthropic/*` models +- Transcript policy follows OpenRouter patterns + +## Files to Modify + +### Core Credential Management + +#### 1. `src/commands/onboard-auth.credentials.ts` + +Add: + +```typescript +export const KILOCODE_DEFAULT_MODEL_REF = "kilocode/anthropic/claude-opus-4.6"; + +export async function setKilocodeApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "kilocode:default", + credential: { + type: "api_key", + provider: "kilocode", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} +``` + +#### 2. `src/agents/model-auth.ts` + +Add to `envMap` in `resolveEnvApiKey()`: + +```typescript +const envMap: Record = { + // ... existing entries + kilocode: "KILOCODE_API_KEY", +}; +``` + +#### 3. `src/config/io.ts` + +Add to `SHELL_ENV_EXPECTED_KEYS`: + +```typescript +const SHELL_ENV_EXPECTED_KEYS = [ + // ... existing entries + "KILOCODE_API_KEY", +]; +``` + +### Config Application + +#### 4. `src/commands/onboard-auth.config-core.ts` + +Add new functions: + +```typescript +export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; + +export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KILOCODE_DEFAULT_MODEL_REF] = { + ...models[KILOCODE_DEFAULT_MODEL_REF], + alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.kilocode; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + providers.kilocode = { + ...existingProviderRest, + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyKilocodeProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: KILOCODE_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} +``` + +### Auth Choice System + +#### 5. `src/commands/onboard-types.ts` + +Add to `AuthChoice` type: + +```typescript +export type AuthChoice = + // ... existing choices + "kilocode-api-key"; +// ... +``` + +Add to `OnboardOptions`: + +```typescript +export type OnboardOptions = { + // ... existing options + kilocodeApiKey?: string; + // ... +}; +``` + +#### 6. `src/commands/auth-choice-options.ts` + +Add to `AuthChoiceGroupId`: + +```typescript +export type AuthChoiceGroupId = + // ... existing groups + "kilocode"; +// ... +``` + +Add to `AUTH_CHOICE_GROUP_DEFS`: + +```typescript +{ + value: "kilocode", + label: "Kilo Gateway", + hint: "API key (OpenRouter-compatible)", + choices: ["kilocode-api-key"], +}, +``` + +Add to `buildAuthChoiceOptions()`: + +```typescript +options.push({ + value: "kilocode-api-key", + label: "Kilo Gateway API key", + hint: "OpenRouter-compatible gateway", +}); +``` + +#### 7. `src/commands/auth-choice.preferred-provider.ts` + +Add mapping: + +```typescript +const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { + // ... existing mappings + "kilocode-api-key": "kilocode", +}; +``` + +### Auth Choice Application + +#### 8. `src/commands/auth-choice.apply.api-providers.ts` + +Add import: + +```typescript +import { + // ... existing imports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.js"; +``` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "kilocode", + }); + const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); + const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + let profileId = "kilocode:default"; + let mode: "api_key" | "oauth" | "token" = "api_key"; + let hasCredential = false; + + if (existingProfileId && existingCred?.type) { + profileId = existingProfileId; + mode = + existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; + hasCredential = true; + } + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kilocode") { + await setKilocodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("kilocode"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing KILOCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setKilocodeApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Kilo Gateway API key", + validate: validateApiKeyInput, + }); + await setKilocodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "kilocode", + mode, + }); + } + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: KILOCODE_DEFAULT_MODEL_REF, + applyDefaultConfig: applyKilocodeConfig, + applyProviderConfig: applyKilocodeProviderConfig, + noteDefault: KILOCODE_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; +} +``` + +Also add tokenProvider mapping at the top of the function: + +```typescript +if (params.opts.tokenProvider === "kilocode") { + authChoice = "kilocode-api-key"; +} +``` + +### CLI Registration + +#### 9. `src/cli/program/register.onboard.ts` + +Add CLI option: + +```typescript +.option("--kilocode-api-key ", "Kilo Gateway API key") +``` + +Add to action handler: + +```typescript +kilocodeApiKey: opts.kilocodeApiKey as string | undefined, +``` + +Update auth-choice help text: + +```typescript +.option( + "--auth-choice ", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|kilocode-api-key|ai-gateway-api-key|...", +) +``` + +### Non-Interactive Onboarding + +#### 10. `src/commands/onboard-non-interactive/local/auth-choice.ts` + +Add handling for `kilocode-api-key`: + +```typescript +if (authChoice === "kilocode-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "kilocode", + cfg: baseConfig, + flagValue: opts.kilocodeApiKey, + flagName: "--kilocode-api-key", + envVar: "KILOCODE_API_KEY", + }); + await setKilocodeApiKey(resolved.apiKey, agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "kilocode:default", + provider: "kilocode", + mode: "api_key", + }); + // ... apply default model +} +``` + +### Export Updates + +#### 11. `src/commands/onboard-auth.ts` + +Add exports: + +```typescript +export { + // ... existing exports + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_BASE_URL, +} from "./onboard-auth.config-core.js"; + +export { + // ... existing exports + KILOCODE_DEFAULT_MODEL_REF, + setKilocodeApiKey, +} from "./onboard-auth.credentials.js"; +``` + +### Special Handling (Optional) + +#### 12. `src/agents/pi-embedded-runner/cache-ttl.ts` + +Add Kilo Gateway support for Anthropic models: + +```typescript +export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { + const normalizedProvider = provider.toLowerCase(); + const normalizedModelId = modelId.toLowerCase(); + if (normalizedProvider === "anthropic") return true; + if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) + return true; + if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) return true; + return false; +} +``` + +#### 13. `src/agents/transcript-policy.ts` + +Add Kilo Gateway handling (similar to OpenRouter): + +```typescript +const isKilocodeGemini = provider === "kilocode" && modelId.toLowerCase().includes("gemini"); + +// Include in needsNonImageSanitize check +const needsNonImageSanitize = + isGoogle || isAnthropic || isMistral || isOpenRouterGemini || isKilocodeGemini; +``` + +## Configuration Structure + +### User Config Example + +```json +{ + "models": { + "mode": "merge", + "providers": { + "kilocode": { + "baseUrl": "https://api.kilo.ai/api/gateway/", + "apiKey": "xxxxx", + "api": "openai-completions", + "models": [ + { + "id": "anthropic/claude-opus-4.6", + "name": "Anthropic: Claude Opus 4.6" + }, + { "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" } + ] + } + } + } +} +``` + +### Auth Profile Structure + +```json +{ + "profiles": { + "kilocode:default": { + "type": "api_key", + "provider": "kilocode", + "key": "xxxxx" + } + } +} +``` + +## Testing Considerations + +1. **Unit Tests:** + - Test `setKilocodeApiKey()` writes correct profile + - Test `applyKilocodeConfig()` sets correct defaults + - Test `resolveEnvApiKey("kilocode")` returns correct env var + +2. **Integration Tests:** + - Test onboarding flow with `--auth-choice kilocode-api-key` + - Test non-interactive onboarding with `--kilocode-api-key` + - Test model selection with `kilocode/` prefix + +3. **E2E Tests:** + - Test actual API calls through Kilo Gateway (live tests) + +## Migration Notes + +- No migration needed for existing users +- New users can immediately use `kilocode-api-key` auth choice +- Existing manual config with `kilocode` provider will continue to work + +## Future Considerations + +1. **Model Catalog:** If Kilo Gateway exposes a `/models` endpoint, add scanning support similar to `scanOpenRouterModels()` + +2. **OAuth Support:** If Kilo Gateway adds OAuth, extend the auth system accordingly + +3. **Rate Limiting:** Consider adding rate limit handling specific to Kilo Gateway if needed + +4. **Documentation:** Add docs at `docs/providers/kilocode.md` explaining setup and usage + +## Summary of Changes + +| File | Change Type | Description | +| ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | +| `src/commands/onboard-auth.credentials.ts` | Add | `KILOCODE_DEFAULT_MODEL_REF`, `setKilocodeApiKey()` | +| `src/agents/model-auth.ts` | Modify | Add `kilocode` to `envMap` | +| `src/config/io.ts` | Modify | Add `KILOCODE_API_KEY` to shell env keys | +| `src/commands/onboard-auth.config-core.ts` | Add | `applyKilocodeProviderConfig()`, `applyKilocodeConfig()` | +| `src/commands/onboard-types.ts` | Modify | Add `kilocode-api-key` to `AuthChoice`, add `kilocodeApiKey` to options | +| `src/commands/auth-choice-options.ts` | Modify | Add `kilocode` group and option | +| `src/commands/auth-choice.preferred-provider.ts` | Modify | Add `kilocode-api-key` mapping | +| `src/commands/auth-choice.apply.api-providers.ts` | Modify | Add `kilocode-api-key` handling | +| `src/cli/program/register.onboard.ts` | Modify | Add `--kilocode-api-key` option | +| `src/commands/onboard-non-interactive/local/auth-choice.ts` | Modify | Add non-interactive handling | +| `src/commands/onboard-auth.ts` | Modify | Export new functions | +| `src/agents/pi-embedded-runner/cache-ttl.ts` | Modify | Add kilocode support | +| `src/agents/transcript-policy.ts` | Modify | Add kilocode Gemini handling | diff --git a/docs/docs.json b/docs/docs.json index 5e91b3501138..4c83f3058bdb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1263,7 +1263,12 @@ }, { "group": "Technical reference", - "pages": ["reference/wizard", "reference/token-use", "channels/grammy"] + "pages": [ + "reference/wizard", + "reference/token-use", + "reference/prompt-caching", + "channels/grammy" + ] }, { "group": "Concept internals", diff --git a/docs/experiments/.DS_Store b/docs/experiments/.DS_Store deleted file mode 100644 index b13221a744b1..000000000000 Binary files a/docs/experiments/.DS_Store and /dev/null differ diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 960f37c005bc..d3838bbdae60 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -169,6 +169,9 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional + highWaterBytes: "400mb", // optional (defaults to 80% of maxDiskBytes) }, typingIntervalSeconds: 5, sendPolicy: { @@ -199,7 +202,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["steipete"] }, + dm: { enabled: true, allowFrom: ["123456789012345678"] }, guilds: { "123456789012345678": { slug: "friends-of-openclaw", @@ -314,7 +317,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. allowFrom: { whatsapp: ["+15555550123"], telegram: ["123456789"], - discord: ["steipete"], + discord: ["123456789012345678"], slack: ["U123"], signal: ["+15555550123"], imessage: ["user@example.com"], @@ -355,6 +358,10 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. store: "~/.openclaw/cron/cron.json", maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, // Webhooks @@ -454,7 +461,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_TOKEN", - dm: { allowFrom: ["yourname"] }, + dm: { allowFrom: ["123456789012345678"] }, }, }, } @@ -480,12 +487,15 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["alice", "bob"] }, + dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] }, }, }, } ``` +For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default. +Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk. + ### OAuth with API key failover ```json5 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 50f40998ca15..0b89a272d903 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -212,7 +212,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat }, replyToMode: "off", // off | first | all dmPolicy: "pairing", - allowFrom: ["1234567890", "steipete"], + allowFrom: ["1234567890", "123456789012345678"], dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] }, guilds: { "123456789012345678": { @@ -283,6 +283,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. +- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). **Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds..users` on all messages). @@ -317,7 +318,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`). - Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. -- Use `spaces/` or `users/` for delivery targets. +- Use `spaces/` or `users/` for delivery targets. +- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode). ### Slack @@ -718,9 +720,16 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` +- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - String form sets only the primary model. + - Object form sets primary plus ordered failover models. +- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - Used by the `image` tool path as its vision-model config. + - Also used as fallback routing when the selected/default model cannot accept image input. - `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated). -- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific: `temperature`, `maxTokens`). -- `imageModel`: only used if the primary model lacks image input. +- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`). +- `params` merge precedence (config): `agents.defaults.models["provider/model"].params` is the base, then `agents.list[].params` (matching agent id) overrides by key. +- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): @@ -1044,6 +1053,7 @@ scripts/sandbox-browser-setup.sh # optional browser image workspace: "~/.openclaw/workspace", agentDir: "~/.openclaw/agents/main/agent", model: "anthropic/claude-opus-4-6", // or { primary, fallbacks } + params: { cacheRetention: "none" }, // overrides matching defaults.models params by key identity: { name: "Samantha", theme: "helpful sloth", @@ -1068,6 +1078,7 @@ scripts/sandbox-browser-setup.sh # optional browser image - `id`: stable agent id (required). - `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default. - `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. +- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. - `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only). @@ -1237,6 +1248,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden pruneAfter: "30d", maxEntries: 500, rotateBytes: "10mb", + resetArchiveRetention: "30d", // duration or false + maxDiskBytes: "500mb", // optional hard budget + highWaterBytes: "400mb", // optional cleanup target }, threadBindings: { enabled: true, @@ -1264,7 +1278,14 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. -- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation. +- **`maintenance`**: session-store cleanup + retention controls. + - `mode`: `warn` emits warnings only; `enforce` applies cleanup. + - `pruneAfter`: age cutoff for stale entries (default `30d`). + - `maxEntries`: maximum number of entries in `sessions.json` (default `500`). + - `rotateBytes`: rotate `sessions.json` when it exceeds this size (default `10mb`). + - `resetArchiveRetention`: retention for `*.reset.` transcript archives. Defaults to `pruneAfter`; set `false` to disable. + - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. + - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. - **`threadBindings`**: global defaults for thread-bound session features. - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) - `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override) @@ -1471,7 +1492,7 @@ Controls elevated (host) exec access: enabled: true, allowFrom: { whatsapp: ["+15555550123"], - discord: ["steipete", "1234567890123"], + discord: ["1234567890123", "987654321098765432"], }, }, }, @@ -1662,6 +1683,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1670,6 +1692,7 @@ Notes: ``` - `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model. +- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout. - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`. --- @@ -1997,6 +2020,12 @@ See [Plugins](/tools/plugin). enabled: true, evaluateEnabled: true, defaultProfile: "chrome", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, @@ -2012,6 +2041,10 @@ See [Plugins](/tools/plugin). ``` - `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`. +- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model). +- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation. +- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. +- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). @@ -2066,6 +2099,8 @@ See [Plugins](/tools/plugin). enabled: true, basePath: "/openclaw", // root: "dist/control-ui", + // allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI + // dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode // allowInsecureAuth: false, // dangerouslyDisableDeviceAuth: false, }, @@ -2100,6 +2135,8 @@ See [Plugins](/tools/plugin). - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds. +- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. @@ -2117,6 +2154,8 @@ See [Plugins](/tools/plugin). - `gateway.http.endpoints.responses.maxUrlParts` - `gateway.http.endpoints.responses.files.urlAllowlist` - `gateway.http.endpoints.responses.images.urlAllowlist` +- Optional response hardening header: + - `gateway.http.securityHeaders.strictTransportSecurity` (set only for HTTPS origins you control; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts)) ### Multi-instance isolation @@ -2448,11 +2487,17 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` -- `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable. +- `runLog.maxBytes`: max size per run log file (`cron/runs/.jsonl`) before pruning. Default: `2_000_000` bytes. +- `runLog.keepLines`: newest lines retained when run-log pruning is triggered. Default: `2000`. - `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent. - `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e367b4caf0d4..f4fea3b5a35b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -251,11 +251,17 @@ When validation fails: enabled: true, maxConcurrentRuns: 2, sessionRetention: "24h", + runLog: { + maxBytes: "2mb", + keepLines: 2000, + }, }, } ``` - See [Cron jobs](/automation/cron-jobs) for the feature overview and CLI examples. + - `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable). + - `runLog`: prune `cron/runs/.jsonl` by size and retained lines. + - See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index f048435483aa..4647cb8b411b 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -125,6 +125,7 @@ Current migrations: - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` +- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` ### 2b) OpenCode Zen provider overrides diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 8bcedbe06313..85a69aca6795 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -170,6 +170,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. +### Operator helper methods + +- Operators may call `tools.catalog` (`operator.read`) to fetch the runtime tool catalog for an + agent. The response includes grouped tools and provenance metadata: + - `source`: `core` or `plugin` + - `pluginId`: plugin owner when `source="plugin"` + - `optional`: whether a plugin tool is optional + ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 7abbea866d4a..49b985be2a65 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -37,6 +37,94 @@ OpenClaw assumes the host and config boundary are trusted: - If someone can modify Gateway host state/config (`~/.openclaw`, including `openclaw.json`), treat them as a trusted operator. - Running one Gateway for multiple mutually untrusted/adversarial operators is **not a recommended setup**. - For mixed-trust teams, split trust boundaries with separate gateways (or at minimum separate OS users/hosts). +- OpenClaw can run multiple gateway instances on one machine, but recommended operations favor clean trust-boundary separation. +- Recommended default: one user per machine/host (or VPS), one gateway for that user, and one or more agents in that gateway. +- If multiple users want OpenClaw, use one VPS/host per user. + +### Practical consequence (operator trust boundary) + +Inside one Gateway instance, authenticated operator access is a trusted control-plane role, not a per-user tenant role. + +- Operators with read/control-plane access can inspect gateway session metadata/history by design. +- Session identifiers (`sessionKey`, session IDs, labels) are routing selectors, not authorization tokens. +- Example: expecting per-operator isolation for methods like `sessions.list`, `sessions.preview`, or `chat.history` is outside this model. +- If you need adversarial-user isolation, run separate gateways per trust boundary. +- Multiple gateways on one machine are technically possible, but not the recommended baseline for multi-user isolation. + +## Personal assistant model (not a multi-tenant bus) + +OpenClaw is designed as a personal assistant security model: one trusted operator boundary, potentially many agents. + +- If several people can message one tool-enabled agent, each of them can steer that same permission set. +- Per-user session/memory isolation helps privacy, but does not convert a shared agent into per-user host authorization. +- If users may be adversarial to each other, run separate gateways (or separate OS users/hosts) per trust boundary. + +### Shared Slack workspace: real risk + +If "everyone in Slack can message the bot," the core risk is delegated tool authority: + +- any allowed sender can induce tool calls (`exec`, browser, network/file tools) within the agent's policy; +- prompt/content injection from one sender can cause actions that affect shared state, devices, or outputs; +- if one shared agent has sensitive credentials/files, any allowed sender can potentially drive exfiltration via tool usage. + +Use separate agents/gateways with minimal tools for team workflows; keep personal-data agents private. + +### Company-shared agent: acceptable pattern + +This is acceptable when everyone using that agent is in the same trust boundary (for example one company team) and the agent is strictly business-scoped. + +- run it on a dedicated machine/VM/container; +- use a dedicated OS user + dedicated browser/profile/accounts for that runtime; +- do not sign that runtime into personal Apple/Google accounts or personal password-manager/browser profiles. + +If you mix personal and company identities on the same runtime, you collapse the separation and increase personal-data exposure risk. + +## Gateway and node trust concept + +Treat Gateway and node as one operator trust domain, with different roles: + +- **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing). +- **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities). +- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node. +- `sessionKey` is routing/context selection, not per-user auth. +- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation. + +If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways. + +## Trust boundary matrix + +Use this as the quick model when triaging risk: + +| Boundary or control | What it means | Common misread | +| ------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| `gateway.auth` (token/password/device auth) | Authenticates callers to gateway APIs | "Needs per-message signatures on every frame to be secure" | +| `sessionKey` | Routing key for context/session selection | "Session key is a user auth boundary" | +| Prompt/content guardrails | Reduce model abuse risk | "Prompt injection alone proves auth bypass" | +| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" | +| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | +| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | + +## Not vulnerabilities by design + +These patterns are commonly reported and are usually closed as no-action unless a real boundary bypass is shown: + +- Prompt-injection-only chains without a policy/auth/sandbox bypass. +- Claims that assume hostile multi-tenant operation on one shared host/config. +- Claims that classify normal operator read-path access (for example `sessions.list`/`sessions.preview`/`chat.history`) as IDOR in a shared-gateway setup. +- Localhost-only deployment findings (for example HSTS on loopback-only gateway). +- Discord inbound webhook signature findings for inbound paths that do not exist in this repo. +- "Missing per-user authorization" findings that treat `sessionKey` as an auth token. + +## Researcher preflight checklist + +Before opening a GHSA, verify all of these: + +1. Repro still works on latest `main` or latest release. +2. Report includes exact code path (`file`, function, line range) and tested version/commit. +3. Impact crosses a documented trust boundary (not just prompt injection). +4. Claim is not listed in [Out of Scope](https://github.com/openclaw/openclaw/blob/main/SECURITY.md#out-of-scope). +5. Existing advisories were checked for duplicates (reuse canonical GHSA when applicable). +6. Deployment assumptions are explicit (loopback/local vs exposed, trusted vs untrusted operators). ## Hardened baseline in 60 seconds @@ -128,6 +216,8 @@ High-signal `checkId` values you will most likely see in real deployments (not e | `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no | | `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | | `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | +| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | | `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | | `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | | `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | @@ -164,6 +254,7 @@ keep it off unless you are actively debugging and can revert quickly. `openclaw security audit` includes `config.insecure_or_dangerous_flags` when any insecure/dangerous debug switches are enabled. This warning aggregates the exact keys so you can review them in one place (for example +`gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`, `gateway.controlUi.allowInsecureAuth=true`, `gateway.controlUi.dangerouslyDisableDeviceAuth=true`, `hooks.gmail.allowUnsafeExternalContent=true`, or @@ -202,6 +293,15 @@ Bad reverse proxy behavior (append/preserve untrusted forwarding headers): proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` +## HSTS and origin notes + +- OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there. +- If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. +- Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). +- For non-loopback Control UI deployments, `gateway.controlUi.allowedOrigins` is required by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode; treat it as a dangerous operator-selected policy. +- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. + ## Local session logs live on disk OpenClaw stores session transcripts on disk under `~/.openclaw/agents//sessions/*.jsonl`. @@ -756,6 +856,30 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +### Browser SSRF policy (trusted-network default) + +OpenClaw’s browser network policy defaults to the trusted-operator model: private/internal destinations are allowed unless you explicitly disable them. + +- Default: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: true` (implicit when unset). +- Legacy alias: `browser.ssrfPolicy.allowPrivateNetwork` is still accepted for compatibility. +- Strict mode: set `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: false` to block private/internal/special-use destinations by default. +- In strict mode, use `hostnameAllowlist` (patterns like `*.example.com`) and `allowedHostnames` (exact host exceptions, including blocked names like `localhost`) for explicit exceptions. +- Navigation is checked before request and best-effort re-checked on the final `http(s)` URL after navigation to reduce redirect-based pivots. + +Example strict policy: + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], + }, + }, +} +``` + ## Per-agent access profiles (multi-agent) With multi-agent routing, each agent can have its own sandbox + tool policy: diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index f9debcfaef02..2b30b234e242 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -4,6 +4,7 @@ read_when: - Running OpenClaw behind an identity-aware proxy - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw - Fixing WebSocket 1008 unauthorized errors with reverse proxy setups + - Deciding where to set HSTS and other HTTP hardening headers --- # Trusted Proxy Auth @@ -75,6 +76,52 @@ If `gateway.bind` is `loopback`, include a loopback proxy address in | `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | | `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | +## TLS termination and HSTS + +Use one TLS termination point and apply HSTS there. + +### Recommended pattern: proxy TLS termination + +When your reverse proxy handles HTTPS for `https://control.example.com`, set +`Strict-Transport-Security` at the proxy for that domain. + +- Good fit for internet-facing deployments. +- Keeps certificate + HTTP hardening policy in one place. +- OpenClaw can stay on loopback HTTP behind the proxy. + +Example header value: + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +### Gateway TLS termination + +If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: + +```json5 +{ + gateway: { + tls: { enabled: true }, + http: { + securityHeaders: { + strictTransportSecurity: "max-age=31536000; includeSubDomains", + }, + }, + }, +} +``` + +`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + +### Rollout guidance + +- Start with a short max age first (for example `max-age=300`) while validating traffic. +- Increase to long-lived values (for example `max-age=31536000`) only after confidence is high. +- Add `includeSubDomains` only if every subdomain is HTTPS-ready. +- Use preload only if you intentionally meet preload requirements for your full domain set. +- Loopback-only local development does not benefit from HSTS. + ## Proxy Setup Examples ### Pomerium diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205e..4cf1c7447ed7 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2814,6 +2814,19 @@ Send any of these **as a standalone message** (no slash): ``` stop +stop action +stop current action +stop run +stop current run +stop agent +stop the agent +stop openclaw +openclaw stop +stop don't do anything +stop do not do anything +stop doing anything +please stop +stop please abort esc wait diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index c31ec7c06187..a585ce9f2a9c 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -60,7 +60,9 @@ When you switch channels with `openclaw update`, OpenClaw also syncs plugin sour ## Tagging best practices -- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-`). +- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta). +- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`. +- Legacy `vYYYY.M.D-` tags are still recognized as stable (non-beta). - Keep tags immutable: never move or reuse a tag. - npm dist-tags remain the source of truth for npm installs: - `latest` → stable diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md index 7ca46ff7cd9a..9baf90278b87 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -17,6 +17,14 @@ Run a persistent OpenClaw Gateway on a Hetzner VPS using Docker, with durable st If you want “OpenClaw 24/7 for ~$5”, this is the simplest reliable setup. Hetzner pricing changes; pick the smallest Debian/Ubuntu VPS and scale up if you hit OOMs. +Security model reminder: + +- Company-shared agents are fine when everyone is in the same trust boundary and the runtime is business-only. +- Keep strict separation: dedicated VPS/runtime + dedicated accounts; no personal Apple/Google/browser/password-manager profiles on that host. +- If users are adversarial to each other, split by gateway/host/OS user. + +See [Security](/gateway/security) and [VPS hosting](/vps). + ## What are we doing (simple terms)? - Rent a small Linux server (Hetzner VPS) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 7d3a8d0190b5..029ab3eed934 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.21.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.21.zip` (and `OpenClaw-2026.2.21.dSYM.zip`) to the GitHub release for tag `v2026.2.21`. +- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 8637685bbe9a..17263ca05093 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -177,6 +177,12 @@ headers are trusted. `webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request remote IP matches the list. +Webhook replay protection is enabled for Twilio and Plivo. Replayed valid webhook +requests are acknowledged but skipped for side effects. + +Twilio conversation turns include a per-turn token in `` callbacks, so +stale/replayed speech callbacks cannot satisfy a newer pending transcript turn. + Example with a stable public host: ```json5 diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 6f9759b3b2ff..40f86630dba9 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -67,6 +67,42 @@ Use the `cacheRetention` parameter in your model config: When using Anthropic API Key authentication, OpenClaw automatically applies `cacheRetention: "short"` (5-minute cache) for all Anthropic models. You can override this by explicitly setting `cacheRetention` in your config. +### Per-agent cacheRetention overrides + +Use model-level params as your baseline, then override specific agents via `agents.list[].params`. + +```json5 +{ + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { + "anthropic/claude-opus-4-6": { + params: { cacheRetention: "long" }, // baseline for most agents + }, + }, + }, + list: [ + { id: "research", default: true }, + { id: "alerts", params: { cacheRetention: "none" } }, // override for this agent only + ], + }, +} +``` + +Config merge order for cache-related params: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching `id`, overrides by key) + +This lets one agent keep a long-lived cache while another agent on the same model disables caching to avoid write costs on bursty/low-reuse traffic. + +### Bedrock Claude notes + +- Anthropic Claude models on Bedrock (`amazon-bedrock/*anthropic.claude*`) accept `cacheRetention` pass-through when configured. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. +- Anthropic API-key smart defaults also seed `cacheRetention: "short"` for Claude-on-Bedrock model refs when no explicit value is set. + ### Legacy parameter The older `cacheControlTtl` parameter is still supported for backwards compatibility: @@ -101,6 +137,10 @@ with `params.context1m: true` for supported Opus/Sonnet models. OpenClaw maps this to `anthropic-beta: context-1m-2025-08-07` on Anthropic requests. +Note: Anthropic currently rejects `context-1m-*` beta requests when using +OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the +context1m beta header for OAuth auth and keeps the required OAuth betas. + ## Option B: Claude setup-token **Best for:** using your Claude subscription. diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md new file mode 100644 index 000000000000..146e22932c4a --- /dev/null +++ b/docs/providers/kilocode.md @@ -0,0 +1,64 @@ +--- +summary: "Use Kilo Gateway's unified API to access many models in OpenClaw" +read_when: + - You want a single API key for many LLMs + - You want to run models via Kilo Gateway in OpenClaw +--- + +# Kilo Gateway + +Kilo Gateway provides a **unified API** that routes requests to many models behind a single +endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL. + +## Getting an API key + +1. Go to [app.kilo.ai](https://app.kilo.ai) +2. Sign in or create an account +3. Navigate to API Keys and generate a new key + +## CLI setup + +```bash +openclaw onboard --kilocode-api-key +``` + +Or set the environment variable: + +```bash +export KILOCODE_API_KEY="your-api-key" +``` + +## Config snippet + +```json5 +{ + env: { KILOCODE_API_KEY: "sk-..." }, + agents: { + defaults: { + model: { primary: "kilocode/anthropic/claude-opus-4.6" }, + }, + }, +} +``` + +## Surfaced model refs + +The built-in Kilo Gateway catalog currently surfaces these model refs: + +- `kilocode/anthropic/claude-opus-4.6` (default) +- `kilocode/z-ai/glm-5:free` +- `kilocode/minimax/minimax-m2.5:free` +- `kilocode/anthropic/claude-sonnet-4.5` +- `kilocode/openai/gpt-5.2` +- `kilocode/google/gemini-3-pro-preview` +- `kilocode/google/gemini-3-flash-preview` +- `kilocode/x-ai/grok-code-fast-1` +- `kilocode/moonshotai/kimi-k2.5` + +## Notes + +- Model refs are `kilocode//` (e.g., `kilocode/anthropic/claude-opus-4.6`). +- Default model: `kilocode/anthropic/claude-opus-4.6` +- Base URL: `https://api.kilo.ai/api/gateway/` +- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers). +- Kilo Gateway uses a Bearer token with your API key under the hood. diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 726a6040fcc7..3b5053fbac7c 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -48,3 +48,11 @@ openclaw onboard --non-interactive \ If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY` is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`). + +## Model ID shorthand + +OpenClaw accepts Vercel Claude shorthand model refs and normalizes them at +runtime: + +- `vercel-ai-gateway/claude-opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4.6` +- `vercel-ai-gateway/opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4-6` diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md new file mode 100644 index 000000000000..67561e4a21b4 --- /dev/null +++ b/docs/reference/prompt-caching.md @@ -0,0 +1,185 @@ +--- +title: "Prompt Caching" +summary: "Prompt caching knobs, merge order, provider behavior, and tuning patterns" +read_when: + - You want to reduce prompt token costs with cache retention + - You need per-agent cache behavior in multi-agent setups + - You are tuning heartbeat and cache-ttl pruning together +--- + +# Prompt caching + +Prompt caching means the model provider can reuse unchanged prompt prefixes (usually system/developer instructions and other stable context) across turns instead of re-processing them every time. The first matching request writes cache tokens (`cacheWrite`), and later matching requests can read them back (`cacheRead`). + +Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. + +This page covers all cache-related knobs that affect prompt reuse and token cost. + +For Anthropic pricing details, see: +[https://docs.anthropic.com/docs/build-with-claude/prompt-caching](https://docs.anthropic.com/docs/build-with-claude/prompt-caching) + +## Primary knobs + +### `cacheRetention` (model and per-agent) + +Set cache retention on model params: + +```yaml +agents: + defaults: + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "short" # none | short | long +``` + +Per-agent override: + +```yaml +agents: + list: + - id: "alerts" + params: + cacheRetention: "none" +``` + +Config merge order: + +1. `agents.defaults.models["provider/model"].params` +2. `agents.list[].params` (matching agent id; overrides by key) + +### Legacy `cacheControlTtl` + +Legacy values are still accepted and mapped: + +- `5m` -> `short` +- `1h` -> `long` + +Prefer `cacheRetention` for new config. + +### `contextPruning.mode: "cache-ttl"` + +Prunes old tool-result context after cache TTL windows so post-idle requests do not re-cache oversized history. + +```yaml +agents: + defaults: + contextPruning: + mode: "cache-ttl" + ttl: "1h" +``` + +See [Session Pruning](/concepts/session-pruning) for full behavior. + +### Heartbeat keep-warm + +Heartbeat can keep cache windows warm and reduce repeated cache writes after idle gaps. + +```yaml +agents: + defaults: + heartbeat: + every: "55m" +``` + +Per-agent heartbeat is supported at `agents.list[].heartbeat`. + +## Provider behavior + +### Anthropic (direct API) + +- `cacheRetention` is supported. +- With Anthropic API-key auth profiles, OpenClaw seeds `cacheRetention: "short"` for Anthropic model refs when unset. + +### Amazon Bedrock + +- Anthropic Claude model refs (`amazon-bedrock/*anthropic.claude*`) support explicit `cacheRetention` pass-through. +- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime. + +### OpenRouter Anthropic models + +For `openrouter/anthropic/*` model refs, OpenClaw injects Anthropic `cache_control` on system/developer prompt blocks to improve prompt-cache reuse. + +### Other providers + +If the provider does not support this cache mode, `cacheRetention` has no effect. + +## Tuning patterns + +### Mixed traffic (recommended default) + +Keep a long-lived baseline on your main agent, disable caching on bursty notifier agents: + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" + list: + - id: "research" + default: true + heartbeat: + every: "55m" + - id: "alerts" + params: + cacheRetention: "none" +``` + +### Cost-first baseline + +- Set baseline `cacheRetention: "short"`. +- Enable `contextPruning.mode: "cache-ttl"`. +- Keep heartbeat below your TTL only for agents that benefit from warm caches. + +## Cache diagnostics + +OpenClaw exposes dedicated cache-trace diagnostics for embedded agent runs. + +### `diagnostics.cacheTrace` config + +```yaml +diagnostics: + cacheTrace: + enabled: true + filePath: "~/.openclaw/logs/cache-trace.jsonl" # optional + includeMessages: false # default true + includePrompt: false # default true + includeSystem: false # default true +``` + +Defaults: + +- `filePath`: `$OPENCLAW_STATE_DIR/logs/cache-trace.jsonl` +- `includeMessages`: `true` +- `includePrompt`: `true` +- `includeSystem`: `true` + +### Env toggles (one-off debugging) + +- `OPENCLAW_CACHE_TRACE=1` enables cache tracing. +- `OPENCLAW_CACHE_TRACE_FILE=/path/to/cache-trace.jsonl` overrides output path. +- `OPENCLAW_CACHE_TRACE_MESSAGES=0|1` toggles full message payload capture. +- `OPENCLAW_CACHE_TRACE_PROMPT=0|1` toggles prompt text capture. +- `OPENCLAW_CACHE_TRACE_SYSTEM=0|1` toggles system prompt capture. + +### What to inspect + +- Cache trace events are JSONL and include staged snapshots like `session:loaded`, `prompt:before`, `stream:context`, and `session:after`. +- Per-turn cache token impact is visible in normal usage surfaces via `cacheRead` and `cacheWrite` (for example `/usage full` and session usage summaries). + +## Quick troubleshooting + +- High `cacheWrite` on most turns: check for volatile system-prompt inputs and verify model/provider supports your cache settings. +- No effect from `cacheRetention`: confirm model key matches `agents.defaults.models["provider/model"]`. +- Bedrock Nova/Mistral requests with cache settings: expected runtime force to `none`. + +Related docs: + +- [Anthropic](/providers/anthropic) +- [Token Use and Costs](/reference/token-use) +- [Session Pruning](/concepts/session-pruning) +- [Gateway Configuration Reference](/gateway/configuration-reference) diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 3a08575454ed..aff09a303e8a 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -65,6 +65,44 @@ OpenClaw resolves these via `src/config/sessions.ts`. --- +## Store maintenance and disk controls + +Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json` and transcript artifacts: + +- `mode`: `warn` (default) or `enforce` +- `pruneAfter`: stale-entry age cutoff (default `30d`) +- `maxEntries`: cap entries in `sessions.json` (default `500`) +- `rotateBytes`: rotate `sessions.json` when oversized (default `10mb`) +- `resetArchiveRetention`: retention for `*.reset.` transcript archives (default: same as `pruneAfter`; `false` disables cleanup) +- `maxDiskBytes`: optional sessions-directory budget +- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) + +Enforcement order for disk budget cleanup (`mode: "enforce"`): + +1. Remove oldest archived or orphan transcript artifacts first. +2. If still above the target, evict oldest session entries and their transcript files. +3. Keep going until usage is at or below `highWaterBytes`. + +In `mode: "warn"`, OpenClaw reports potential evictions but does not mutate the store/files. + +Run maintenance on demand: + +```bash +openclaw sessions cleanup --dry-run +openclaw sessions cleanup --enforce +``` + +--- + +## Cron sessions and run logs + +Isolated cron runs also create session entries/transcripts, and they have dedicated retention controls: + +- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables). +- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl` files (defaults: `2_000_000` bytes and `2000` lines). + +--- + ## Session keys (`sessionKey`) A `sessionKey` identifies _which conversation bucket_ you’re in (routing + isolation). diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 7f04e19650fb..9127e2477e00 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -88,6 +88,11 @@ Heartbeat can keep the cache **warm** across idle gaps. If your model cache TTL is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid re-caching the full prompt, reducing cache write costs. +In multi-agent setups, you can keep one shared model config and tune cache behavior +per agent with `agents.list[].params.cacheRetention`. + +For a full knob-by-knob guide, see [Prompt Caching](/reference/prompt-caching). + For Anthropic API pricing, cache reads are significantly cheaper than input tokens, while cache writes are billed at a higher multiplier. See Anthropic’s prompt caching pricing for the latest rates and TTL multipliers: @@ -108,6 +113,30 @@ agents: every: "55m" ``` +### Example: mixed traffic with per-agent cache strategy + +```yaml +agents: + defaults: + model: + primary: "anthropic/claude-opus-4-6" + models: + "anthropic/claude-opus-4-6": + params: + cacheRetention: "long" # default baseline for most agents + list: + - id: "research" + default: true + heartbeat: + every: "55m" # keep long cache warm for deep sessions + - id: "alerts" + params: + cacheRetention: "none" # avoid cache writes for bursty notifications +``` + +`agents.list[].params` merges on top of the selected model's `params`, so you can +override only `cacheRetention` and inherit other model defaults unchanged. + ### Example: enable Anthropic 1M context beta header Anthropic's 1M context window is currently beta-gated. OpenClaw can inject the @@ -125,6 +154,10 @@ agents: This maps to Anthropic's `context-1m-2025-08-07` beta header. +If you authenticate Anthropic with OAuth/subscription tokens (`sk-ant-oat-*`), +OpenClaw skips the `context-1m-*` beta header because Anthropic currently +rejects that combination with HTTP 401. + ## Tips for reducing token pressure - Use `/compact` to summarize long sessions. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4d8492f2151c..13eaf3203f84 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -59,6 +59,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. { browser: { enabled: true, // default: true + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) @@ -86,6 +92,9 @@ Notes: - `cdpUrl` defaults to the relay port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. +- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing. +- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. - Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser. @@ -561,6 +570,20 @@ These are useful for “make the site behave like X” workflows: - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. +Strict-mode example (block private/internal destinations by default): + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], // optional exact allow + }, + }, +} +``` + ## Troubleshooting For Linux-specific issues (especially snap Chromium), see diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a73..964eb40f37b5 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -77,6 +77,18 @@ openclaw browser create-profile \ --color "#00AA00" ``` +### Custom Gateway ports + +If you're using a custom gateway port, the extension relay port is automatically derived: + +**Extension Relay Port = Gateway Port + 3** + +Example: if `gateway.port: 19001`, then: + +- Extension relay port: `19004` (gateway + 3) + +Configure the extension to use the derived relay port in the extension Options page. + ## Attach / detach (toolbar button) - Open the tab you want OpenClaw to control. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index cec00599e2af..f155fbbd7905 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -25,6 +25,12 @@ Exec approvals are enforced locally on the execution host: - **gateway host** → `openclaw` process on the gateway machine - **node host** → node runner (macOS companion app or headless node host) +Trust model note: + +- Gateway-authenticated callers are trusted operators for that Gateway. +- Paired nodes extend that trusted operator capability onto the node host. +- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary. + macOS split: - **node host service** forwards `system.run` to the **macOS app** over local IPC. @@ -119,6 +125,12 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill are treated as allowlisted on nodes (macOS node or headless node host). This uses `skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists. +Important trust notes: + +- This is an **implicit convenience allowlist**, separate from manual path allowlist entries. +- It is intended for trusted operator environments where Gateway and node are in the same trust boundary. +- If you require strict explicit trust, keep `autoAllowSkills: false` and use manual path allowlist entries only. + ## Safe bins (stdin-only) `tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`) @@ -131,17 +143,20 @@ Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles. Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, -`sort --files0-from`, `sort --compress-program`, `wc --files0-from`, `jq -f/--from-file`, +`sort --files0-from`, `sort --compress-program`, `sort --random-source`, +`sort --temporary-directory`/`-T`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`). Safe bins also enforce explicit per-binary flag policy for options that break stdin-only behavior (for example `sort -o/--output/--compress-program` and grep recursive flags). +Long options are validated fail-closed in safe-bin mode: unknown flags and ambiguous +abbreviations are rejected. Denied flags by safe-bin profile: - `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r` - `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f` -- `sort`: `--compress-program`, `--files0-from`, `--output`, `-o` +- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o` - `wc`: `--files0-from` @@ -163,7 +178,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper -paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically. +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`, +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 1123d3068d27..1dc5cc4fc1de 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -122,12 +122,15 @@ running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` noti ## Allowlist + safe bins -Allowlist enforcement matches **resolved binary paths only** (no basename matches). When +Manual allowlist enforcement matches **resolved binary paths only** (no basename matches). When `security=allowlist`, shell commands are auto-allowed only if every pipeline segment is allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in allowlist mode unless every top-level segment satisfies the allowlist (including safe bins). Redirections remain unsupported. +`autoAllowSkills` is a separate convenience path in exec approvals. It is not the same as +manual path allowlist entries. For strict explicit trust, keep `autoAllowSkills` disabled. + Use the two controls for different jobs: - `tools.exec.safeBins`: small, stdin-only stream filters. diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccdb..269b6856d038 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -478,6 +478,7 @@ Notes: - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - If `thread: true` and `mode` is omitted, mode defaults to `session`. - `mode: "session"` requires `thread: true`. + - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - Reply format includes `Status`, `Result`, and compact stats. - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 7334da1ec401..9542858c8402 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -71,6 +71,7 @@ Use `sessions_spawn`: - Then runs an announce step and posts the announce reply to the requester chat channel - Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. - Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- Default run timeout: if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). Tool params: @@ -79,7 +80,7 @@ Tool params: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, the sub-agent run is aborted after N seconds) - `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session) - `mode?` (`run|session`) - default is `run` @@ -148,6 +149,7 @@ By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). Y maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) + runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout) }, }, }, diff --git a/docs/tools/web.md b/docs/tools/web.md index b0e295cd22a7..0d48d746b5e1 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,9 +1,10 @@ --- -summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" +summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)" read_when: - You want to enable web_search or web_fetch - You need Brave Search API key setup - You want to use Perplexity Sonar for web search + - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +12,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). +- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -22,6 +23,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_search` calls your configured provider and returns results. - **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity**: returns AI-synthesized answers with citations from real-time web search. + - **Gemini**: returns AI-synthesized answers grounded in Google Search with citations. - Results are cached by query for 15 minutes (configurable). - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. @@ -33,9 +35,23 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. +### Auto-detection + +If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: + +1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config +2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config +3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config +4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config + +If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). + +### Explicit provider + Set the provider in config: ```json5 @@ -43,7 +59,7 @@ Set the provider in config: tools: { web: { search: { - provider: "brave", // or "perplexity" + provider: "brave", // or "perplexity" or "gemini" }, }, }, @@ -139,6 +155,49 @@ If no base URL is set, OpenClaw chooses a default based on the API key source: | `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions | | `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research | +## Using Gemini (Google Search grounding) + +Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), +which returns AI-synthesized answers backed by live Google Search results with citations. + +### Getting a Gemini API key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create an API key +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` + +### Setting up Gemini search + +```json5 +{ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, +} +``` + +**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +### Notes + +- Citation URLs from Gemini grounding are automatically resolved from Google's + redirect URLs to direct URLs. +- Redirect resolution uses the SSRF guard path (HEAD + redirect checks + http/https validation) before returning the final citation URL. +- This redirect resolver follows the trusted-network model (private/internal networks allowed by default) to match Gateway operator trust assumptions. +- The default model (`gemini-2.5-flash`) is fast and cost-effective. + Any Gemini model that supports grounding can be used. + ## web_search Search the web using your configured provider. diff --git a/docs/vps.md b/docs/vps.md index f0b1f7d77775..adb884038909 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -34,6 +34,16 @@ deployments work at a high level. Remote access: [Gateway remote](/gateway/remote) Platforms hub: [Platforms](/platforms) +## Shared company agent on a VPS + +This is a valid setup when the users are in one trust boundary (for example one company team), and the agent is business-only. + +- Keep it on a dedicated runtime (VPS/VM/container + dedicated OS user/accounts). +- Do not sign that runtime into personal Apple/Google accounts or personal browser/password-manager profiles. +- If users are adversarial to each other, split by gateway/host/OS user. + +Security model details: [Security](/gateway/security) + ## Using nodes with a VPS You can keep the Gateway in the cloud and pair **nodes** on your local devices diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0a..ad6d2393523a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. @@ -96,7 +99,7 @@ Cron jobs panel notes: - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band + - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: - When a run is aborted, partial assistant text can still be shown in the UI @@ -230,8 +233,10 @@ Notes: Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. -- For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI - origin to `gateway.controlUi.allowedOrigins`. +- Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` + explicitly (full origins). This includes remote dev setups. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but it is a dangerous security mode. Example: diff --git a/docs/web/index.md b/docs/web/index.md index 42baffe8027e..3fc48dd993c1 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -99,8 +99,10 @@ Open: - Non-loopback binds still **require** a shared token/password (`gateway.auth` or env). - The wizard generates a gateway token by default (even on loopback). - The UI sends `connect.params.auth.token` or `connect.params.auth.password`. -- The Control UI sends anti-clickjacking headers and only accepts same-origin browser - websocket connections unless `gateway.controlUi.allowedOrigins` is set. +- For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` + explicitly (full origins). Without it, gateway startup is refused by default. +- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables + Host-header origin fallback mode, but is a dangerous security downgrade. - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth when `gateway.auth.allowTailscale` is `true` (no token/password required). HTTP API endpoints still require token/password. Set diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9853e372159d..307a69a8dcf3 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -31,6 +31,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. +## Control UI agents tools panel + +- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each + tool as `core` or `plugin:` (plus `optional` for optional plugin tools). +- If `tools.catalog` is unavailable, the panel falls back to a built-in static list. +- The panel edits profile and override config, but effective runtime access still follows policy + precedence (`allow`/`deny`, per-agent and provider/channel overrides). + ## Remote use - Remote mode tunnels the gateway WebSocket over SSH/Tailscale. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index da6b3ad9afb7..102b71717118 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fef..904d21d4d3f2 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index aabc5adf8fe7..5db42331207f 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => { const handleAction = bluebubblesMessageActions.handleAction!; const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); + const blueBubblesConfig = (): OpenClawConfig => ({ + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }); + const runReactAction = async (params: Record) => { + return await callHandleAction({ + action: "react", + params, + cfg: blueBubblesConfig(), + accountId: null, + }); + }; beforeEach(() => { vi.clearAllMocks(); @@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => { it("sends reaction successfully with chatGuid", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( @@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => { it("sends reaction removal successfully", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 22c5d3e42e8c..e774ef6c85ef 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -2,13 +2,13 @@ import { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS, createActionGate, + extractToolSend, jsonResult, readNumberParam, readReactionParams, readStringParam, type ChannelMessageActionAdapter, type ChannelMessageActionName, - type ChannelToolSend, } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { const account = resolveBlueBubblesAccount({ cfg: cfg, diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 17060229930c..d6b12d311f88 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => { setBlueBubblesRuntime(runtimeStub); }); + async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { + const largeBuffer = new Uint8Array(params.bufferBytes); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), + }), + ).rejects.toThrow("too large"); + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => { }); it("throws when attachment exceeds max bytes", async () => { - const largeBuffer = new Uint8Array(10 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + await expectAttachmentTooLarge({ + bufferBytes: 10 * 1024 * 1024, + maxBytes: 5 * 1024 * 1024, }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - maxBytes: 5 * 1024 * 1024, - }), - ).rejects.toThrow("too large"); }); it("uses default max bytes when not specified", async () => { - const largeBuffer = new Uint8Array(9 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("too large"); + await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); }); it("uses attachment mimeType as fallback when response has no content-type", async () => { @@ -274,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 3b8850f21540..6ccb043845f0 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, @@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment( url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index d22ded63613d..cc37829bc9dd 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({ }); describe("chat", () => { + function mockOkTextResponse() { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + } + + async function expectCalledUrlIncludesPassword(params: { + password: string; + invoke: () => Promise; + }) { + mockOkTextResponse(); + await params.invoke(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(`password=${params.password}`); + } + + async function expectCalledUrlUsesConfigCredentials(params: { + serverHost: string; + password: string; + invoke: (cfg: { + channels: { bluebubbles: { serverUrl: string; password: string } }; + }) => Promise; + }) { + mockOkTextResponse(); + await params.invoke({ + channels: { + bluebubbles: { + serverUrl: `http://${params.serverHost}`, + password: params.password, + }, + }, + }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(params.serverHost); + expect(calledUrl).toContain(`password=${params.password}`); + } + describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty or whitespace", async () => { for (const chatGuid of ["", " "]) { @@ -73,18 +111,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -119,25 +153,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + markBlueBubblesChatRead("chat-123", { + cfg, + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); }); @@ -536,18 +559,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -582,25 +601,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg, + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe16..e4bef3fd73bb 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts index 88e840394173..c768385e03a1 100644 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ b/extensions/bluebubbles/src/monitor-shared.ts @@ -1,8 +1,10 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import type { BlueBubblesAccountConfig } from "./types.js"; +export { normalizeWebhookPath }; + export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; @@ -30,18 +32,6 @@ export type WebhookTarget = { export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; -export function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { const raw = config?.webhookPath?.trim(); if (raw) { diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index ca6b42ab5df0..78b2876b5e0e 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { let next = cfg; const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); + const validateServerUrlInput = (value: unknown): string | undefined => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }; + const promptServerUrl = async (initialValue?: string): Promise => { + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + initialValue, + validate: validateServerUrlInput, + }); + return String(entered).trim(); + }; // Prompt for server URL let serverUrl = resolvedAccount.config.serverUrl?.trim(); @@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { ].join("\n"), "BlueBubbles server URL", ); - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(); } else { const keepUrl = await prompter.confirm({ message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, initialValue: true, }); if (!keepUrl) { - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - initialValue: serverUrl, - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }, - }); - serverUrl = String(entered).trim(); + serverUrl = await promptServerUrl(serverUrl); } } diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 0ea99f911f6e..419ccc81e452 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,6 +19,27 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { + async function expectRemovedReaction(emoji: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji, + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + } + it("throws when chatGuid is empty", async () => { await expect( sendBlueBubblesReaction({ @@ -208,45 +229,11 @@ describe("reactions", () => { }); it("sends reaction removal with dash prefix", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("love"); }); it("strips leading dash from emoji when remove flag is set", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "-love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("-love"); }); it("uses custom partIndex when provided", async () => { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 9872372641e3..6b2e5fe051fa 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) { }); } +function mockNewChatSendResponse(guid: string) { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid }, + }), + ), + }); +} + describe("send", () => { describe("resolveChatGuidForTarget", () => { const resolveHandleTargetGuid = async (data: Array>) => { @@ -453,20 +470,7 @@ describe("send", () => { }); it("strips markdown when creating a new chat", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-stripped" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-stripped"); const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { serverUrl: "http://localhost:1234", @@ -483,20 +487,7 @@ describe("send", () => { }); it("creates a new chat when handle target is missing", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-guid" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-guid"); const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { serverUrl: "http://localhost:1234", diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a0..72ccd9918570 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; }; diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 155e611f6a80..3a3310f7d999 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7890659fef18..f3a32e4542f4 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,7 +1,12 @@ -import { spawn } from "node:child_process"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; +import { + approveDevicePairing, + listDevicePairing, + resolveGatewayBindUrl, + runPluginCommandWithTimeout, + resolveTailnetHostWithRunner, +} from "openclaw/plugin-sdk"; import qrcode from "qrcode-terminal"; function renderQrAscii(data: string): Promise { @@ -37,77 +42,6 @@ type ResolveAuthResult = { error?: string; }; -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout( - argv: string[], - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const [command, ...args] = argv; - if (!command) { - resolve({ code: 1, stdout: "", stderr: "command is required" }); - return; - } - const proc = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${timeoutMs}ms`, - }); - }, timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -239,48 +173,12 @@ function pickTailnetIPv4(): string | null { } async function resolveTailnetHost(): Promise { - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000); - if (result.code !== 0) { - continue; - } - const raw = result.stdout.trim(); - if (!raw) { - continue; - } - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; - if (dns && dns.length > 0) { - return dns.replace(/\.$/, ""); - } - const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : []; - if (ips.length > 0) { - return ips[0] ?? null; - } - } catch { - continue; - } - } - return null; -} - -function parsePossiblyNoisyJsonObject(raw: string): Record { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; - } + return await resolveTailnetHostWithRunner((argv, opts) => + runPluginCommandWithTimeout({ + argv, + timeoutMs: opts.timeoutMs, + }), + ); } function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { @@ -365,29 +263,16 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise ({ }, })); -vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({ OTLPMetricExporter: class {}, })); -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({ OTLPTraceExporter: class { constructor(options?: unknown) { traceExporterCtor(options); @@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ }, })); -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({ OTLPLogExporter: class {}, })); @@ -110,6 +110,10 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk"; import { emitDiagnosticEvent } from "openclaw/plugin-sdk"; import { createDiagnosticsOtelService } from "./service.js"; +const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test"; +const OTEL_TEST_ENDPOINT = "http://otel-collector:4318"; +const OTEL_TEST_PROTOCOL = "http/protobuf"; + function createLogger() { return { info: vi.fn(), @@ -119,7 +123,15 @@ function createLogger() { }; } -function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { +type OtelContextFlags = { + traces?: boolean; + metrics?: boolean; + logs?: boolean; +}; +function createOtelContext( + endpoint: string, + { traces = false, metrics = false, logs = false }: OtelContextFlags = {}, +): OpenClawPluginServiceContext { return { config: { diagnostics: { @@ -127,17 +139,46 @@ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext otel: { enabled: true, endpoint, - protocol: "http/protobuf", - traces: true, - metrics: false, - logs: false, + protocol: OTEL_TEST_PROTOCOL, + traces, + metrics, + logs, }, }, }, logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", + stateDir: OTEL_TEST_STATE_DIR, }; } + +function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext { + return createOtelContext(endpoint, { traces: true }); +} + +type RegisteredLogTransport = (logObj: Record) => void; +function setupRegisteredTransports() { + const registeredTransports: RegisteredLogTransport[] = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + return { registeredTransports, stopTransport }; +} + +async function emitAndCaptureLog(logObj: Record) { + const { registeredTransports } = setupRegisteredTransports(); + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true }); + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.(logObj); + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + await service.stop?.(ctx); + return emitCall; +} + describe("diagnostics-otel service", () => { beforeEach(() => { telemetryState.counters.clear(); @@ -154,31 +195,10 @@ describe("diagnostics-otel service", () => { }); test("records message-flow metrics and spans", async () => { - const registeredTransports: Array<(logObj: Record) => void> = []; - const stopTransport = vi.fn(); - registerLogTransportMock.mockImplementation((transport) => { - registeredTransports.push(transport); - return stopTransport; - }); + const { registeredTransports } = setupRegisteredTransports(); const service = createDiagnosticsOtelService(); - const ctx: OpenClawPluginServiceContext = { - config: { - diagnostics: { - enabled: true, - otel: { - enabled: true, - endpoint: "http://otel-collector:4318", - protocol: "http/protobuf", - traces: true, - metrics: true, - logs: true, - }, - }, - }, - logger: createLogger(), - stateDir: "/tmp/openclaw-diagnostics-otel-test", - }; + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true }); await service.start(ctx); emitDiagnosticEvent({ @@ -293,4 +313,55 @@ describe("diagnostics-otel service", () => { expect(options?.url).toBe("https://collector.example.com/v1/Traces"); await service.stop?.(ctx); }); + + test("redacts sensitive data from log messages before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: "Using API key sk-1234567890abcdef1234567890abcdef", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + + expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef"); + expect(emitCall?.body).toContain("sk-123"); + expect(emitCall?.body).toContain("…"); + }); + + test("redacts sensitive data from log attributes before export", async () => { + const emitCall = await emitAndCaptureLog({ + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 1: "auth configured", + _meta: { logLevelName: "DEBUG", date: new Date() }, + }); + + const tokenAttr = emitCall?.attributes?.["openclaw.token"]; + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + if (typeof tokenAttr === "string") { + expect(tokenAttr).toContain("…"); + } + }); + + test("redacts sensitive reason in session.state metric attributes", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "session.state", + state: "waiting", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + }); + + const sessionCounter = telemetryState.counters.get("openclaw.session.state"); + expect(sessionCounter?.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + "openclaw.reason": expect.stringContaining("…"), + }), + ); + const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; + expect(typeof attrs?.["openclaw.reason"]).toBe("string"); + expect(String(attrs?.["openclaw.reason"])).not.toContain( + "ghp_abcdefghijklmnopqrstuvwxyz123456", + ); + await service.stop?.(ctx); + }); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 78975eb36e2b..be9a547963f1 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1,8 +1,8 @@ import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; import type { SeverityNumber } from "@opentelemetry/api-logs"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; @@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; -import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk"; const DEFAULT_SERVICE_NAME = "openclaw"; @@ -54,6 +54,14 @@ function formatError(err: unknown): string { } } +function redactOtelAttributes(attributes: Record) { + const redactedAttributes: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value; + } + return redactedAttributes; +} + export function createDiagnosticsOtelService(): OpenClawPluginService { let sdk: NodeSDK | null = null; let logProvider: LoggerProvider | null = null; @@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes["openclaw.code.location"] = meta.path.filePathWithLine; } + // OTLP can leave the host boundary, so redact string fields before export. otelLogger.emit({ - body: message, + body: redactSensitiveText(message), severityText: logLevelName, severityNumber, - attributes, + attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }); } catch (err) { @@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const redactedError = redactSensitiveText(evt.error); const spanAttrs: Record = { ...attrs, - "openclaw.error": evt.error, + "openclaw.error": redactedError, }; if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); @@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { const span = tracer.startSpan("openclaw.webhook.error", { attributes: spanAttrs, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -496,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { } }; + const addSessionIdentityAttrs = ( + spanAttrs: Record, + evt: { sessionKey?: string; sessionId?: string }, + ) => { + if (evt.sessionKey) { + spanAttrs["openclaw.sessionKey"] = evt.sessionKey; + } + if (evt.sessionId) { + spanAttrs["openclaw.sessionId"] = evt.sessionId; + } + }; + const recordMessageProcessed = ( evt: Extract, ) => { @@ -511,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); } @@ -524,11 +541,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { spanAttrs["openclaw.messageId"] = String(evt.messageId); } if (evt.reason) { - spanAttrs["openclaw.reason"] = evt.reason; + spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); - if (evt.outcome === "error") { - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + if (evt.outcome === "error" && evt.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } span.end(); }; @@ -557,7 +574,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ) => { const attrs: Record = { "openclaw.state": evt.state }; if (evt.reason) { - attrs["openclaw.reason"] = evt.reason; + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); } sessionStateCounter.add(1, attrs); }; @@ -574,12 +591,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...attrs }; - if (evt.sessionKey) { - spanAttrs["openclaw.sessionKey"] = evt.sessionKey; - } - if (evt.sessionId) { - spanAttrs["openclaw.sessionId"] = evt.sessionId; - } + addSessionIdentityAttrs(spanAttrs, evt); spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; spanAttrs["openclaw.ageMs"] = evt.ageMs; const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); @@ -645,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { }); if (logsEnabled) { - ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)"); } }, async stop() { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 98ca5edb26e3..dac541368eba 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Discord channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 446f8747b89e..5ef3ab09caed 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, @@ -347,16 +348,8 @@ export const discordPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectDiscordStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1debb8f4ee01..2eb737280563 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 91d390ac04dc..f18658e62b50 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -720,10 +720,10 @@ export async function handleFeishuMessage(params: { // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; + let topicSessionMode: "enabled" | "disabled" = "disabled"; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - const topicSessionMode = - groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; @@ -739,6 +739,14 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, + // Add parentPeer for binding inheritance in topic mode + parentPeer: + isGroup && ctx.rootId && topicSessionMode === "enabled" + ? { + kind: "group", + id: ctx.chatId, + } + : null, }); // Dynamic agent creation for DM users diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index bbe56bbb02a2..73c5ff2652c0 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; export type DownloadImageResult = { buffer: Buffer; @@ -268,18 +268,11 @@ export async function sendImageFeishu(params: { accountId?: string; }): Promise { const { cfg, to, imageKey, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ image_key: imageKey }); if (replyToMessageId) { @@ -320,18 +313,11 @@ export async function sendFileFeishu(params: { }): Promise { const { cfg, to, fileKey, replyToMessageId, accountId } = params; const msgType = params.msgType ?? "file"; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ + cfg, + to, + accountId, + }); const content = JSON.stringify({ file_key: fileKey }); if (replyToMessageId) { diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts new file mode 100644 index 000000000000..7d0d28663cc3 --- /dev/null +++ b/extensions/feishu/src/send-target.ts @@ -0,0 +1,25 @@ +import type { ClawdbotConfig } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; + +export function resolveFeishuSendTarget(params: { + cfg: ClawdbotConfig; + to: string; + accountId?: string; +}) { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + const client = createFeishuClient(account); + const receiveId = normalizeFeishuTarget(params.to); + if (!receiveId) { + throw new Error(`Invalid Feishu target: ${params.to}`); + } + return { + client, + receiveId, + receiveIdType: resolveReceiveIdType(receiveId), + }; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index c97601ccccbb..341ff3ed64d6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js"; import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; -import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; -import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js"; +import { resolveFeishuSendTarget } from "./send-target.js"; +import type { FeishuSendResult } from "./types.js"; export type FeishuMessageInfo = { messageId: string; @@ -128,18 +128,7 @@ export async function sendMessageFeishu( params: SendFeishuMessageParams, ): Promise { const { cfg, to, text, replyToMessageId, mentions, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu", @@ -188,18 +177,7 @@ export type SendFeishuCardParams = { export async function sendCardFeishu(params: SendFeishuCardParams): Promise { const { cfg, to, card, replyToMessageId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); - const receiveId = normalizeFeishuTarget(to); - if (!receiveId) { - throw new Error(`Invalid Feishu target: ${to}`); - } - - const receiveIdType = resolveReceiveIdType(receiveId); + const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const content = JSON.stringify(card); if (replyToMessageId) { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 93cf41661088..56f1fc365571 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -132,6 +132,26 @@ export class FeishuStreamingSession { this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); } + private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise { + if (!this.state) { + return; + } + const apiBase = resolveApiBase(this.creds.domain); + this.state.sequence += 1; + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { + method: "PUT", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: text, + sequence: this.state.sequence, + uuid: `s_${this.state.cardId}_${this.state.sequence}`, + }), + }).catch((error) => onError?.(error)); + } + async update(text: string): Promise { if (!this.state || this.closed) { return; @@ -150,20 +170,7 @@ export class FeishuStreamingSession { return; } this.state.currentText = text; - this.state.sequence += 1; - const apiBase = resolveApiBase(this.creds.domain); - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch((e) => this.log?.(`Update failed: ${String(e)}`)); + await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`)); }); await this.queue; } @@ -181,19 +188,7 @@ export class FeishuStreamingSession { // Only send final update if content differs from what's already displayed if (text && text !== this.state.currentText) { - this.state.sequence += 1; - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch(() => {}); + await this.updateCardContent(text); this.state.currentText = text; } diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md deleted file mode 100644 index 4e1dee975eaf..000000000000 --- a/extensions/google-antigravity-auth/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Google Antigravity Auth (OpenClaw plugin) - -OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-antigravity-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-antigravity --set-default -``` - -## Notes - -- Antigravity uses Google Cloud project quotas. -- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts deleted file mode 100644 index 055cb15e00b6..000000000000 --- a/extensions/google-antigravity-auth/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - isWSL2Sync, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync -const decode = (s: string) => Buffer.from(s, "base64").toString(); -const CLIENT_ID = decode( - "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", -); -const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); -const REDIRECT_URI = "http://localhost:51121/oauth-callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking"; - -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; - -const CODE_ASSIST_ENDPOINTS = [ - "https://cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", -]; - -const RESPONSE_PAGE = ` - - - - OpenClaw Antigravity OAuth - - -
-

Authentication complete

-

You can return to the terminal.

-
- -`; - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function buildAuthUrl(params: { challenge: string; state: string }): string { - const url = new URL(AUTH_URL); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("response_type", "code"); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPES.join(" ")); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - return url.toString(); -} - -function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter in URL" }; - } - return { code, state }; - } catch { - return { error: "Paste the full redirect URL (not just the code)." }; - } -} - -async function startCallbackServer(params: { timeoutMs: number }) { - const redirect = new URL(REDIRECT_URI); - const port = redirect.port ? Number(redirect.port) : 51121; - - let settled = false; - let resolveCallback: (url: URL) => void; - let rejectCallback: (err: Error) => void; - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = (url) => { - if (settled) { - return; - } - settled = true; - resolve(url); - }; - rejectCallback = (err) => { - if (settled) { - return; - } - settled = true; - reject(err); - }; - }); - - const timeout = setTimeout(() => { - rejectCallback(new Error("Timed out waiting for OAuth callback")); - }, params.timeoutMs); - timeout.unref?.(); - - const server = createServer((request, response) => { - if (!request.url) { - response.writeHead(400, { "Content-Type": "text/plain" }); - response.end("Missing URL"); - return; - } - - const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); - if (url.pathname !== redirect.pathname) { - response.writeHead(404, { "Content-Type": "text/plain" }); - response.end("Not found"); - return; - } - - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(RESPONSE_PAGE); - resolveCallback(url); - - setImmediate(() => { - server.close(); - }); - }); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - server.off("error", onError); - reject(err); - }; - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function exchangeCode(params: { - code: string; - verifier: string; -}): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: params.code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: params.verifier, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token exchange failed: ${text}`); - } - - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expiresIn = data.expires_in ?? 0; - - if (!access) { - throw new Error("Token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Token exchange returned no refresh_token"); - } - - const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; - return { access, refresh, expires }; -} - -async function fetchUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { email?: string }; - return data.email; - } catch { - return undefined; - } -} - -async function fetchProjectId(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }; - - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - cloudaicompanionProject?: string | { id?: string }; - }; - - if (typeof data.cloudaicompanionProject === "string") { - return data.cloudaicompanionProject; - } - if ( - data.cloudaicompanionProject && - typeof data.cloudaicompanionProject === "object" && - data.cloudaicompanionProject.id - ) { - return data.cloudaicompanionProject.id; - } - } catch { - // ignore - } - } - - return DEFAULT_PROJECT_ID; -} - -async function loginAntigravity(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}): Promise<{ - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}> { - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - const authUrl = buildAuthUrl({ challenge, state }); - - let callbackServer: Awaited> | null = null; - const needsManual = shouldUseManualOAuthFlow(params.isRemote); - if (!needsManual) { - try { - callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); - } catch { - callbackServer = null; - } - } - - if (!callbackServer) { - await params.note( - [ - "Open the URL in your local browser.", - "After signing in, copy the full redirect URL and paste it back here.", - "", - `Auth URL: ${authUrl}`, - `Redirect URI: ${REDIRECT_URI}`, - ].join("\n"), - "Google Antigravity OAuth", - ); - // Output raw URL below the box for easy copying (fixes #1772) - params.log(""); - params.log("Copy this URL:"); - params.log(authUrl); - params.log(""); - } - - if (!needsManual) { - params.progress.update("Opening Google sign-in…"); - try { - await params.openUrl(authUrl); - } catch { - // ignore - } - } - - let code = ""; - let returnedState = ""; - - if (callbackServer) { - params.progress.update("Waiting for OAuth callback…"); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code") ?? ""; - returnedState = callback.searchParams.get("state") ?? ""; - await callbackServer.close(); - } else { - params.progress.update("Waiting for redirect URL…"); - const input = await params.prompt("Paste the redirect URL: "); - const parsed = parseCallbackInput(input); - if ("error" in parsed) { - throw new Error(parsed.error); - } - code = parsed.code; - returnedState = parsed.state; - } - - if (!code) { - throw new Error("Missing OAuth code"); - } - if (returnedState !== state) { - throw new Error("OAuth state mismatch. Please try again."); - } - - params.progress.update("Exchanging code for tokens…"); - const tokens = await exchangeCode({ code, verifier }); - const email = await fetchUserEmail(tokens.access); - const projectId = await fetchProjectId(tokens.access); - - params.progress.stop("Antigravity OAuth complete"); - return { ...tokens, email, projectId }; -} - -const antigravityPlugin = { - id: "google-antigravity-auth", - name: "Google Antigravity Auth", - description: "OAuth flow for Google Antigravity (Cloud Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "google-antigravity", - label: "Google Antigravity", - docsPath: "/providers/models", - aliases: ["antigravity"], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); - try { - const result = await loginAntigravity({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (message) => String(await ctx.prompter.text({ message })), - note: ctx.prompter.note, - log: (message) => ctx.runtime.log(message), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "google-antigravity", - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: [ - "Antigravity uses Google Cloud project quotas.", - "Enable Gemini for Google Cloud on your project if requests fail.", - ], - }); - } catch (err) { - spin.stop("Antigravity OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json deleted file mode 100644 index 2ef207f04863..000000000000 --- a/extensions/google-antigravity-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-antigravity-auth", - "providers": ["google-antigravity"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json deleted file mode 100644 index e730f4dcbe45..000000000000 --- a/extensions/google-antigravity-auth/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.22", - "private": true, - "description": "OpenClaw Google Antigravity OAuth provider plugin", - "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index c9675901266f..62fcd6d318e5 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index bd166510c7ad..95d444f30785 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 6eec88abbe40..2a4e9935e2c0 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { isSenderAllowed } from "./monitor.js"; describe("isSenderAllowed", () => { - it("matches allowlist entries with raw email", () => { - expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true); + it("matches raw email entries only when dangerous name matching is enabled", () => { + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true); }); it("does not treat users/ entries as email allowlist (deprecated form)", () => { @@ -17,6 +18,8 @@ describe("isSenderAllowed", () => { }); it("rejects non-matching raw email entries", () => { - expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false); + expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe( + false, + ); }); }); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 689f10341c2d..c75294896957 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -6,6 +6,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSingleWebhookTargetAsync, @@ -287,6 +288,7 @@ export function isSenderAllowed( senderId: string, senderEmail: string | undefined, allowFrom: string[], + allowNameMatching = false, ) { if (allowFrom.includes("*")) { return true; @@ -305,8 +307,8 @@ export function isSenderAllowed( return normalizeUserId(withoutPrefix) === normalizedSenderId; } - // Raw email allowlist entries remain supported for usability. - if (normalizedEmail && isEmailLike(withoutPrefix)) { + // Raw email allowlist entries are a break-glass override. + if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { return withoutPrefix === normalizedEmail; } @@ -409,6 +411,7 @@ async function processMessageWithPipeline(params: { const senderId = sender?.name ?? ""; const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? undefined; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const allowBots = account.config.allowBots === true; if (!allowBots) { @@ -489,6 +492,7 @@ async function processMessageWithPipeline(params: { senderId, senderEmail, groupUsers.map((v) => String(v)), + allowNameMatching, ); if (!ok) { logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); @@ -508,7 +512,12 @@ async function processMessageWithPipeline(params: { warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; - const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); + const senderAllowedForCommands = isSenderAllowed( + senderId, + senderEmail, + commandAllowFrom, + allowNameMatching, + ); const commandAuthorized = shouldComputeAuth ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 926e012ddd14..9956c7bb7f42 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 39e2d8485f84..876fcb9adb73 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw IRC channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 59121e7ff58e..6993baa0ba70 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,13 +1,15 @@ import { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, - deleteAccountFromConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; import { @@ -319,37 +321,23 @@ export const ircPlugin: ChannelPlugin = { lastError: null, }, buildChannelSummary: ({ account, snapshot }) => ({ - configured: snapshot.configured ?? false, + ...buildBaseChannelStatusSummary(snapshot), host: account.host, port: snapshot.port, tls: account.tls, nick: account.nick, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ cfg, account, timeoutMs }) => probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 14ce51b39a46..74a7ac363afd 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -45,6 +46,7 @@ export const IrcAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), host: z.string().optional(), port: z.number().int().min(1).max(65535).optional(), tls: z.boolean().optional(), @@ -62,15 +64,7 @@ export const IrcAccountSchemaBase = z channels: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index dd466f095072..26d0aa85927a 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,11 +1,16 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, + isDangerousNameMatchingEnabled, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -27,32 +32,20 @@ const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); async function deliverIrcReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const text = params.payload.text ?? ""; - const mediaList = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - if (params.sendReply) { await params.sendReply(params.target, combined, params.payload.replyToId); } else { @@ -86,6 +79,7 @@ export async function handleIrcInbound(params: { const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick; + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = resolveDefaultGroupPolicy(config); @@ -140,6 +134,7 @@ export async function handleIrcInbound(params: { const senderAllowedForCommands = resolveIrcAllowlistMatch({ allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, message, + allowNameMatching, }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const commandGate = resolveControlCommandGate({ @@ -161,6 +156,7 @@ export async function handleIrcInbound(params: { message, outerAllowFrom: effectiveGroupAllowFrom, innerAllowFrom: groupAllowFrom, + allowNameMatching, }); if (!senderAllowed) { runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); @@ -175,6 +171,7 @@ export async function handleIrcInbound(params: { const dmAllowed = resolveIrcAllowlistMatch({ allowFrom: effectiveAllowFrom, message, + allowNameMatching, }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") { @@ -317,26 +314,22 @@ export async function handleIrcInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverIrcReply({ + payload, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverIrcReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index d4dbec89db8c..4e07fa28abd8 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -39,13 +39,12 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.configured) { throw new Error( diff --git a/extensions/irc/src/normalize.test.ts b/extensions/irc/src/normalize.test.ts index a498ffaacd02..428f0015fd2e 100644 --- a/extensions/irc/src/normalize.test.ts +++ b/extensions/irc/src/normalize.test.ts @@ -30,6 +30,8 @@ describe("irc normalize", () => { }; expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org"); + expect(buildIrcAllowlistCandidates(message)).not.toContain("alice"); + expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice"); expect( resolveIrcAllowlistMatch({ allowFrom: ["alice!ident@example.org"], @@ -38,9 +40,16 @@ describe("irc normalize", () => { ).toBe(true); expect( resolveIrcAllowlistMatch({ - allowFrom: ["bob"], + allowFrom: ["alice"], message, }).allowed, ).toBe(false); + expect( + resolveIrcAllowlistMatch({ + allowFrom: ["alice"], + message, + allowNameMatching: true, + }).allowed, + ).toBe(true); }); }); diff --git a/extensions/irc/src/normalize.ts b/extensions/irc/src/normalize.ts index 89d135dbfd75..90b731dcbbfe 100644 --- a/extensions/irc/src/normalize.ts +++ b/extensions/irc/src/normalize.ts @@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string { return base; } -export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] { +export function buildIrcAllowlistCandidates( + message: IrcInboundMessage, + params?: { allowNameMatching?: boolean }, +): string[] { const nick = message.senderNick.trim().toLowerCase(); const user = message.senderUser?.trim().toLowerCase(); const host = message.senderHost?.trim().toLowerCase(); const candidates = new Set(); - if (nick) { + if (nick && params?.allowNameMatching === true) { candidates.add(nick); } if (nick && user) { @@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[ export function resolveIrcAllowlistMatch(params: { allowFrom: string[]; message: IrcInboundMessage; + allowNameMatching?: boolean; }): { allowed: boolean; source?: string } { const allowFrom = new Set( params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean), @@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: { if (allowFrom.has("*")) { return { allowed: true, source: "wildcard" }; } - const candidates = buildIrcAllowlistCandidates(params.message); + const candidates = buildIrcAllowlistCandidates(params.message, { + allowNameMatching: params.allowNameMatching, + }); for (const candidate of candidates) { if (allowFrom.has(candidate)) { return { allowed: true, source: candidate }; diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts index be3f65e617ec..4136466ca792 100644 --- a/extensions/irc/src/policy.test.ts +++ b/extensions/irc/src/policy.test.ts @@ -50,12 +50,29 @@ describe("irc policy", () => { }), ).toBe(false); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice!ident@example.org"], + innerAllowFrom: [], + }), + ).toBe(true); + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice"], + innerAllowFrom: [], + }), + ).toBe(false); expect( resolveIrcGroupSenderAllowed({ groupPolicy: "allowlist", message, outerAllowFrom: ["alice"], innerAllowFrom: [], + allowNameMatching: true, }), ).toBe(true); }); diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts index 81828a5ac09a..356f0fae7d8b 100644 --- a/extensions/irc/src/policy.ts +++ b/extensions/irc/src/policy.ts @@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: { message: IrcInboundMessage; outerAllowFrom: string[]; innerAllowFrom: string[]; + allowNameMatching?: boolean; }): boolean { const policy = params.groupPolicy ?? "allowlist"; const inner = normalizeIrcAllowlist(params.innerAllowFrom); const outer = normalizeIrcAllowlist(params.outerAllowFrom); if (inner.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: inner, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } if (outer.length > 0) { - return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed; + return resolveIrcAllowlistMatch({ + allowFrom: outer, + message: params.message, + allowNameMatching: params.allowNameMatching, + }).allowed; } return policy === "open"; } diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index 2da3d31bafc4..03e2d3f5eb30 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -32,6 +32,11 @@ export type IrcNickServConfig = { export type IrcAccountConfig = { name?: string; enabled?: boolean; + /** + * Break-glass override: allow nick-only allowlist matching. + * Default behavior requires host/user-qualified identities. + */ + dangerouslyAllowNameMatching?: boolean; host?: string; port?: number; tls?: boolean; diff --git a/extensions/line/package.json b/extensions/line/package.json index 69907bd5ef78..ef86adea7322 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index c2864ec70c0a..b11bdc998702 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,10 +1,6 @@ -import type { - OpenClawConfig, - PluginRuntime, - ResolvedLineAccount, - RuntimeEnv, -} from "openclaw/plugin-sdk"; +import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function resolveAccount( resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], cfg: OpenClawConfig, diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index abd1aedf17cb..e5b0ce333f5c 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -33,20 +33,10 @@ function createRuntime() { return { runtime, probeLineBot, monitorLineProvider }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { token: string; secret: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ac49940d2563..a260d96c9615 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,6 @@ import { buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, @@ -595,17 +596,7 @@ export const linePlugin: ChannelPlugin = { } return issues; }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 7e9e24eade15..44dc1b46fe11 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index f40d0351fec3..87588d7adbd1 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; - const primary = api.config?.agents?.defaults?.model?.primary; + const defaultsModel = api.config?.agents?.defaults?.model; + const primary = + typeof defaultsModel === "string" + ? defaultsModel.trim() + : (defaultsModel?.primary?.trim() ?? undefined); const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index e6c7665735e6..4fdbe8bd8877 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.22", + "version": "2026.2.23", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 294e625ce2b0..78de735f8ef3 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("lobster plugin tool", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeAll(async () => { ({ createLobsterTool } = await import("./lobster-tool.js")); @@ -79,29 +74,7 @@ describe("lobster plugin tool", () => { }); afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); }); afterAll(async () => { @@ -156,17 +129,6 @@ describe("lobster plugin tool", () => { }); }; - const createWindowsShimFixture = async (params: { - shimPath: string; - scriptPath: string; - scriptToken: string; - }) => { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); - }; - it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -281,10 +243,10 @@ describe("lobster plugin tool", () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); - await createWindowsShimFixture({ + await createWindowsCmdShimFixture({ shimPath, scriptPath: shimScriptPath, - scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); process.env.PATHEXT = ".CMD;.EXE"; process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts new file mode 100644 index 000000000000..30f2dc81d1bb --- /dev/null +++ b/extensions/lobster/src/test-helpers.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; + +const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; + +export type PlatformPathEnvSnapshot = { + platformDescriptor: PropertyDescriptor | undefined; + env: Record; +}; + +export function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot { + return { + platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + env: { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + Pathext: process.env.Pathext, + }, + }; +} + +export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void { + if (snapshot.platformDescriptor) { + Object.defineProperty(process, "platform", snapshot.platformDescriptor); + } + + for (const key of PATH_ENV_KEYS) { + const value = snapshot.env[key]; + if (value === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = value; + } +} + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index 75f49f34b058..e3d791e36e4b 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -2,22 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); @@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => { }); afterEach(async () => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); @@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 7ffcb8e6cd98..f7ea9f2327c2 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 16de3bfd3e3e..6941af8af68b 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -1,9 +1,8 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk"; const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; @@ -22,85 +21,6 @@ function resolvePluginRoot(): string { return path.resolve(currentDir, "..", ".."); } -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout(params: { - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; -}): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - export async function ensureMatrixSdkInstalled(params: { runtime: RuntimeEnv; confirm?: (message: string) => Promise; @@ -121,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runFixedCommandWithTimeout({ + const result = await runPluginCommandWithTimeout({ argv: command, cwd: root, timeoutMs: 300_000, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 0544dba9ab20..936eabdd3467 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,5 @@ -import { format } from "node:util"; import { + createLoggerBackedRuntime, GROUP_POLICY_BLOCKED_LABEL, mergeAllowlist, resolveAllowlistProviderRuntimeGroupPolicy, @@ -48,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b58..dfbfbabb8af2 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => { ); }); + it("skips reasoning-only replies with Reasoning prefix", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, + { text: "Here is the answer.", replyToId: "r2" }, + ], + roomId: "room:reason", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "first", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); + }); + + it("skips reasoning-only replies with thinking tags", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "internal chain of thought", replyToId: "r1" }, + { text: " more reasoning ", replyToId: "r2" }, + { text: "hidden", replyToId: "r3" }, + { text: "Visible reply", replyToId: "r4" }, + ], + roomId: "room:tags", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); + }); + + it("delivers all replies when none are reasoning-only", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "First answer", replyToId: "r1" }, + { text: "Second answer", replyToId: "r2" }, + ], + roomId: "room:normal", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + }); + it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 643e95cd4134..c86c7dde688c 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } + // Skip pure reasoning messages so internal thinking traces are never delivered. + if (reply.text && isReasoningOnlyMessage(reply.text)) { + logVerbose("matrix reply is reasoning-only; skipping"); + continue; + } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; const rawText = reply.text ?? ""; @@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: { } } } + +const REASONING_PREFIX = "Reasoning:\n"; +const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; + +/** + * Detect messages that contain only reasoning/thinking content and no user-facing answer. + * These are emitted by the agent when `includeReasoning` is active but should not + * be forwarded to channels that do not support a dedicated reasoning lane. + */ +function isReasoningOnlyMessage(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith(REASONING_PREFIX)) { + return true; + } + if (THINKING_TAG_RE.test(trimmed)) { + return true; + } + return false; +} diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index be6206d71f90..e1036ea2e398 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Mattermost channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 7628613a16b1..bb0d99e56675 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + dangerouslyAllowNameMatching: z.boolean().optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index c423513a6a2f..d645d563d38c 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -1,4 +1,8 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + formatInboundFromLabel as formatInboundFromLabelShared, + resolveThreadSessionKeys as resolveThreadSessionKeysShared, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk"; export type ResponsePrefixContext = { @@ -15,27 +19,7 @@ export function extractShortModelName(fullModel: string): string { return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); } -export function formatInboundFromLabel(params: { - isGroup: boolean; - groupLabel?: string; - groupId?: string; - directLabel: string; - directId?: string; - groupFallback?: string; -}): string { - if (params.isGroup) { - const label = params.groupLabel?.trim() || params.groupFallback || "Group"; - const id = params.groupId?.trim(); - return id ? `${label} id:${id}` : label; - } - - const directLabel = params.directLabel.trim(); - const directId = params.directId?.trim(); - if (!directId || directId === directLabel) { - return directLabel; - } - return `${directLabel} id:${directId}`; -} +export const formatInboundFromLabel = formatInboundFromLabelShared; function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); @@ -81,13 +65,8 @@ export function resolveThreadSessionKeys(params: { parentSessionKey?: string; useSuffix?: boolean; }): { sessionKey: string; parentSessionKey?: string } { - const threadId = (params.threadId ?? "").trim(); - if (!threadId) { - return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; - } - const useSuffix = params.useSuffix ?? true; - const sessionKey = useSuffix - ? `${params.baseSessionKey}:thread:${threadId}` - : params.baseSessionKey; - return { sessionKey, parentSessionKey: params.parentSessionKey }; + return resolveThreadSessionKeysShared({ + ...params, + normalizeThreadId: (threadId) => threadId, + }); } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 2ae8388b0fb8..fe799a295c93 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -15,6 +15,7 @@ import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, + isDangerousNameMatchingEnabled, resolveControlCommandGate, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -152,6 +153,7 @@ function isSenderAllowed(params: { senderId: string; senderName?: string; allowFrom: string[]; + allowNameMatching?: boolean; }): boolean { const allowFrom = params.allowFrom; if (allowFrom.length === 0) { @@ -162,10 +164,15 @@ function isSenderAllowed(params: { } const normalizedSenderId = normalizeAllowEntry(params.senderId); const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; - return allowFrom.some( - (entry) => - entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), - ); + return allowFrom.some((entry) => { + if (entry === normalizedSenderId) { + return true; + } + if (params.allowNameMatching !== true) { + return false; + } + return normalizedSenderName ? entry === normalizedSenderName : false; + }); } type MattermostMediaInfo = { @@ -206,6 +213,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, accountId: opts.accountId, }); + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); const botToken = opts.botToken?.trim() || account.botToken?.trim(); if (!botToken) { throw new Error( @@ -416,11 +424,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId, senderName, allowFrom: effectiveAllowFrom, + allowNameMatching, }); const groupAllowedForCommands = isSenderAllowed({ senderId, senderName, allowFrom: effectiveGroupAllowFrom, + allowNameMatching, }); const commandGate = resolveControlCommandGate({ useAccessGroups, @@ -892,6 +902,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId: userId, senderName, allowFrom: effectiveAllowFrom, + allowNameMatching, }); if (!allowed) { logVerboseMessage( @@ -927,6 +938,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId: userId, senderName, allowFrom: effectiveGroupAllowFrom, + allowNameMatching, }); if (!allowed) { logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`); diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 7501cca3f313..150989b7b442 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -7,6 +7,11 @@ export type MattermostAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** + * Break-glass override: allow mutable identity matching (@username/display name) in allowlists. + * Default behavior is ID-only matching. + */ + dangerouslyAllowNameMatching?: boolean; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Mattermost account. Default: true. */ diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index b577c8cfc901..aa9102dfccd6 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index dfd9b2b8030b..12610d18e9e4 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 3913b304c6bd..f61b1e019678 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 3f44afa994d4..1dd46e1d788e 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ea8b9babd1..b67289aea9dc 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,38 +1,85 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, + buildMSTeamsMediaPayload, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, +} from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; +vi.mock("openclaw/plugin-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isPrivateIpAddress: () => false, + }; +}); + /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ const publicResolveFn = async () => ({ address: "13.107.136.10" }); - -const detectMimeMock = vi.fn(async () => "image/png"); +const GRAPH_HOST = "graph.microsoft.com"; +const SHAREPOINT_HOST = "contoso.sharepoint.com"; +const AZUREEDGE_HOST = "azureedge.net"; +const TEST_HOST = "x"; +const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`; +const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment); +const SAVED_PNG_PATH = "/tmp/saved.png"; +const SAVED_PDF_PATH = "/tmp/saved.pdf"; +const TEST_URL_IMAGE = createTestUrl("img"); +const TEST_URL_IMAGE_PNG = createTestUrl("img.png"); +const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png"); +const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg"); +const TEST_URL_PDF = createTestUrl("x.pdf"); +const TEST_URL_PDF_1 = createTestUrl("1.pdf"); +const TEST_URL_PDF_2 = createTestUrl("2.pdf"); +const TEST_URL_HTML_A = createTestUrl("a.png"); +const TEST_URL_HTML_B = createTestUrl("b.png"); +const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png"); +const TEST_URL_DOC_PDF = createTestUrl("doc.pdf"); +const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl"); +const TEST_URL_OUTSIDE_ALLOWLIST = "https://evil.test/img"; +const CONTENT_TYPE_IMAGE_PNG = "image/png"; +const CONTENT_TYPE_APPLICATION_PDF = "application/pdf"; +const CONTENT_TYPE_TEXT_HTML = "text/html"; +const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info"; +const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]; +const MAX_REDIRECT_HOPS = 5; +type RemoteMediaFetchParams = { + url: string; + maxBytes?: number; + filePathHint?: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG); const saveMediaBufferMock = vi.fn(async () => ({ - path: "/tmp/saved.png", - contentType: "image/png", + path: SAVED_PNG_PATH, + contentType: CONTENT_TYPE_IMAGE_PNG, })); -const fetchRemoteMediaMock = vi.fn( - async (params: { - url: string; - maxBytes?: number; - filePathHint?: string; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - }) => { - const fetchFn = params.fetchImpl ?? fetch; - const res = await fetchFn(params.url); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - const buffer = Buffer.from(await res.arrayBuffer()); - if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { - throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); - } - return { - buffer, - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; - }, -); +const readRemoteMediaResponse = async ( + res: Response, + params: Pick, +) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) { + throw new Error(`payload exceeds maxBytes ${params.maxBytes}`); + } + return { + buffer, + contentType: res.headers.get("content-type") ?? undefined, + fileName: params.filePathHint, + }; +}; +const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { + const fetchFn = params.fetchImpl ?? fetch; + const res = await fetchFn(params.url); + return readRemoteMediaResponse(res, params); +}); const runtimeStub = { media: { @@ -48,11 +95,546 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; +type DownloadedMedia = Awaited>; +type MSTeamsMediaPayload = ReturnType; +type DownloadAttachmentsBuildOverrides = Partial< + Omit +> & + Pick; +type DownloadAttachmentsNoFetchOverrides = Partial< + Omit< + DownloadAttachmentsParams, + "attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn" + > +> & + Pick; +type DownloadGraphMediaOverrides = Partial< + Omit +>; +type FetchFn = typeof fetch; +type MSTeamsAttachments = DownloadAttachmentsParams["attachments"]; +type AttachmentPlaceholderInput = Parameters[0]; +type GraphMessageUrlParams = Parameters[0]; +type LabeledCase = { label: string }; +type FetchCallExpectation = { expectFetchCalled?: boolean }; +type DownloadedMediaExpectation = { path?: string; placeholder?: string }; +type MSTeamsMediaPayloadExpectation = { + firstPath: string; + paths: string[]; + types: string[]; +}; + +const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`; +const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`; +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_ALLOW_HOSTS = [TEST_HOST]; +const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST]; +const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file"); +const MEDIA_PLACEHOLDER_IMAGE = ""; +const MEDIA_PLACEHOLDER_DOCUMENT = ""; +const formatImagePlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE; +const formatDocumentPlaceholder = (count: number) => + count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT; +const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST_URL_IMAGE }; +const PNG_BUFFER = Buffer.from("png"); +const PNG_BASE64 = PNG_BUFFER.toString("base64"); +const PDF_BUFFER = Buffer.from("pdf"); +const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") }); +const asSingleItemArray = (value: T) => [value]; +const withLabel = (label: string, fields: T): T & LabeledCase => ({ + label, + ...fields, +}); +const buildAttachment = >(contentType: string, props: T) => ({ + contentType, + ...props, +}); +const createHtmlAttachment = (content: string) => + buildAttachment(CONTENT_TYPE_TEXT_HTML, { content }); +const buildHtmlImageTag = (src: string) => ``; +const createHtmlImageAttachments = (sources: string[], prefix = "") => + asSingleItemArray(createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`)); +const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) => + contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl })); +const createImageAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls); +const createPdfAttachments = (...contentUrls: string[]) => + createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls); +const createTeamsFileDownloadInfoAttachments = ( + downloadUrl = TEST_URL_FILE_DOWNLOAD, + fileType = "png", +) => + asSingleItemArray( + buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, { + content: { downloadUrl, fileType }, + }), + ); +const createMediaEntriesWithType = (contentType: string, ...paths: string[]) => + paths.map((path) => ({ path, contentType })); +const createHostedContentsWithType = (contentType: string, ...ids: string[]) => + ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 })); +const createImageMediaEntries = (...paths: string[]) => + createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths); +const createHostedImageContents = (...ids: string[]) => + createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids); +const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => { + return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF); +}; +const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => { + const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + return new Response(new Uint8Array(raw), { + status, + headers: { "content-type": contentType }, + }); +}; +const createJsonResponse = (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { status }); +const createTextResponse = (body: string, status = 200) => new Response(body, { status }); +const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value }); +const createNotFoundResponse = () => new Response("not found", { status: 404 }); +const createRedirectResponse = (location: string, status = 302) => + new Response(null, { status, headers: { location } }); + +const createOkFetchMock = (contentType: string, payload = "png") => + vi.fn(async () => createBufferResponse(payload, contentType)); +const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn; + +const buildDownloadParams = ( + attachments: MSTeamsAttachments, + overrides: DownloadAttachmentsBuildOverrides = {}, +): DownloadAttachmentsParams => { + return { + attachments, + maxBytes: DEFAULT_MAX_BYTES, + allowHosts: DEFAULT_ALLOW_HOSTS, + resolveFn: publicResolveFn, + ...overrides, }; +}; + +const downloadAttachmentsWithFetch = async ( + attachments: MSTeamsAttachments, + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: FetchCallExpectation = {}, +) => { + const media = await downloadMSTeamsAttachments( + buildDownloadParams(attachments, { + ...overrides, + fetchFn: asFetchFn(fetchFn), + }), + ); + expectMockCallState(fetchFn, options.expectFetchCalled ?? true); + return media; +}; + +const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) => + vi.fn(async (_url: string, opts?: RequestInit) => { + const headers = new Headers(opts?.headers); + const hasAuth = Boolean(headers.get("Authorization")); + if (!hasAuth) { + return createTextResponse(params.unauthBody, params.unauthStatus); + } + return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG); + }); +const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => { + if (shouldCall) { + expect(mockFn).toHaveBeenCalled(); + } else { + expect(mockFn).not.toHaveBeenCalled(); + } +}; + +const DEFAULT_CHANNEL_TEAM_ID = "team-id"; +const DEFAULT_CHANNEL_ID = "chan-id"; +const createChannelGraphMessageUrlParams = (params: { + messageId: string; + replyToId?: string; + conversationId?: string; +}) => ({ + conversationType: "channel" as const, + ...params, + channelData: { + team: { id: DEFAULT_CHANNEL_TEAM_ID }, + channel: { id: DEFAULT_CHANNEL_ID }, + }, +}); +const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) => + params.replyToId + ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}` + : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`; + +const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => { + expect(media).toHaveLength(expectedLength); +}; +const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => { + expectAttachmentMediaLength(media, 1); + expectFirstMedia(media, expected); +}; +const expectMediaBufferSaved = () => { + expect(saveMediaBufferMock).toHaveBeenCalled(); +}; +const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => { + const first = media[0]; + if (expected.path !== undefined) { + expect(first?.path).toBe(expected.path); + } + if (expected.placeholder !== undefined) { + expect(first?.placeholder).toBe(expected.placeholder); + } +}; +const expectMSTeamsMediaPayload = ( + payload: MSTeamsMediaPayload, + expected: MSTeamsMediaPayloadExpectation, +) => { + expect(payload.MediaPath).toBe(expected.firstPath); + expect(payload.MediaUrl).toBe(expected.firstPath); + expect(payload.MediaPaths).toEqual(expected.paths); + expect(payload.MediaUrls).toEqual(expected.paths); + expect(payload.MediaTypes).toEqual(expected.types); +}; +type AttachmentPlaceholderCase = LabeledCase & { + attachments: AttachmentPlaceholderInput; + expected: string; +}; +type CountedAttachmentPlaceholderCaseDef = LabeledCase & { + attachments: AttachmentPlaceholderCase["attachments"]; + count: number; + formatPlaceholder: (count: number) => string; +}; +type AttachmentDownloadSuccessCase = LabeledCase & { + attachments: MSTeamsAttachments; + buildFetchFn?: () => unknown; + beforeDownload?: () => void; + assert?: (media: DownloadedMedia) => void; +}; +type AttachmentAuthRetryScenario = { + attachmentUrl: string; + unauthStatus: number; + unauthBody: string; + overrides?: Omit; +}; +type AttachmentAuthRetryCase = LabeledCase & { + scenario: AttachmentAuthRetryScenario; + expectedMediaLength: number; + expectTokenFetch: boolean; +}; +type GraphUrlExpectationCase = LabeledCase & { + params: GraphMessageUrlParams; + expectedPath: string; +}; +type ChannelGraphUrlCaseParams = { + messageId: string; + replyToId?: string; + conversationId?: string; +}; +type GraphMediaDownloadResult = { + fetchMock: ReturnType; + media: Awaited>; +}; +type GraphMediaSuccessCase = LabeledCase & { + buildOptions: () => GraphFetchMockOptions; + expectedLength: number; + assert?: (params: GraphMediaDownloadResult) => void; +}; +const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [ + withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }), + withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }), +]; +const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [ + withLabel("returns image placeholder for one image attachment", { + attachments: createImageAttachments(TEST_URL_IMAGE_PNG), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns image placeholder with count for many image attachments", { + attachments: [ + ...createImageAttachments(TEST_URL_IMAGE_1_PNG), + { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG }, + ], + count: 2, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("treats Teams file.download.info image attachments as images", { + attachments: createTeamsFileDownloadInfoAttachments(), + count: 1, + formatPlaceholder: formatImagePlaceholder, + }), + withLabel("returns document placeholder for non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF), + count: 1, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("returns document placeholder with count for many non-image attachments", { + attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2), + count: 2, + formatPlaceholder: formatDocumentPlaceholder, + }), + withLabel("counts one inline image in html attachments", { + attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "

hi

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

hi

', - }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '', - }, - ]), - ).toBe(" (2 images)"); - }); + it.each(ATTACHMENT_PLACEHOLDER_CASES)( + "$label", + ({ attachments, expected }) => { + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); + }, + ); }); describe("downloadMSTeamsAttachments", () => { - it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.png"); - }); - - it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - }); - - it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - }); - detectMimeMock.mockResolvedValueOnce("application/pdf"); - saveMediaBufferMock.mockResolvedValueOnce({ - path: "/tmp/saved.pdf", - contentType: "application/pdf", - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.pdf"); - expect(media[0]?.placeholder).toBe(""); - }); - - it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: '', - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - }); + it.each(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)( + "$label", + runAttachmentDownloadSuccessCase, + ); it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: ``, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - }); - - expect(media).toHaveLength(1); - expect(saveMediaBufferMock).toHaveBeenCalled(); - }); - - it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("unauthorized", { status: 401 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - allowHosts: ["x"], - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([ + ...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]), + ]), + ); - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); + expectSingleMedia(media); + expectMediaBufferSaved(); }); - it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); - const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("forbidden", { status: 403 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, - ], - maxBytes: 1024 * 1024, - tokenProvider, - allowHosts: ["azureedge.net"], - authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); - - expect(media).toHaveLength(0); - expect(fetchMock).toHaveBeenCalled(); - expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); - }); + it.each(ATTACHMENT_AUTH_RETRY_CASES)( + "$label", + runAttachmentAuthRetryCase, + ); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadAttachmentsWithFetch( + createImageAttachments(TEST_URL_OUTSIDE_ALLOWLIST), + fetchMock, + { + allowHosts: [GRAPH_HOST], + resolveFn: undefined, + }, + { expectFetchCalled: false }, + ); - expect(media).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); + expectAttachmentMediaLength(media, 0); }); }); describe("buildMSTeamsGraphMessageUrls", () => { - it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - conversationId: "19:thread@thread.tacv2", - messageId: "123", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); - }); - - it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - messageId: "reply-id", - replyToId: "root-id", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain( - "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", - ); - }); - - it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "groupChat", - conversationId: "19:chat@thread.v2", - messageId: "456", - }); - expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => { + const urls = buildMSTeamsGraphMessageUrls(params); + expect(urls[0]).toContain(expectedPath); }); }); describe("downloadMSTeamsGraphMedia", () => { - it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "1", - contentType: "image/png", - contentBytes: base64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - }); - - it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const hostedBase64 = Buffer.from("png").toString("base64"); - const shareUrl = "https://contoso.sharepoint.com/site/file"; - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "hosted-1", - contentType: "image/png", - contentBytes: hostedBase64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); - - expect(media.media).toHaveLength(2); - }); + it.each(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { const fetchFn = params.fetchImpl ?? fetch; let currentUrl = params.url; - for (let i = 0; i < 5; i += 1) { + for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) { const res = await fetchFn(currentUrl, { redirect: "manual" }); - if ([301, 302, 303, 307, 308].includes(res.status)) { + if (REDIRECT_STATUS_CODES.includes(res.status)) { const location = res.headers.get("location"); if (!location) { throw new Error("redirect missing location"); @@ -485,84 +714,43 @@ describe("msteams attachments", () => { currentUrl = new URL(location, currentUrl).toString(); continue; } - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - return { - buffer: Buffer.from(await res.arrayBuffer()), - contentType: res.headers.get("content-type") ?? undefined, - fileName: params.filePathHint, - }; + return readRemoteMediaResponse(res, params); } throw new Error("too many redirects"); }); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(null, { - status: 302, - headers: { location: escapedUrl }, - }); - } - if (url === escapedUrl) { - return new Response(Buffer.from("should-not-be-fetched"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const { fetchMock, media } = await downloadGraphMediaWithMockOptions( + { + ...buildDefaultShareReferenceGraphFetchOptions({ + onShareRequest: () => createRedirectResponse(escapedUrl), + onUnhandled: (url) => { + if (url === escapedUrl) { + return createPdfResponse("should-not-be-fetched"); + } + return undefined; + }, + }), + }, + { + allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS, + }, + ); - expect(media.media).toHaveLength(0); + expectAttachmentMediaLength(media.media, 0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); - expect( - calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")), - ).toBe(true); + expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true); expect(calledUrls).not.toContain(escapedUrl); }); }); describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); - const payload = buildMSTeamsMediaPayload([ - { path: "/tmp/a.png", contentType: "image/png" }, - { path: "/tmp/b.png", contentType: "image/png" }, - ]); - expect(payload.MediaPath).toBe("/tmp/a.png"); - expect(payload.MediaUrl).toBe("/tmp/a.png"); - expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]); - expect(payload.MediaTypes).toEqual(["image/png", "image/png"]); + const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png")); + expectMSTeamsMediaPayload(payload, { + firstPath: "/tmp/a.png", + paths: ["/tmp/a.png", "/tmp/b.png"], + types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG], + }); }); }); }); diff --git a/extensions/msteams/src/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts index 3887f9ee9271..2049609d8940 100644 --- a/extensions/msteams/src/attachments/payload.ts +++ b/extensions/msteams/src/attachments/payload.ts @@ -1,3 +1,5 @@ +import { buildMediaPayload } from "openclaw/plugin-sdk"; + export function buildMSTeamsMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -8,15 +10,5 @@ export function buildMSTeamsMediaPayload( MediaUrls?: string[]; MediaTypes?: string[]; } { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType ?? ""); - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined, - }; + return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true }); } diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cbd562ae3adb..ba176019994d 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -49,6 +49,28 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +const createNoopAdapter = (): MSTeamsAdapter => ({ + continueConversation: async () => {}, + process: async () => {}, +}); + +const createRecordedSendActivity = ( + sink: string[], + failFirstWithStatusCode?: number, +): ((activity: unknown) => Promise<{ id: string }>) => { + let attempts = 0; + return async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + sink.push(content); + attempts += 1; + if (failFirstWithStatusCode !== undefined && attempts === 1) { + throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode }); + } + return { id: `id:${content}` }; + }; +}; + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -117,17 +139,9 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - sent.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(sent), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -149,11 +163,7 @@ describe("msteams messenger", () => { continueConversation: async (_appId, reference, logic) => { seen.reference = reference; await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - seen.texts.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, + sendActivity: createRecordedSendActivity(seen.texts), }); }, process: async () => {}, @@ -192,10 +202,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -242,20 +249,9 @@ describe("msteams messenger", () => { const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("throttled"), { statusCode: 429 }); - } - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(attempts, 429), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -280,10 +276,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); await expect( sendMSTeamsMessages({ @@ -303,18 +296,7 @@ describe("msteams messenger", () => { const adapter: MSTeamsAdapter = { continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("server error"), { - statusCode: 503, - }); - } - return { id: `id:${text ?? ""}` }; - }, - }); + await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 56f9848dd717..085efeeb0a88 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -6,6 +6,7 @@ import { recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, resolveDefaultGroupPolicy, + isDangerousNameMatchingEnabled, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -145,10 +146,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { if (dmPolicy !== "open") { const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; + const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveAllowFrom, senderId, senderName, + allowNameMatching, }); if (!allowMatch.allowed) { @@ -226,10 +229,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { return; } if (effectiveGroupAllowFrom.length > 0) { + const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching, }); if (!allowMatch.allowed) { log.debug?.("dropping group message (not in groupAllowFrom)", { @@ -248,12 +253,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { allowFrom: effectiveDmAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const groupAllowedForCommands = isMSTeamsGroupAllowed({ groupPolicy: "allowlist", allowFrom: effectiveGroupAllowFrom, senderId, senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); const commandGate = resolveControlCommandGate({ diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 90ee1f3cd241..3c7daa58b3f3 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -184,7 +184,7 @@ describe("msteams policy", () => { ).toBe(true); }); - it("allows allowlist when sender name matches", () => { + it("blocks sender-name allowlist matches by default", () => { expect( isMSTeamsGroupAllowed({ groupPolicy: "allowlist", @@ -192,6 +192,18 @@ describe("msteams policy", () => { senderId: "other", senderName: "User", }), + ).toBe(false); + }); + + it("allows sender-name allowlist matches when explicitly enabled", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user"], + senderId: "other", + senderName: "User", + allowNameMatching: true, + }), ).toBe(true); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 6bab808ce919..a3545c0594fb 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): MSTeamsAllowlistMatch { return resolveAllowlistMatchSimple(params); } @@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: { allowFrom: Array; senderId: string; senderName?: string | null; + allowNameMatching?: boolean; }): boolean { const { groupPolicy } = params; if (groupPolicy === "disabled") { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 80a1f5fbd2fd..772cffe238c1 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 73369b1eb2ef..b52522983c26 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -40,15 +41,7 @@ export const NextcloudTalkAccountSchemaBase = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 5ad02979b60a..dcef6aa93822 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,11 +1,15 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -26,32 +30,17 @@ import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./typ const CHANNEL_ID = "nextcloud-talk" as const; async function deliverNextcloudTalkReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; roomToken: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const text = payload.text ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - await sendMessageNextcloudTalk(roomToken, combined, { accountId, replyTo: payload.replyToId, @@ -318,25 +307,21 @@ export async function handleNextcloudTalkInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverNextcloudTalkReply({ + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverNextcloudTalkReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - roomToken, - accountId: account.accountId, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); }, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index ca9214fa6007..b7daac4d07c3 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { + createLoggerBackedRuntime, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, @@ -212,13 +213,12 @@ export async function monitorNextcloudTalkProvider( cfg, accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.secret) { throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 27ce113e3faa..c8583c392a3b 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d0c1c30ac8b2..5e2d3c838d58 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -204,6 +204,23 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + async function expectPrivatePictureRejected(pictureUrl: string) { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: pictureUrl, + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + } + it("validates profile and publishes", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -263,37 +280,11 @@ describe("nostr-profile-http", () => { }); it("rejects private IP in picture URL (SSRF protection)", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://127.0.0.1/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg"); }); it("rejects non-https URLs", async () => { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 76bc26da1767..038a5d7f3a7f 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index bca4c655cd15..bec90b982330 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2feb30dfe954..9f3a96b6c415 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,6 @@ import { applyAccountNameToChannelSection, + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -273,18 +274,8 @@ export const signalPlugin: ChannelPlugin = { return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8c936b45e36b..8541fffd0143 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 100807588061..230bcc80b3d4 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.22", + "version": "2026.2.23", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 622c7bffaedf..076339c4456d 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -39,6 +39,7 @@ vi.mock("zod", () => ({ })); const { createSynologyChatPlugin } = await import("./channel.js"); +const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk"); describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { @@ -336,5 +337,39 @@ describe("createSynologyChatPlugin", () => { const result = await plugin.gateway.startAccount(ctx); expect(typeof result.stop).toBe("function"); }); + + it("deregisters stale route before re-registering same account/path", async () => { + const unregisterFirst = vi.fn(); + const unregisterSecond = vi.fn(); + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookPath: "/webhook/synology", + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const first = await plugin.gateway.startAccount(ctx); + const second = await plugin.gateway.startAccount(ctx); + + expect(registerMock).toHaveBeenCalledTimes(2); + expect(unregisterFirst).toHaveBeenCalledTimes(1); + expect(unregisterSecond).not.toHaveBeenCalled(); + + // Clean up active route map so this module-level state doesn't leak across tests. + first.stop(); + second.stop(); + }); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0e205f60c3e8..37d4a4216ba9 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,6 +20,8 @@ import { createWebhookHandler } from "./webhook-handler.js"; const CHANNEL_ID = "synology-chat"; const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); +const activeRouteUnregisters = new Map void>(); + export function createSynologyChatPlugin() { return { id: CHANNEL_ID, @@ -270,7 +272,16 @@ export function createSynologyChatPlugin() { log, }); - // Register HTTP route via the SDK + // Deregister any stale route from a previous start (e.g. on auto-restart) + // to avoid "already registered" collisions that trigger infinite loops. + const routeKey = `${accountId}:${account.webhookPath}`; + const prevUnregister = activeRouteUnregisters.get(routeKey); + if (prevUnregister) { + log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); + prevUnregister(); + activeRouteUnregisters.delete(routeKey); + } + const unregister = registerPluginHttpRoute({ path: account.webhookPath, pluginId: CHANNEL_ID, @@ -278,6 +289,7 @@ export function createSynologyChatPlugin() { log: (msg: string) => log?.info?.(msg), handler, }); + activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); @@ -285,6 +297,7 @@ export function createSynologyChatPlugin() { stop: () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); + activeRouteUnregisters.delete(routeKey); }, }; }, diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 9aa14f3f5f3c..edb483069486 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -23,14 +23,14 @@ async function settleTimers(promise: Promise): Promise { return promise; } -function mockSuccessResponse() { +function mockResponse(statusCode: number, body: string) { const httpsRequest = vi.mocked(https.request); httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; - res.statusCode = 200; + res.statusCode = statusCode; process.nextTick(() => { callback(res); - res.emit("data", Buffer.from('{"success":true}')); + res.emit("data", Buffer.from(body)); res.emit("end"); }); const req = new EventEmitter() as any; @@ -41,22 +41,12 @@ function mockSuccessResponse() { }); } +function mockSuccessResponse() { + mockResponse(200, '{"success":true}'); +} + function mockFailureResponse(statusCode = 500) { - const httpsRequest = vi.mocked(https.request); - httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = statusCode; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from("error")); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.write = vi.fn(); - req.end = vi.fn(); - req.destroy = vi.fn(); - return req; - }); + mockResponse(statusCode, "error"); } describe("sendMessage", () => { diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 9248cc427e68..7e20c1006109 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -80,6 +80,24 @@ describe("createWebhookHandler", () => { }; }); + async function expectForbiddenByPolicy(params: { + account: Partial; + bodyContains: string; + }) { + const handler = createWebhookHandler({ + account: makeAccount(params.account), + deliver: vi.fn(), + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain(params.bodyContains); + } + it("rejects non-POST methods with 405", async () => { const handler = createWebhookHandler({ account: makeAccount(), @@ -129,36 +147,20 @@ describe("createWebhookHandler", () => { }); it("returns 403 for unauthorized user with allowlist policy", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ + await expectForbiddenByPolicy({ + account: { dmPolicy: "allowlist", allowedUserIds: ["456"], - }), - deliver: vi.fn(), - log, + }, + bodyContains: "not authorized", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("not authorized"); }); it("returns 403 when DMs are disabled", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ dmPolicy: "disabled" }), - deliver: vi.fn(), - log, + await expectForbiddenByPolicy({ + account: { dmPolicy: "disabled" }, + bodyContains: "disabled", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("disabled"); }); it("returns 429 when rate limited", async () => { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index a89802860c79..e6d9d0aff850 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index ffe4ce58fb71..0fd75ae7664d 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedTelegramAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig { } as OpenClawConfig; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { cfg: OpenClawConfig; accountId: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const account = telegramPlugin.config.resolveAccount( params.cfg, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c562d12470d9..0028e993fc0c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -374,17 +375,7 @@ export const telegramPlugin: ChannelPlugin ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getTelegramRuntime().channel.telegram.probeTelegram( account.token, diff --git a/extensions/test-utils/runtime-env.ts b/extensions/test-utils/runtime-env.ts new file mode 100644 index 000000000000..747ad5f5f3af --- /dev/null +++ b/extensions/test-utils/runtime-env.ts @@ -0,0 +1,12 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +export function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c58a60564a4c..ae5079b29ade 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 3dbc091ef6f3..ea80212088d2 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -13,7 +13,7 @@ export const TlonAuthorizationSchema = z.object({ channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(), }); -export const TlonAccountSchema = z.object({ +const tlonCommonConfigFields = { name: z.string().optional(), enabled: z.boolean().optional(), ship: ShipSchema.optional(), @@ -25,20 +25,14 @@ export const TlonAccountSchema = z.object({ autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), responsePrefix: z.string().optional(), +} satisfies z.ZodRawShape; + +export const TlonAccountSchema = z.object({ + ...tlonCommonConfigFields, }); export const TlonConfigSchema = z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - ship: ShipSchema.optional(), - url: z.string().optional(), - code: z.string().optional(), - allowPrivateNetwork: z.boolean().optional(), - groupChannels: z.array(ChannelNestSchema).optional(), - dmAllowlist: z.array(ShipSchema).optional(), - autoDiscoverChannels: z.boolean().optional(), - showModelSignature: z.boolean().optional(), - responsePrefix: z.string().optional(), + ...tlonCommonConfigFields, authorization: TlonAuthorizationSchema.optional(), defaultAuthorizedShips: z.array(ShipSchema).optional(), accounts: z.record(z.string(), TlonAccountSchema).optional(), diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e9d9750537ba..7d2e8dbd31f7 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,6 +1,5 @@ -import { format } from "node:util"; import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; @@ -88,18 +87,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); if (!account.enabled) { @@ -422,11 +414,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { channel: "testchannel", }; + function runAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + return checkTwitchAccessControl({ + message: { + ...mockMessage, + ...params.message, + }, + account: { + ...mockAccount, + ...params.account, + }, + botUsername: "testbot", + }); + } + + function expectSingleRoleAllowed(params: { + role: NonNullable[number]; + message: Partial; + }) { + const result = runAccessCheck({ + account: { allowedRoles: [params.role] }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const message: TwitchChatMessage = { @@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => { describe("allowedRoles", () => { it("allows users with matching role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); expect(result.matchSource).toBe("role"); }); @@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => { }); it("handles moderator role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); }); it("handles subscriber role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["subscriber"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isSub: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "subscriber", + message: { isSub: true }, }); - expect(result.allowed).toBe(true); }); it("handles owner role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "owner", + message: { isOwner: true }, }); - expect(result.allowed).toBe(true); }); it("handles vip role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["vip"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isVip: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "vip", + message: { isVip: true }, }); - expect(result.allowed).toBe(true); }); }); @@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => { }); it("checks allowlist before allowedRoles", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456"], - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { + allowFrom: ["123456"], + allowedRoles: ["owner"], + }, + message: { + message: "@testbot hello", + isOwner: false, + }, }); expect(result.allowed).toBe(true); expect(result.matchSource).toBe("allowlist"); diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index f278c22cb747..9acc9aec9873 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -175,5 +175,7 @@ Actions: ## Notes - Uses webhook signature verification for Twilio/Telnyx/Plivo. +- Adds replay protection for Twilio and Plivo webhooks (valid duplicate callbacks are ignored safely). +- Twilio speech turns include a per-turn token so stale/replayed callbacks cannot complete a newer turn. - `responseModel` / `responseSystemPrompt` control AI auto-responses. - Media streaming requires `ws` and OpenAI Realtime API key. diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7d8607ea3679..cb636350415d 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index eaf4e3fc0a53..83b681530217 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -81,6 +81,27 @@ function summarizeSeries(values: number[]): { }; } +function resolveCallMode(mode?: string): "notify" | "conversation" | undefined { + return mode === "notify" || mode === "conversation" ? mode : undefined; +} + +async function initiateCallAndPrintId(params: { + runtime: VoiceCallRuntime; + to: string; + message?: string; + mode?: string; +}) { + const result = await params.runtime.manager.initiateCall(params.to, undefined, { + message: params.message, + mode: resolveCallMode(params.mode), + }); + if (!result.success) { + throw new Error(result.error || "initiate failed"); + } + // eslint-disable-next-line no-console + console.log(JSON.stringify({ callId: result.callId }, null, 2)); +} + export function registerVoiceCallCli(params: { program: Command; config: VoiceCallConfig; @@ -112,16 +133,12 @@ export function registerVoiceCallCli(params: { if (!to) { throw new Error("Missing --to and no toNumber configured"); } - const result = await rt.manager.initiateCall(to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root @@ -136,16 +153,12 @@ export function registerVoiceCallCli(params: { ) .action(async (options: { to: string; message?: string; mode?: string }) => { const rt = await ensureRuntime(); - const result = await rt.manager.initiateCall(options.to, undefined, { + await initiateCallAndPrintId({ + runtime: rt, + to: options.to, message: options.message, - mode: - options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, + mode: options.mode, }); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - // eslint-disable-next-line no-console - console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index d92dbc11f852..06bb380c9163 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -17,12 +17,16 @@ import type { } from "./types.js"; class FakeProvider implements VoiceCallProvider { - readonly name = "plivo" as const; + readonly name: "plivo" | "twilio"; readonly playTtsCalls: PlayTtsInput[] = []; readonly hangupCalls: HangupCallInput[] = []; readonly startListeningCalls: StartListeningInput[] = []; readonly stopListeningCalls: StopListeningInput[] = []; + constructor(name: "plivo" | "twilio" = "plivo") { + this.name = name; + } + verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult { return { ok: true }; } @@ -319,6 +323,61 @@ describe("CallManager", () => { expect(provider.stopListeningCalls).toHaveLength(1); }); + it("ignores speech events with mismatched turnToken while waiting for transcript", async () => { + const { manager, provider } = createManagerHarness( + { + transcriptTimeoutMs: 5000, + }, + new FakeProvider("twilio"), + ); + + const started = await manager.initiateCall("+15550000004"); + expect(started.success).toBe(true); + + markCallAnswered(manager, started.callId, "evt-turn-token-answered"); + + const turnPromise = manager.continueCall(started.callId, "Prompt"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedTurnToken = provider.startListeningCalls[0]?.turnToken; + expect(typeof expectedTurnToken).toBe("string"); + + manager.processEvent({ + id: "evt-turn-token-bad", + type: "call.speech", + callId: started.callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + transcript: "stale replay", + isFinal: true, + turnToken: "wrong-token", + }); + + const pendingState = await Promise.race([ + turnPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(pendingState).toBe("pending"); + + manager.processEvent({ + id: "evt-turn-token-good", + type: "call.speech", + callId: started.callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + transcript: "final answer", + isFinal: true, + turnToken: expectedTurnToken, + }); + + const turnResult = await turnPromise; + expect(turnResult.success).toBe(true); + expect(turnResult.transcript).toBe("final answer"); + + const call = manager.getCall(started.callId); + expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]); + }); + it("tracks latency metadata across multiple closed-loop turns", async () => { const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, diff --git a/extensions/voice-call/src/manager/context.ts b/extensions/voice-call/src/manager/context.ts index 1af703ed3276..ed14a167e120 100644 --- a/extensions/voice-call/src/manager/context.ts +++ b/extensions/voice-call/src/manager/context.ts @@ -6,6 +6,7 @@ export type TranscriptWaiter = { resolve: (text: string) => void; reject: (err: Error) => void; timeout: NodeJS.Timeout; + turnToken?: string; }; export type CallManagerRuntimeState = { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index f1d5b5d6f037..ec2a26cd051c 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: { }; } +function createRejectingInboundContext(): { + ctx: CallManagerContext; + hangupCalls: HangupCallInput[]; +} { + const hangupCalls: HangupCallInput[] = []; + const provider = createProvider({ + hangupCall: async (input: HangupCallInput): Promise => { + hangupCalls.push(input); + }, + }); + const ctx = createContext({ + config: createInboundDisabledConfig(), + provider, + }); + return { ctx, hangupCalls }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event = createInboundInitiatedEvent({ id: "evt-1", providerCallId: "prov-1", @@ -118,16 +125,7 @@ describe("processEvent (functional)", () => { }); it("calls hangup only once for duplicate events for same rejected call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event1 = createInboundInitiatedEvent({ id: "evt-init", providerCallId: "prov-dup", @@ -236,4 +234,49 @@ describe("processEvent (functional)", () => { expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); }); + + it("deduplicates by dedupeKey even when event IDs differ", () => { + const now = Date.now(); + const ctx = createContext(); + ctx.activeCalls.set("call-dedupe", { + callId: "call-dedupe", + providerCallId: "provider-dedupe", + provider: "plivo", + direction: "outbound", + state: "answered", + from: "+15550000000", + to: "+15550000001", + startedAt: now, + transcript: [], + processedEventIds: [], + metadata: {}, + }); + ctx.providerCallIdMap.set("provider-dedupe", "call-dedupe"); + + processEvent(ctx, { + id: "evt-1", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 1, + transcript: "hello", + isFinal: true, + }); + + processEvent(ctx, { + id: "evt-2", + dedupeKey: "stable-key-1", + type: "call.speech", + callId: "call-dedupe", + providerCallId: "provider-dedupe", + timestamp: now + 2, + transcript: "hello", + isFinal: true, + }); + + const call = ctx.activeCalls.get("call-dedupe"); + expect(call?.transcript).toHaveLength(1); + expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]); + }); }); diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 508a8d526340..2d39a96bf749 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -92,10 +92,11 @@ function createInboundCall(params: { } export function processEvent(ctx: EventContext, event: NormalizedEvent): void { - if (ctx.processedEventIds.has(event.id)) { + const dedupeKey = event.dedupeKey || event.id; + if (ctx.processedEventIds.has(dedupeKey)) { return; } - ctx.processedEventIds.add(event.id); + ctx.processedEventIds.add(dedupeKey); let call = findCall({ activeCalls: ctx.activeCalls, @@ -158,7 +159,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { } } - call.processedEventIds.push(event.id); + call.processedEventIds.push(dedupeKey); switch (event.type) { case "call.initiated": @@ -192,8 +193,20 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { case "call.speech": if (event.isFinal) { + const hadWaiter = ctx.transcriptWaiters.has(call.callId); + const resolved = resolveTranscriptWaiter( + ctx, + call.callId, + event.transcript, + event.turnToken, + ); + if (hadWaiter && !resolved) { + console.warn( + `[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`, + ); + break; + } addTranscriptEntry(call, "user", event.transcript); - resolveTranscriptWaiter(ctx, call.callId, event.transcript); } transitionState(call, "listening"); break; diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index 38978b6791c6..494d7a10b5d3 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -63,6 +63,15 @@ type ConnectedCallLookup = provider: NonNullable; }; +type ConnectedCallResolution = + | { ok: false; error: string } + | { + ok: true; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { const call = ctx.activeCalls.get(callId); if (!call) { @@ -77,6 +86,22 @@ function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): Connect return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; } +function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution { + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { ok: false, error: lookup.error }; + } + if (lookup.kind === "ended") { + return { ok: false, error: "Call has ended" }; + } + return { + ok: true, + call: lookup.call, + providerCallId: lookup.providerCallId, + provider: lookup.provider, + }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -175,14 +200,11 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const lookup = lookupConnectedCall(ctx, callId); - if (lookup.kind === "error") { - return { success: false, error: lookup.error }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } - if (lookup.kind === "ended") { - return { success: false, error: "Call has ended" }; - } - const { call, providerCallId, provider } = lookup; + const { call, providerCallId, provider } = connected; try { transitionState(call, "speaking"); @@ -257,14 +279,11 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const lookup = lookupConnectedCall(ctx, callId); - if (lookup.kind === "error") { - return { success: false, error: lookup.error }; + const connected = requireConnectedCall(ctx, callId); + if (!connected.ok) { + return { success: false, error: connected.error }; } - if (lookup.kind === "ended") { - return { success: false, error: "Call has ended" }; - } - const { call, providerCallId, provider } = lookup; + const { call, providerCallId, provider } = connected; if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; @@ -272,6 +291,7 @@ export async function continueCall( ctx.activeTurnCalls.add(callId); const turnStartedAt = Date.now(); + const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined; try { await speak(ctx, callId, prompt); @@ -280,9 +300,9 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await provider.startListening({ callId, providerCallId }); + await provider.startListening({ callId, providerCallId, turnToken }); - const transcript = await waitForFinalTranscript(ctx, callId); + const transcript = await waitForFinalTranscript(ctx, callId, turnToken); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 236ffa143547..595ddb993f4d 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -77,16 +77,25 @@ export function resolveTranscriptWaiter( ctx: TranscriptWaiterContext, callId: CallId, transcript: string, -): void { + turnToken?: string, +): boolean { const waiter = ctx.transcriptWaiters.get(callId); if (!waiter) { - return; + return false; + } + if (waiter.turnToken && waiter.turnToken !== turnToken) { + return false; } clearTranscriptWaiter(ctx, callId); waiter.resolve(transcript); + return true; } -export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise { +export function waitForFinalTranscript( + ctx: TimerContext, + callId: CallId, + turnToken?: string, +): Promise { if (ctx.transcriptWaiters.has(callId)) { return Promise.reject(new Error("Already waiting for transcript")); } @@ -98,6 +107,6 @@ export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promi reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`)); }, timeoutMs); - ctx.transcriptWaiters.set(callId, { resolve, reject, timeout }); + ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken }); }); } diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 9739379cf584..5b5311acc736 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -30,6 +30,29 @@ export interface PlivoProviderOptions { type PendingSpeak = { text: string; locale?: string }; type PendingListen = { language?: string }; +function getHeader( + headers: Record, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function createPlivoRequestDedupeKey(ctx: WebhookContext): string { + const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce"); + if (nonceV3) { + return `plivo:v3:${nonceV3}`; + } + const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce"); + if (nonceV2) { + return `plivo:v2:${nonceV2}`; + } + return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`; +} + export class PlivoProvider implements VoiceCallProvider { readonly name = "plivo" as const; @@ -104,7 +127,7 @@ export class PlivoProvider implements VoiceCallProvider { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } - return { ok: result.ok, reason: result.reason }; + return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; } parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { @@ -173,7 +196,8 @@ export class PlivoProvider implements VoiceCallProvider { // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); - const event = this.normalizeEvent(parsed, callIdFromQuery); + const dedupeKey = createPlivoRequestDedupeKey(ctx); + const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { events: event ? [event] : [], @@ -186,7 +210,11 @@ export class PlivoProvider implements VoiceCallProvider { }; } - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + callIdOverride?: string, + dedupeKey?: string, + ): NormalizedEvent | null { const callUuid = params.get("CallUUID") || ""; const requestUuid = params.get("RequestUUID") || ""; @@ -201,6 +229,7 @@ export class PlivoProvider implements VoiceCallProvider { const baseEvent = { id: crypto.randomUUID(), + dedupeKey, callId: callIdOverride || callUuid || requestUuid, providerCallId: callUuid || requestUuid || undefined, timestamp: Date.now(), @@ -331,31 +360,40 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async playTts(input: PlayTtsInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; + private resolveCallContext(params: { + providerCallId: string; + callId: string; + operation: string; + }): { + callUuid: string; + webhookBase: string; + } { + const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId; const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); + this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId); if (!webhookBase) { throw new Error("Missing webhook URL for this call (provider state missing)"); } - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for playTts"); + throw new Error(`Missing Plivo CallUUID for ${params.operation}`); } + return { callUuid, webhookBase }; + } - const transferUrl = new URL(webhookBase); + private async transferCallLeg(params: { + callUuid: string; + webhookBase: string; + callId: string; + flow: "xml-speak" | "xml-listen"; + }): Promise { + const transferUrl = new URL(params.webhookBase); transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-speak"); - transferUrl.searchParams.set("callId", input.callId); - - this.pendingSpeakByCallId.set(input.callId, { - text: input.text, - locale: input.locale, - }); + transferUrl.searchParams.set("flow", params.flow); + transferUrl.searchParams.set("callId", params.callId); await this.apiRequest({ method: "POST", - endpoint: `/Call/${callUuid}/`, + endpoint: `/Call/${params.callUuid}/`, body: { legs: "aleg", aleg_url: transferUrl.toString(), @@ -364,35 +402,42 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async startListening(input: StartListeningInput): Promise { - const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId; - const webhookBase = - this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId); - if (!webhookBase) { - throw new Error("Missing webhook URL for this call (provider state missing)"); - } + async playTts(input: PlayTtsInput): Promise { + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "playTts", + }); - if (!callUuid) { - throw new Error("Missing Plivo CallUUID for startListening"); - } + this.pendingSpeakByCallId.set(input.callId, { + text: input.text, + locale: input.locale, + }); - const transferUrl = new URL(webhookBase); - transferUrl.searchParams.set("provider", "plivo"); - transferUrl.searchParams.set("flow", "xml-listen"); - transferUrl.searchParams.set("callId", input.callId); + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-speak", + }); + } + + async startListening(input: StartListeningInput): Promise { + const { callUuid, webhookBase } = this.resolveCallContext({ + providerCallId: input.providerCallId, + callId: input.callId, + operation: "startListening", + }); this.pendingListenByCallId.set(input.callId, { language: input.language, }); - await this.apiRequest({ - method: "POST", - endpoint: `/Call/${callUuid}/`, - body: { - legs: "aleg", - aleg_url: transferUrl.toString(), - aleg_method: "POST", - }, + await this.transferCallLeg({ + callUuid, + webhookBase, + callId: input.callId, + flow: "xml-listen", }); } diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 3a5652a35636..0d5c6de03d0d 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -59,4 +59,38 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain('"); }); + + it("uses a stable dedupeKey for identical request payloads", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-123" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA).events[0]; + const eventB = provider.parseWebhookEvent(ctxB).events[0]; + + expect(eventA).toBeDefined(); + expect(eventB).toBeDefined(); + expect(eventA?.id).not.toBe(eventB?.id); + expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123"); + expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey); + }); + + it("keeps turnToken from query on speech events", () => { + const provider = createProvider(); + const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", { + callId: "call-2", + turnToken: "turn-xyz", + }); + + const event = provider.parseWebhookEvent(ctx).events[0]; + expect(event?.type).toBe("call.speech"); + expect(event?.turnToken).toBe("turn-xyz"); + }); }); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 45031c351428..c1dbf6c7f4f8 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -20,6 +20,33 @@ import type { VoiceCallProvider } from "./base.js"; import { twilioApiRequest } from "./twilio/api.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; +function getHeader( + headers: Record, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function createTwilioRequestDedupeKey(ctx: WebhookContext): string { + const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token"); + if (idempotencyToken) { + return `twilio:idempotency:${idempotencyToken}`; + } + + const signature = getHeader(ctx.headers, "x-twilio-signature") ?? ""; + const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : ""; + const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; + const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : ""; + return `twilio:fallback:${crypto + .createHash("sha256") + .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`) + .digest("hex")}`; +} + /** * Twilio Voice API provider implementation. * @@ -212,7 +239,16 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; - const event = this.normalizeEvent(params, callIdFromQuery); + const turnTokenFromQuery = + typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim() + ? ctx.query.turnToken.trim() + : undefined; + const dedupeKey = createTwilioRequestDedupeKey(ctx); + const event = this.normalizeEvent(params, { + callIdOverride: callIdFromQuery, + dedupeKey, + turnToken: turnTokenFromQuery, + }); // For Twilio, we must return TwiML. Most actions are driven by Calls API updates, // so the webhook response is typically a pause to keep the call alive. @@ -245,14 +281,24 @@ export class TwilioProvider implements VoiceCallProvider { /** * Convert Twilio webhook params to normalized event format. */ - private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null { + private normalizeEvent( + params: URLSearchParams, + options?: { + callIdOverride?: string; + dedupeKey?: string; + turnToken?: string; + }, + ): NormalizedEvent | null { const callSid = params.get("CallSid") || ""; + const callIdOverride = options?.callIdOverride; const baseEvent = { id: crypto.randomUUID(), + dedupeKey: options?.dedupeKey, callId: callIdOverride || callSid, providerCallId: callSid, timestamp: Date.now(), + turnToken: options?.turnToken, direction: TwilioProvider.parseDirection(params.get("Direction")), from: params.get("From") || undefined, to: params.get("To") || undefined, @@ -603,9 +649,14 @@ export class TwilioProvider implements VoiceCallProvider { throw new Error("Missing webhook URL for this call (provider state not initialized)"); } + const actionUrl = new URL(webhookUrl); + if (input.turnToken) { + actionUrl.searchParams.set("turnToken", input.turnToken); + } + const twiml = ` - + `; diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 91fdfb2dc1e5..072e7f4f3999 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -28,5 +28,6 @@ export function verifyTwilioProviderWebhook(params: { return { ok: result.ok, reason: result.reason, + isReplay: result.isReplay, }; } diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 38091baa4d44..835b8ad8a1d0 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -74,9 +74,13 @@ export type EndReason = z.infer; const BaseEventSchema = z.object({ id: z.string(), + // Stable provider-derived key for idempotency/replay dedupe. + dedupeKey: z.string().optional(), callId: z.string(), providerCallId: z.string().optional(), timestamp: z.number(), + // Optional per-turn nonce for speech events (Twilio replay hardening). + turnToken: z.string().optional(), // Optional fields for inbound call detection direction: z.enum(["inbound", "outbound"]).optional(), from: z.string().optional(), @@ -171,6 +175,8 @@ export type CallRecord = z.infer; export type WebhookVerificationResult = { ok: boolean; reason?: string; + /** Signature is valid, but request was seen before within replay window. */ + isReplay?: boolean; }; export type WebhookContext = { @@ -226,6 +232,8 @@ export type StartListeningInput = { callId: CallId; providerCallId: ProviderCallId; language?: string; + /** Optional per-turn nonce for provider callbacks (replay hardening). */ + turnToken?: string; }; export type StopListeningInput = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 9ad662726a15..a047481125f5 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -163,6 +163,40 @@ describe("verifyPlivoWebhook", () => { expect(result.ok).toBe(false); expect(result.reason).toMatch(/Missing Plivo signature headers/); }); + + it("marks replayed valid V3 requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const nonce = "nonce-replay-v3"; + const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc"; + const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; + const signature = plivoV3Signature({ + authToken, + urlWithQuery, + postBody, + nonce, + }); + + const ctx = { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-plivo-signature-v3": signature, + "x-plivo-signature-v3-nonce": nonce, + }, + rawBody: postBody, + url: urlWithQuery, + method: "POST" as const, + query: { flow: "answer", callId: "abc" }, + }; + + const first = verifyPlivoWebhook(ctx, authToken); + const second = verifyPlivoWebhook(ctx, authToken); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); }); describe("verifyTwilioWebhook", () => { @@ -197,6 +231,48 @@ describe("verifyTwilioWebhook", () => { expect(result.ok).toBe(true); }); + it("marks replayed valid requests as replay without failing auth", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS777&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + const headers = { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-1", + }; + + const first = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); + it("rejects invalid signatures even when attacker injects forwarded host", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 7a8eccda5ae2..cc035b115b8d 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,6 +1,63 @@ import crypto from "node:crypto"; import type { WebhookContext } from "./types.js"; +const REPLAY_WINDOW_MS = 10 * 60 * 1000; +const REPLAY_CACHE_MAX_ENTRIES = 10_000; +const REPLAY_CACHE_PRUNE_INTERVAL = 64; + +type ReplayCache = { + seenUntil: Map; + calls: number; +}; + +const twilioReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +const plivoReplayCache: ReplayCache = { + seenUntil: new Map(), + calls: 0, +}; + +function sha256Hex(input: string): string { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function pruneReplayCache(cache: ReplayCache, now: number): void { + for (const [key, expiresAt] of cache.seenUntil) { + if (expiresAt <= now) { + cache.seenUntil.delete(key); + } + } + while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + const oldest = cache.seenUntil.keys().next().value; + if (!oldest) { + break; + } + cache.seenUntil.delete(oldest); + } +} + +function markReplay(cache: ReplayCache, replayKey: string): boolean { + const now = Date.now(); + cache.calls += 1; + if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) { + pruneReplayCache(cache, now); + } + + const existing = cache.seenUntil.get(replayKey); + if (existing && existing > now) { + return true; + } + + cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS); + if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) { + pruneReplayCache(cache, now); + } + return false; +} + /** * Validate Twilio webhook signature using HMAC-SHA1. * @@ -328,6 +385,8 @@ export interface TwilioVerificationResult { verificationUrl?: string; /** Whether we're running behind ngrok free tier */ isNgrokFreeTier?: boolean; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } export interface TelnyxVerificationResult { @@ -335,6 +394,20 @@ export interface TelnyxVerificationResult { reason?: string; } +function createTwilioReplayKey(params: { + ctx: WebhookContext; + signature: string; + verificationUrl: string; +}): string { + const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token"); + if (idempotencyToken) { + return `twilio:idempotency:${idempotencyToken}`; + } + return `twilio:fallback:${sha256Hex( + `${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`, + )}`; +} + function decodeBase64OrBase64Url(input: string): Buffer { // Telnyx docs say Base64; some tooling emits Base64URL. Accept both. const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); @@ -505,7 +578,9 @@ export function verifyTwilioWebhook( const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params); if (isValid) { - return { ok: true, verificationUrl }; + const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl }); + const isReplay = markReplay(twilioReplayCache, replayKey); + return { ok: true, verificationUrl, isReplay }; } // Check if this is ngrok free tier - the URL might have different format @@ -533,6 +608,8 @@ export interface PlivoVerificationResult { verificationUrl?: string; /** Signature version used for verification */ version?: "v3" | "v2"; + /** Request is cryptographically valid but was already processed recently. */ + isReplay?: boolean; } function normalizeSignatureBase64(input: string): string { @@ -753,14 +830,17 @@ export function verifyPlivoWebhook( url: verificationUrl, postParams, }); - return ok - ? { ok: true, version: "v3", verificationUrl } - : { - ok: false, - version: "v3", - verificationUrl, - reason: "Invalid Plivo V3 signature", - }; + if (!ok) { + return { + ok: false, + version: "v3", + verificationUrl, + reason: "Invalid Plivo V3 signature", + }; + } + const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v3", verificationUrl, isReplay }; } if (signatureV2 && nonceV2) { @@ -770,14 +850,17 @@ export function verifyPlivoWebhook( nonce: nonceV2, url: verificationUrl, }); - return ok - ? { ok: true, version: "v2", verificationUrl } - : { - ok: false, - version: "v2", - verificationUrl, - reason: "Invalid Plivo V2 signature", - }; + if (!ok) { + return { + ok: false, + version: "v2", + verificationUrl, + reason: "Invalid Plivo V2 signature", + }; + } + const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`; + const isReplay = markReplay(plivoReplayCache, replayKey); + return { ok: true, version: "v2", verificationUrl, isReplay }; } return { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 51afdb7eba01..8dcf3346342c 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -45,12 +45,14 @@ const createCall = (startedAt: number): CallRecord => ({ const createManager = (calls: CallRecord[]) => { const endCall = vi.fn(async () => ({ success: true })); + const processEvent = vi.fn(); const manager = { getActiveCalls: () => calls, endCall, + processEvent, } as unknown as CallManager; - return { manager, endCall }; + return { manager, endCall, processEvent }; }; describe("VoiceCallWebhookServer stale call reaper", () => { @@ -116,3 +118,51 @@ describe("VoiceCallWebhookServer stale call reaper", () => { } }); }); + +describe("VoiceCallWebhookServer replay handling", () => { + it("acknowledges replayed webhook requests and skips event side effects", async () => { + const replayProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, isReplay: true }), + parseWebhookEvent: () => ({ + events: [ + { + id: "evt-replay", + dedupeKey: "stable-replay", + type: "call.speech", + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + }), + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, replayProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(processEvent).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); +}); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index ec052342285a..4b778e3a8d76 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -346,11 +346,15 @@ export class VoiceCallWebhookServer { const result = this.provider.parseWebhookEvent(ctx); // Process each event - for (const event of result.events) { - try { - this.manager.processEvent(event); - } catch (err) { - console.error(`[voice-call] Error processing event ${event.type}:`, err); + if (verification.isReplay) { + console.warn("[voice-call] Replay detected; skipping event side effects"); + } else { + for (const event of result.events) { + try { + this.manager.processEvent(event); + } catch (err) { + console.error(`[voice-call] Error processing event ${event.type}:`, err); + } } } diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 819c3c2ab307..60dd015f6ade 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index b122577e2e82..a5554cd4c5e8 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -4,7 +4,6 @@ import { collectWhatsAppStatusIssues, createActionGate, DEFAULT_ACCOUNT_ID, - escapeRegExp, formatPairingApproveHint, getChatChannelMeta, listWhatsAppAccountIds, @@ -14,8 +13,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, + normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, - normalizeWhatsAppTarget, readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, @@ -23,8 +22,10 @@ import { resolveDefaultGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripPatterns, whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, @@ -114,12 +115,7 @@ export const whatsappPlugin: ChannelPlugin = { }), resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => { const root = cfg.channels?.whatsapp; const normalized = normalizeAccountId(accountId); @@ -211,18 +207,10 @@ export const whatsappPlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID (group participant id).", + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) { - return []; - } - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, + stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx), }, commands: { enforceOwnerForCommands: true, diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 86295a310ef6..51bcd15bad30 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -71,8 +71,10 @@ vi.mock("openclaw/plugin-sdk", () => ({ readStringParam: vi.fn(), resolveDefaultWhatsAppAccountId: vi.fn(), resolveWhatsAppAccount: vi.fn(), + resolveWhatsAppGroupIntroHint: vi.fn(), resolveWhatsAppGroupRequireMention: vi.fn(), resolveWhatsAppGroupToolPolicy: vi.fn(), + resolveWhatsAppMentionStripPatterns: vi.fn(() => []), applyAccountNameToChannelSection: vi.fn(), })); diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index f0edd3e3a764..b7172deaaeeb 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 318220f8c16f..a5fca946ca7a 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -3,7 +3,7 @@ import type { ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk"; -import { jsonResult, readStringParam } from "openclaw/plugin-sdk"; +import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk"; import { listEnabledZaloAccounts } from "./accounts.js"; import { sendMessageZalo } from "./send.js"; @@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsButtons: () => false, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId }) => { if (action === "send") { const to = readStringParam(params, "to", { required: true }); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b7f9fce996d2..9e263f0bff8d 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -7,6 +7,7 @@ import type { import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, @@ -309,17 +310,7 @@ export const zaloPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectZaloStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 6b253d3cd7b6..47269635a442 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; +import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { createDedupeCache, createReplyPrefixOptions, @@ -9,6 +9,8 @@ import { rejectNonPostWebhookRequest, resolveSingleWebhookTarget, resolveSenderCommandAuthorization, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, resolveWebhookPath, resolveWebhookTargets, requestBodyErrorToText, @@ -681,7 +683,7 @@ async function processMessageWithPipeline(params: { } async function deliverZaloReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; token: string; chatId: string; runtime: ZaloRuntimeEnv; @@ -696,24 +698,18 @@ async function deliverZaloReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Zalo photo send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index c779e291159c..7a76c1553f2d 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 17575c401285..7e2ff850d40a 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,11 +1,18 @@ import type { ChildProcess } from "node:child_process"; -import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { + MarkdownTableMode, + OpenClawConfig, + OutboundReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + resolveOutboundMediaUrls, mergeAllowlist, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, + sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; @@ -392,7 +399,7 @@ async function processMessage( } async function deliverZalouserReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; profile: string; chatId: string; isGroup: boolean; @@ -408,29 +415,23 @@ async function deliverZalouserReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - logVerbose(core, runtime, `Sending media to ${chatId}`); - await sendMessageZalouser(chatId, caption ?? "", { - profile, - mediaUrl, - isGroup, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser media send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + logVerbose(core, runtime, `Sending media to ${chatId}`); + await sendMessageZalouser(chatId, caption ?? "", { + profile, + mediaUrl, + isGroup, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error(`Zalouser media send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/package.json b/package.json index 2799e9ca61bd..1faf25ec9964 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.22-2", + "version": "2026.2.23", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e13c90548d6..e44dcd4a2b3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,13 +280,13 @@ importers: '@opentelemetry/api-logs': specifier: ^0.212.0 version: 0.212.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': + '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': + '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': @@ -334,12 +334,6 @@ importers: specifier: workspace:* version: link:../.. - extensions/google-antigravity-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-gemini-cli-auth: devDependencies: openclaw: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000000..b69da03be53b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82", "I"] + +[tool.pytest.ini_options] +testpaths = ["skills"] +python_files = ["test_*.py"] diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh index 437c68e8bebb..df5c249caf3a 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -19,7 +19,8 @@ ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then - if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + # Accept legacy calver suffixes like -1 and prerelease forms like -beta.1 / .beta.1. + if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?)\.zip$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 7e2bd4490445..0ccc3efc1de4 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,7 +22,12 @@ type PackageJson = { }; function normalizePluginSyncVersion(version: string): string { - return version.replace(/[-+].*$/, ""); + const normalized = version.trim().replace(/^v/, ""); + const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; + if (base) { + return base; + } + return normalized.replace(/[-+].*$/, ""); } function runPackDry(): PackResult[] { diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index bed23a431fd5..0ec8d2fdc5f3 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -19,6 +19,25 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Process supervision + docker setup suites are stable but setup-heavy. + "src/process/supervisor/supervisor.test.ts", + "src/docker-setup.test.ts", + // Filesystem-heavy skills sync suite. + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + // Real git hook integration test; keep signal, move off unit-fast critical path. + "test/git-hooks-pre-commit.test.ts", + // Setup-heavy doctor command suites; keep them off the unit-fast critical path. + "src/commands/doctor.warns-state-directory-is-missing.test.ts", + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", + // Setup-heavy CLI update flow suite; move off unit-fast critical path. + "src/cli/update-cli.test.ts", + // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. + "src/config/schema.test.ts", + "src/config/schema.tags.test.ts", + // CLI smoke/agent flows are stable but setup-heavy. + "src/cli/program.smoke.test.ts", + "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", "src/web/media.test.ts", @@ -43,22 +62,15 @@ const unitIsolatedFilesRaw = [ "src/agents/subagent-announce.format.test.ts", "src/infra/archive.test.ts", "src/cli/daemon-cli.coverage.test.ts", - "test/media-understanding.auto.test.ts", // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts", "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. "src/telegram/bot.create-telegram-bot.test.ts", @@ -164,16 +176,21 @@ const testProfile = const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; -// Keep gateway serial on Windows CI and CI by default; run in parallel locally -// for lower wall-clock time. CI can opt in via OPENCLAW_TEST_PARALLEL_GATEWAY=1. +const hostCpuCount = os.cpus().length; +const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); +// Keep aggressive local defaults for high-memory workstations (Mac Studio class). +const highMemLocalHost = !isCI && hostMemoryGiB >= 96; +const lowMemLocalHost = !isCI && hostMemoryGiB < 64; +const parallelGatewayEnabled = + process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); +// Keep gateway serial by default except when explicitly requested or on high-memory local hosts. const keepGatewaySerial = isWindowsCi || process.env.OPENCLAW_TEST_SERIAL_GATEWAY === "1" || testProfile === "serial" || - (isCI && process.env.OPENCLAW_TEST_PARALLEL_GATEWAY !== "1"); + !parallelGatewayEnabled; const parallelRuns = keepGatewaySerial ? runs.filter((entry) => entry.name !== "gateway") : runs; const serialRuns = keepGatewaySerial ? runs.filter((entry) => entry.name === "gateway") : []; -const hostCpuCount = os.cpus().length; const baseLocalWorkers = Math.max(4, Math.min(16, hostCpuCount)); const loadAwareDisabledRaw = process.env.OPENCLAW_TEST_LOAD_AWARE?.trim().toLowerCase(); const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false"; @@ -206,15 +223,29 @@ const defaultWorkerBudget = extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), } - : { - // Local `pnpm test` runs multiple vitest groups concurrently; - // bias workers toward unit-fast (wall-clock bottleneck) while - // keeping unit-isolated low enough that both groups finish closer together. - unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), - unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), - extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(4, Math.floor(localWorkers / 3))), - }; + : highMemLocalHost + ? { + // High-memory local hosts can prioritize wall-clock speed. + unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), + unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + } + : lowMemLocalHost + ? { + // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. + unit: 2, + unitIsolated: 1, + extensions: 1, + gateway: 1, + } + : { + // 64-95 GiB local hosts: conservative split with some parallel headroom. + unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), + unitIsolated: 1, + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: 1, + }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. // In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b0193..0e106e65969d 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -15,7 +15,6 @@ const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); const readmePath = resolve("README.md"); -const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; const seedCommit = mapConfig.seedCommit ?? null; const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); @@ -98,33 +97,33 @@ for (const login of ensureLogins) { const entriesByKey = new Map(); for (const seed of seedEntries) { - const login = loginFromUrl(seed.html_url); - const resolvedLogin = - login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); - const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; - const avatar = - seed.avatar_url && !isGhostAvatar(seed.avatar_url) - ? normalizeAvatar(seed.avatar_url) - : placeholderAvatar; + const login = + loginFromUrl(seed.html_url) ?? + resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + const key = login.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(login); + if (!user) { + continue; + } + apiByLogin.set(key, user); const existing = entriesByKey.get(key); if (!existing) { - const user = resolvedLogin ? apiByLogin.get(key) : null; entriesByKey.set(key, { key, - login: resolvedLogin ?? login ?? undefined, + login: user.login, display: seed.display, - html_url: user?.html_url ?? seed.html_url, - avatar_url: user?.avatar_url ?? avatar, + html_url: user.html_url, + avatar_url: user.avatar_url, lines: 0, }); } else { existing.display = existing.display || seed.display; - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - existing.avatar_url = avatar; - } - if (!existing.html_url || existing.html_url.includes("/search?q=")) { - existing.html_url = seed.html_url; - } + existing.login = user.login; + existing.html_url = user.html_url; + existing.avatar_url = user.avatar_url; } } @@ -138,52 +137,37 @@ for (const item of contributors) { ? item.login : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); - if (resolvedLogin) { - const key = resolvedLogin.toLowerCase(); - const existing = entriesByKey.get(key); - if (!existing) { - let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - entriesByKey.set(key, { - key, - login: user.login, - display: pickDisplay(baseName, user.login, existing?.display), - html_url: user.html_url, - avatar_url: normalizeAvatar(user.avatar_url), - lines: lines > 0 ? lines : contributions, - }); - } - } else if (existing) { - existing.login = existing.login ?? resolvedLogin; - existing.display = pickDisplay(baseName, existing.login, existing.display); - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - existing.html_url = user.html_url; - existing.avatar_url = normalizeAvatar(user.avatar_url); - } - } - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); - } + if (!resolvedLogin) { + continue; + } + + const key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { continue; } + apiByLogin.set(key, user); - const anonKey = `name:${normalizeName(baseName)}`; - const existingAnon = entriesByKey.get(anonKey); - if (!existingAnon) { - entriesByKey.set(anonKey, { - key: anonKey, - display: baseName, - html_url: fallbackHref(baseName), - avatar_url: placeholderAvatar, - lines: item.contributions ?? 0, + const existing = entriesByKey.get(key); + if (!existing) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, }); } else { - existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + existing.login = user.login; + existing.display = pickDisplay(baseName, user.login, existing.display); + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); } } @@ -205,14 +189,6 @@ for (const [login, lines] of linesByLogin.entries()) { avatar_url: normalizeAvatar(user.avatar_url), lines: lines > 0 ? lines : contributions, }); - } else { - entriesByKey.set(login, { - key: login, - display: login, - html_url: fallbackHref(login), - avatar_url: placeholderAvatar, - lines, - }); } } @@ -323,10 +299,6 @@ function normalizeAvatar(url: string): string { return `${url}${sep}s=48`; } -function isGhostAvatar(url: string): boolean { - return url.toLowerCase().includes("ghost.png"); -} - function fetchUser(login: string): User | null { const normalized = normalizeLogin(login); if (!normalized) { diff --git a/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py index 0b71f96ea0f6..ea28fa0d983e 100644 --- a/skills/model-usage/scripts/model_usage.py +++ b/skills/model-usage/scripts/model_usage.py @@ -17,6 +17,16 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return parsed + + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -239,7 +249,7 @@ def main() -> int: parser.add_argument("--mode", choices=["current", "all"], default="current") parser.add_argument("--model", help="Explicit model name to report instead of auto-current.") parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).") - parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).") + parser.add_argument("--days", type=positive_int, help="Limit to last N days (based on daily rows).") parser.add_argument("--format", choices=["text", "json"], default="text") parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") diff --git a/skills/model-usage/scripts/test_model_usage.py b/skills/model-usage/scripts/test_model_usage.py new file mode 100644 index 000000000000..4d5273401de1 --- /dev/null +++ b/skills/model-usage/scripts/test_model_usage.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Tests for model_usage helpers. +""" + +import argparse +from datetime import date, timedelta +from unittest import TestCase, main + +from model_usage import filter_by_days, positive_int + + +class TestModelUsage(TestCase): + def test_positive_int_accepts_valid_numbers(self): + self.assertEqual(positive_int("1"), 1) + self.assertEqual(positive_int("7"), 7) + + def test_positive_int_rejects_zero_and_negative(self): + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("0") + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("-3") + + def test_filter_by_days_keeps_recent_entries(self): + today = date.today() + entries = [ + {"date": (today - timedelta(days=5)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": today.strftime("%Y-%m-%d"), "modelBreakdowns": []}, + ] + + filtered = filter_by_days(entries, 2) + + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0]["date"], (today - timedelta(days=1)).strftime("%Y-%m-%d")) + self.assertEqual(filtered[1]["date"], today.strftime("%Y-%m-%d")) + + +if __name__ == "__main__": + main() diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index 3365c20077ff..8d60882c4561 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -95,12 +95,13 @@ def main(): max_input_dim = 0 for img_path in args.input_images: try: - img = PILImage.open(img_path) - input_images.append(img) + with PILImage.open(img_path) as img: + copied = img.copy() + width, height = copied.size + input_images.append(copied) print(f"Loaded input image: {img_path}") # Track largest dimension for auto-resolution - width, height = img.size max_input_dim = max(max_input_dim, width, height) except Exception as e: print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py index 7bd59e361264..4043f1a8ed73 100644 --- a/skills/openai-image-gen/scripts/gen.py +++ b/skills/openai-image-gen/scripts/gen.py @@ -9,6 +9,7 @@ import sys import urllib.error import urllib.request +from html import escape as html_escape from pathlib import Path @@ -131,8 +132,8 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: [ f"""
- -
{it["prompt"]}
+ +
{html_escape(it["prompt"])}
""".strip() for it in items @@ -152,7 +153,7 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: code {{ color: #9cd1ff; }}

openai-image-gen

-

Output: {out_dir.as_posix()}

+

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

{thumbs}
diff --git a/skills/openai-image-gen/scripts/test_gen.py b/skills/openai-image-gen/scripts/test_gen.py new file mode 100644 index 000000000000..3f0a38d978f3 --- /dev/null +++ b/skills/openai-image-gen/scripts/test_gen.py @@ -0,0 +1,50 @@ +"""Tests for write_gallery HTML escaping (fixes #12538 - stored XSS).""" + +import tempfile +from pathlib import Path + +from gen import write_gallery + + +def test_write_gallery_escapes_prompt_xss(): + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) + items = [{"prompt": '', "file": "001-test.png"}] + write_gallery(out, items) + html = (out / "index.html").read_text() + assert "